@avi2dg/checks 0.44.0 → 0.45.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,22 @@
2
2
 
3
3
  Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
4
4
 
5
+ ## 0.45.1
6
+
7
+ Released 2026-10-19.
8
+
9
+ ### Fixes
10
+
11
+ - **quality:** pass @property in the Baseline CSS gate at every target [#169](https://github.com/avi2d/checks/pull/169)
12
+
13
+ ## 0.45.0
14
+
15
+ Released 2026-10-18.
16
+
17
+ ### Breaking changes
18
+
19
+ - **quality:** hold CSS to the stylelint rule families set in stylelint.config.ts [#167](https://github.com/avi2d/checks/pull/167)
20
+
5
21
  ## 0.44.0
6
22
 
7
23
  Released 2026-10-18.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # checks
2
2
 
3
3
  `@avi2dg/checks` is the kit of deterministic checks a TypeScript repository installs to hold its code, tests, commits, CI wiring and docs to one shared standard.
4
- It ships the lint gates `checks-lint` runs over each pull request, the test runners, and a `defineConfig` for oxlint, Knip and dependency-cruiser.
4
+ It ships the lint gates `checks-lint` runs over each pull request, the test runners, and a `defineConfig` for oxlint, Knip, dependency-cruiser and stylelint.
5
5
  It also ships the configs a repository extends for tsc, commitlint, bun and Stryker.
6
6
  Each repository owns its workflows and native tool configs, as [Native settings](docs/configs/native-settings.md) maps.
7
7
 
@@ -26,6 +26,8 @@ Each repository owns its workflows and native tool configs, as [Native settings]
26
26
  - `playwright-core` 1.63.0
27
27
  - `postcss-html` 2.0.0
28
28
  - `stylelint` 17.16.0
29
+ - `stylelint-config-standard` 40.0.0
30
+ - `stylelint-plugin-use-baseline` 1.4.7
29
31
 
30
32
  <!-- end generated prerequisites -->
31
33
 
@@ -234,7 +236,7 @@ Every path is relative to the installed package, `node_modules/@avi2dg/checks/`.
234
236
  | `dependency-cruiser.config.js` | the kit's dependency-cruiser rules as a base a `.dependency-cruiser.cjs` extends by path, which the build writes |
235
237
  | `knip-base.json` | the Knip `include` setting, for a configuration that spreads it |
236
238
  | `src/` | every bin, which a package script calls by its `checks-` name, the modules the bins import, and the Effect language service severities under `src/quality/presets/` |
237
- | `dist/` | the compiled oxlint plugins, the config builders `@avi2dg/checks/oxlint`, `@avi2dg/checks/knip` and `@avi2dg/checks/dependency-cruiser` resolve to, and the doc templates, one template per kind of doc file |
239
+ | `dist/` | the compiled oxlint plugins, the config builders `@avi2dg/checks/oxlint`, `@avi2dg/checks/knip`, `@avi2dg/checks/dependency-cruiser` and `@avi2dg/checks/stylelint` resolve to, and the doc templates, one template per kind of doc file |
238
240
  | `oxlintrc.json` | the oxlint `base` as a config a `.oxlintrc.json` extends by path, which the build writes |
239
241
  | `stryker.preset.js` | the Stryker mutation-testing preset, which refuses a full run outside CI |
240
242
  | `tsconfig.effect.json` | the tsconfig fragment with the shared compiler options and the Effect language-service block |
@@ -0,0 +1,10 @@
1
+ import type { Config } from "stylelint";
2
+ type Globs = readonly [string, ...string[]];
3
+ export type BaselineTarget = "widely" | "newly" | number;
4
+ export type ChecksStylelintConfig = Omit<Config, "extends" | "plugins"> & {
5
+ readonly baseline: BaselineTarget;
6
+ readonly tokens: Globs | false;
7
+ };
8
+ export declare const MARKUP: readonly ["**/*.html", "**/*.astro", "**/*.vue", "**/*.svelte"];
9
+ export declare function defineConfig({ baseline, tokens, rules, overrides, ...rest }: ChecksStylelintConfig): Config;
10
+ export {};
@@ -0,0 +1,84 @@
1
+ // src/quality/presets/stylelint.ts
2
+ import { fileURLToPath } from "node:url";
3
+ var HTML_AND_ASTRO = ["**/*.html", "**/*.astro"];
4
+ var MARKUP = [...HTML_AND_ASTRO, "**/*.vue", "**/*.svelte"];
5
+ function peer(name) {
6
+ return fileURLToPath(import.meta.resolve(name));
7
+ }
8
+ var CONVENTION_CHANGES = {
9
+ "no-descending-specificity": null,
10
+ "property-no-vendor-prefix": [true, { disableFix: true, ignoreProperties: ["-webkit-user-select"] }]
11
+ };
12
+ var SELECTOR_COMPLEXITY = {
13
+ "selector-max-specificity": "0,3,2",
14
+ "selector-max-compound-selectors": 3
15
+ };
16
+ var COLOUR_FUNCTIONS = ["rgb", "rgba", "hsl", "hsla", "hwb", "lab", "lch", "oklab", "oklch", "color"];
17
+ var DESIGN_TOKENS = {
18
+ "color-no-hex": true,
19
+ "color-named": "never",
20
+ "function-disallowed-list": COLOUR_FUNCTIONS.map((name) => `/^${name}$/i`),
21
+ "declaration-property-value-allowed-list": { "/^font-size$/i": ["/^var\\(\\s*--[^\\s,()]+\\s*\\)$/i", "/^inherit$/i"] }
22
+ };
23
+ var REFUSED_BASELINE = '@avi2dg/checks/stylelint: set baseline to "widely", "newly" or a year such as 2024';
24
+ var REFUSED_TOKENS = "@avi2dg/checks/stylelint: set tokens to the globs of the token files, or false to leave the design token rules off";
25
+ function baselineTarget(baseline) {
26
+ if (baseline === "widely" || baseline === "newly")
27
+ return baseline;
28
+ if (typeof baseline === "number" && Number.isInteger(baseline))
29
+ return baseline;
30
+ throw new TypeError(REFUSED_BASELINE);
31
+ }
32
+ function isGlobList(value) {
33
+ return Array.isArray(value) && value.every((glob) => typeof glob === "string" && glob !== "");
34
+ }
35
+ function tokenFiles(tokens) {
36
+ if (tokens === false)
37
+ return [];
38
+ if (isGlobList(tokens) && tokens.length > 0)
39
+ return tokens;
40
+ throw new TypeError(REFUSED_TOKENS);
41
+ }
42
+ var BASELINE_RULE = "plugin/use-baseline";
43
+ function passProperty(entry) {
44
+ if (entry === null || entry === undefined)
45
+ return entry;
46
+ const [primary, options = {}] = Array.isArray(entry) ? entry : [entry];
47
+ const ignored = Reflect.get(options, "ignoreAtRules") ?? [];
48
+ if (!Array.isArray(ignored))
49
+ return entry;
50
+ return [primary, { ...options, ignoreAtRules: ["property", ...ignored] }];
51
+ }
52
+ function designTokens(files) {
53
+ if (files.length === 0)
54
+ return { rules: {}, overrides: [] };
55
+ const off = Object.fromEntries(Object.keys(DESIGN_TOKENS).map((rule) => [rule, null]));
56
+ return { rules: DESIGN_TOKENS, overrides: [{ files: [...files], rules: off }] };
57
+ }
58
+ function defineConfig({ baseline, tokens, rules, overrides = [], ...rest }) {
59
+ const available = baselineTarget(baseline);
60
+ const tokenRules = designTokens(tokenFiles(tokens));
61
+ const merged = {
62
+ ...CONVENTION_CHANGES,
63
+ ...SELECTOR_COMPLEXITY,
64
+ [BASELINE_RULE]: [true, { available }],
65
+ ...tokenRules.rules,
66
+ ...rules
67
+ };
68
+ return {
69
+ ...rest,
70
+ extends: [peer("stylelint-config-standard")],
71
+ plugins: [peer("stylelint-plugin-use-baseline")],
72
+ rules: { ...merged, [BASELINE_RULE]: passProperty(merged[BASELINE_RULE]) },
73
+ overrides: [
74
+ { files: [...MARKUP], customSyntax: peer("postcss-html") },
75
+ { files: [...HTML_AND_ASTRO], rules: { "declaration-block-single-line-max-declarations": null } },
76
+ ...tokenRules.overrides,
77
+ ...overrides
78
+ ]
79
+ };
80
+ }
81
+ export {
82
+ MARKUP,
83
+ defineConfig
84
+ };
@@ -13,9 +13,11 @@ A consuming repository puts each setting in the file its tool reads, and no mani
13
13
  | `.github/workflows/*.yml` | Pull request commands, scheduled commands, runner labels | GitHub Actions and `checks-ci-wiring` |
14
14
  | `oxlint.config.ts` | Effect paths and their exemptions, ignore patterns, and any rule or budget the repository sets over the kit's defaults | oxlint and `checks-effect-scope` |
15
15
  | `oxlint-suppressions.json` | The existing violations oxlint suppresses, per file and rule | oxlint and `checks-suppressions-ratchet` |
16
+ | `stylelint-suppressions.json` | The existing violations stylelint suppresses, per file and rule | stylelint, `checks-frontend-syntax` and `checks-suppressions-ratchet` |
16
17
  | `exports-baseline.json` | The existing unused exports and types, per file, kind and name | `checks-exports` |
17
18
  | `.jscpd.json` | The `path` and `ignore` globs of the files repetition is measured in | jscpd and `checks-repetition` |
18
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` |
19
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` |
20
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 |
21
23
  | `package.json` | `scripts` with the `checks-vendor` arguments in `prepare`, `author` and `contributors` | Bun, `checks-commit-identity` and `checks-vendor` |
@@ -29,7 +31,7 @@ A page whose front matter sets `audience: consumers` names commands that a consu
29
31
 
30
32
  ## The config builders
31
33
 
32
- The kit ships a `defineConfig` for oxlint, Knip and dependency-cruiser.
34
+ The kit ships a `defineConfig` for oxlint, Knip, dependency-cruiser and stylelint.
33
35
  Each takes the tool's own config type, less the keys the kit owns, and returns the whole config with a default for every kit rule.
34
36
  A key only the repository knows has no default, so `tsc` refuses a config that leaves it out.
35
37
  The builder throws when the tool loads such a config anyway.
@@ -39,6 +41,7 @@ The builder throws when the tool loads such a config anyway.
39
41
  | `@avi2dg/checks/oxlint` | `effect` | `extends`, `plugins`, `jsPlugins` and `categories` |
40
42
  | `@avi2dg/checks/knip` | `entry`, where `[]` means the package scripts and tests name every entry | `include` |
41
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` |
42
45
 
43
46
  A repository whose sources are Effect programs writes `oxlint.config.ts`:
44
47
 
@@ -75,7 +78,16 @@ export default defineConfig({ devOnly: ["^(?:evals|tests)/"] });
75
78
  ```
76
79
 
77
80
  [The dependency rules](dependency-rules.md) lists its keys.
78
- `tsconfig.json` keeps the three config files in its program, so `bun run typecheck` holds them to the builders' types.
81
+
82
+ A repository that declares `frontend-syntax.json` writes `stylelint.config.ts`, which [checks-frontend-syntax](../gates/checks-frontend-syntax.md) loads and describes:
83
+
84
+ ```ts
85
+ import { defineConfig } from "@avi2dg/checks/stylelint";
86
+
87
+ export default defineConfig({ baseline: "widely", tokens: false });
88
+ ```
89
+
90
+ `tsconfig.json` keeps the config files in its program, so `bun run typecheck` holds them to the builders' types.
79
91
 
80
92
  ## Size limits
81
93
 
@@ -108,4 +120,5 @@ overrides: [{ files: ["src/lib/server.ts"], rules: { "max-lines": ["error", { ma
108
120
  - [The dependency rules](dependency-rules.md)
109
121
  - [checks-ci-wiring](../gates/checks-ci-wiring.md)
110
122
  - [checks-suppressions-ratchet](../gates/checks-suppressions-ratchet.md)
123
+ - [checks-frontend-syntax](../gates/checks-frontend-syntax.md)
111
124
  - [checks-docs](../gates/checks-docs.md)
package/docs/design.md CHANGED
@@ -302,7 +302,12 @@ So a product opts in by declaring `browser-checks.json`, `checks-lint` never sta
302
302
  `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.
303
303
 
304
304
  Each check wraps a detector that already works rather than a rule of the kit's own.
305
- The syntax gate runs two of stylelint's built-in rules, `axe` runs axe-core and `nesting` runs one html-validate rule.
305
+ 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.
306
+ Its Baseline family runs `stylelint-plugin-use-baseline`, the one stylelint rule that judges against the Baseline data rather than a browser list.
307
+ Its design token family is stylelint's own colour and value rules, applied outside the files a repository names as its tokens.
308
+ A check of the rendered page against declared tokens would need a token format of the kit's own.
309
+ The repository's `stylelint.config.ts` sets the Baseline target and the token files, since only the repository knows either.
310
+ 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.
306
311
  The `layout`, `keyboard` and `motion` probes are the website's own browser tests, generalized after they held on its English and Russian pages.
307
312
  A zoom-disabling viewport is judged by axe on the rendered page, because stylelint reads only CSS and html-validate has no rule for the viewport's content.
308
313
 
@@ -10,6 +10,8 @@ audience: consumers
10
10
 
11
11
  It fails when an added line carries a banned comment.
12
12
  It refuses a machine-read directive, a record or ticket pointer, a doc block, and a file opening with a rationale block over three lines, licence headers excepted.
13
+ The directives are `@ts-expect-error`, `@ts-ignore`, `biome-ignore`, `prettier-ignore`, and every directive that begins `eslint-disable` or `stylelint-disable`, such as `stylelint-disable-next-line`.
14
+ A stylesheet keeps its count of existing stylelint problems in `stylelint-suppressions.json` instead, as [checks-frontend-syntax](checks-frontend-syntax.md) says.
13
15
  Each refusal says what to write instead.
14
16
  Only added lines are checked, so a violation in a file the diff never touches stays silent.
15
17
  A refusal counts when any line of the comment carrying it was added.
@@ -4,11 +4,18 @@ audience: consumers
4
4
  ---
5
5
  # checks-frontend-syntax
6
6
 
7
- `checks-frontend-syntax` is the opt-in gate that refuses two CSS constraints a program can decide: a transition over `all` and a body-wide `user-select`.
7
+ `checks-frontend-syntax` is the opt-in gate that holds a repository's CSS to stylelint, under the kit's two rules of its own and the rule families of `@avi2dg/checks/stylelint`.
8
8
 
9
9
  ## What it checks
10
10
 
11
- It runs stylelint with two of stylelint's own rules, and with no other rule and no repository config:
11
+ It runs stylelint twice over each declared input.
12
+ The repository run holds the families the repository's `stylelint.config.ts` sets, and applies `--fix`.
13
+ The kit run then holds the kit's two rules over each file the repository run matched.
14
+ It reads no repository config and no count in `stylelint-suppressions.json`, so neither turns those rules off.
15
+
16
+ ### The kit's two rules
17
+
18
+ The kit run refuses two CSS constraints a program can decide, a transition over `all` and a body-wide `user-select`:
12
19
 
13
20
  | Rule | What it refuses | Why |
14
21
  | --- | --- | --- |
@@ -19,7 +26,37 @@ The second rule refuses any value on those selectors, so `body { user-select: te
19
26
  It matches each item of the selector list as a whole.
20
27
  So it does not follow `:is()`, `:where()` or `:not()`, nor a compound such as `html body` or `body *`.
21
28
  A nested rule is judged by its own selector, so `.card { * { user-select: none; } }` fails as `*`.
22
- A control that needs selection off sets it on its own selector, such as `.drag-handle { user-select: none; }`, which passes.
29
+ A control that needs selection off sets it on its own selector, such as `.drag-handle { user-select: none; }`, which this rule passes and the Baseline family reports.
30
+
31
+ ### The rule families
32
+
33
+ `defineConfig` from `@avi2dg/checks/stylelint` builds the config the repository run holds:
34
+
35
+ | Family | Rules | Default |
36
+ | --- | --- | --- |
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
+ | G2 conventions | `stylelint-config-standard`, which extends G1 with the stylelint team's notation and spacing rules | on |
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 |
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
+
43
+ The builder changes three of the shared config's rules:
44
+
45
+ - `no-descending-specificity` is off, since it compares only the last compound selector and has no access to the DOM.
46
+ It reports components that never match one element.
47
+ - `property-no-vendor-prefix` keeps reporting without a fix, and passes `-webkit-user-select`, since its fix deletes the prefix Safari still needs.
48
+ - In an `.html` or `.astro` file, `declaration-block-single-line-max-declarations` is off, since a `style` attribute is one line.
49
+
50
+ CSS repetition is not a family of this gate: a repository adds `"css"` to `format` in `.jscpd.json`, and [checks-repetition](checks-repetition.md) then fails when a stylesheet's repeated lines grow.
51
+
52
+ ### What no comment turns off
53
+
54
+ It sets stylelint's `ignoreDisables`, so a `stylelint-disable` comment silences nothing in either run, and [checks-comment-gate](checks-comment-gate.md) refuses one on the line that adds it.
55
+ A repository adopts a family over existing problems by counting them in `stylelint-suppressions.json` with `--suppress`.
56
+ stylelint lets a file and rule through while its problems stay within the count, and [checks-suppressions-ratchet](checks-suppressions-ratchet.md) fails a change that raises a count.
57
+ `--suppress` counts only what the repository run reports, and a file that does not parse still fails in the kit run.
58
+
59
+ ### Files
23
60
 
24
61
  It judges each `.css` file, and each `<style>` block and `style` attribute in an `.html`, `.astro`, `.vue` or `.svelte` file, through `postcss-html`.
25
62
  It reports a file that does not parse, since it cannot judge one.
@@ -28,7 +65,7 @@ It fails on each declared input that matches no file, so an input that a renamed
28
65
  These are outside what it decides:
29
66
 
30
67
  - A `transition` shorthand that names no property, such as `transition: 200ms`, which the browser reads as `all`.
31
- - A value it cannot see, such as `transition: var(--motion)` or a style a script sets.
68
+ - 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.
32
69
  - `user-select: none` in a `style` attribute on `<body>`, which has no selector to judge.
33
70
  - A viewport that disables zoom, since stylelint reads only CSS.
34
71
  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.
@@ -49,35 +86,71 @@ A glob over built output, such as `dist/**/*.html`, judges the styles a framewor
49
86
  stylelint resolves each glob from that directory, skips `node_modules/` and honours `.stylelintignore`.
50
87
  It reads the working tree, so an uncommitted file is judged like a committed one.
51
88
 
52
- The repository installs the two optional peers the gate loads, at the versions the kit pins:
89
+ It imports `stylelint.config.ts` from the same directory, and passes its default export to stylelint:
90
+
91
+ ```ts
92
+ import { defineConfig } from "@avi2dg/checks/stylelint";
93
+
94
+ export default defineConfig({
95
+ baseline: "widely",
96
+ tokens: ["src/styles/tokens.css"],
97
+ });
98
+ ```
99
+
100
+ `baseline` and `tokens` have no default, since only the repository knows them, so `tsc` refuses a config that leaves either out.
101
+ The builder throws when the gate loads such a config anyway.
102
+
103
+ | Key | Takes |
104
+ | --- | --- |
105
+ | `baseline` | `"widely"`, `"newly"` or a year such as `2024`, which allows what became Baseline newly available in that year or earlier |
106
+ | `tokens` | the globs of the files that declare the design tokens, which turns G4 on in every other file, or `false` |
107
+
108
+ `rules`, `overrides`, `ignoreFiles` and the other keys mean what they mean in stylelint's own docs, and land after the kit's.
109
+ An accepted exception lives there rather than in a comment, such as a property the Baseline rule passes:
110
+
111
+ ```ts
112
+ rules: { "plugin/use-baseline": [true, { available: "widely", ignoreProperties: { "user-select": ["none"] } }] },
113
+ ```
114
+
115
+ `@property` needs no such exception, since the kit passes it at every target, and adds it to the `ignoreAtRules` of a `plugin/use-baseline` entry the repository sets.
116
+
117
+ The builder owns `extends` and `plugins`.
118
+ It resolves the shared config, the plugin and `postcss-html` beside the kit, so they need not sit under the directory the gate runs in.
119
+ stylelint cannot load `stylelint.config.ts` itself under TypeScript 7, so a bare `stylelint --fix` finds no config, and the gate's `--fix` stands in for it.
120
+ A config not built with `defineConfig` names its own `customSyntax` for markup, or each `.html` or `.astro` file fails to parse in the repository run.
121
+
122
+ The repository installs the optional peers the gate loads, at the versions the kit pins:
53
123
 
54
124
  ```sh
55
- bun add -d stylelint@17.16.0 postcss-html@2.0.0
125
+ bun add -d stylelint@17.16.0 postcss-html@2.0.0 stylelint-config-standard@40.0.0 stylelint-plugin-use-baseline@1.4.7
56
126
  ```
57
127
 
58
128
  ## Arguments
59
129
 
60
130
  ```sh
61
- checks-frontend-syntax [<directory>]
131
+ checks-frontend-syntax [--fix] [--suppress] [<directory>]
62
132
  ```
63
133
 
64
134
  It checks the directory it runs in, or the directory it is given.
135
+ `--fix` rewrites what a rule of the config fixes, such as `#ffffff` to `#fff`, and reports what is left.
136
+ `--suppress` writes each problem the config reports into `stylelint-suppressions.json`, and drops a count whose problems are gone.
65
137
 
66
138
  ## Exit codes
67
139
 
68
140
  | Code | When |
69
141
  | --- | --- |
70
- | 0 | every declared input matches a file, and no file holds a refused declaration |
71
- | 1 | a file holds a refused declaration or does not parse, or a declared input matches no file |
72
- | 2 | `frontend-syntax.json` is missing or does not decode, its input list is empty, or stylelint or `postcss-html` cannot load |
142
+ | 0 | every declared input matches a file, and no file holds a problem beyond its suppressed count |
143
+ | 1 | a file holds a problem or does not parse, or a declared input matches no file |
144
+ | 2 | `frontend-syntax.json` is missing or does not decode, its input list is empty, `stylelint.config.ts` is missing or does not load, stylelint refuses an option, a peer cannot load, or an argument is unknown |
73
145
 
74
146
  ## Sample output
75
147
 
76
148
  ```
77
- frontend-syntax: 3 problem(s) in 2 file(s) from 3 declared input(s):
149
+ frontend-syntax: 4 problem(s) in 2 file(s) from 3 declared input(s):
78
150
  dist/**/*.html: the declared input matches no file
79
151
  src/layouts/Base.astro:4:15 Disallowed property "user-select" for selector "body". Leave text selectable across the page, and turn selection off only on the control that needs it.
80
152
  src/styles/site.css:1:17 Disallowed value "all 200ms" for property "transition". Name the properties the transition animates.
153
+ src/styles/site.css:9:1 Too high specificity in ".nav .item.is-open > a:hover", maximum "0,3,2" (selector-max-specificity)
81
154
  ```
82
155
 
83
156
  A passing run counts what it judged:
@@ -89,9 +162,13 @@ frontend-syntax: no violation in 2 file(s) from 2 declared input(s)
89
162
  ## When it runs
90
163
 
91
164
  `checks-lint` runs it when the repository tracks `frontend-syntax.json` at its root.
92
- A repository without that file never runs it and needs neither stylelint nor `postcss-html`.
165
+ A repository without that file never runs it and needs neither `stylelint.config.ts` nor the peers.
93
166
 
94
167
  ## Related topics
95
168
 
96
169
  - [checks-browser](checks-browser.md)
97
170
  - [checks-lint](checks-lint.md)
171
+ - [checks-comment-gate](checks-comment-gate.md)
172
+ - [checks-suppressions-ratchet](checks-suppressions-ratchet.md)
173
+ - [checks-repetition](checks-repetition.md)
174
+ - [Native settings](../configs/native-settings.md)
@@ -23,6 +23,8 @@ A `path` entry the base revision lacks reads as empty there, so a newly measured
23
23
  { "path": ["src"], "format": ["typescript"], "ignore": ["**/*.d.ts"] }
24
24
  ```
25
25
 
26
+ A repository that lists `"css"` in `format` measures its stylesheets the same way, and [checks-frontend-syntax](checks-frontend-syntax.md) leaves CSS repetition to this gate.
27
+
26
28
  The gate sets the 50 tokens and 5 lines itself, over any the file names.
27
29
  A `threshold` the file names never fails the gate, which reads jscpd's report once the scan finishes.
28
30
  The bin reads the base and head commits rather than the working tree's file contents.
@@ -56,5 +58,6 @@ When the head holds no `.jscpd.json`, the gate reports that no file was measured
56
58
 
57
59
  ## Related topics
58
60
 
61
+ - [checks-frontend-syntax](checks-frontend-syntax.md)
59
62
  - [Native settings](../configs/native-settings.md)
60
63
  - [checks-suppressions-ratchet](checks-suppressions-ratchet.md)
@@ -4,18 +4,19 @@ audience: consumers
4
4
  ---
5
5
  # checks-suppressions-ratchet
6
6
 
7
- `checks-suppressions-ratchet` is the gate that fails when a change raises a count in `oxlint-suppressions.json`.
7
+ `checks-suppressions-ratchet` is the gate that fails when a change raises a count in `oxlint-suppressions.json` or `stylelint-suppressions.json`.
8
8
 
9
9
  ## What it checks
10
10
 
11
11
  oxlint accepts whatever `oxlint --suppress-all` writes to `oxlint-suppressions.json`, so raising a count to let a new site through passes the lint.
12
- The ratchet fails naming each file and rule whose count rose or that appeared.
12
+ stylelint accepts `stylelint-suppressions.json` the same way, and writes it in the same shape, a count per rule per file.
13
+ The ratchet fails naming the suppressions file, the file and the rule of each count that rose or appeared.
13
14
  A count that fell and an entry that went both pass.
14
15
 
15
16
  ## What it reads
16
17
 
17
- It reads `oxlint-suppressions.json` at a base and at a head, from the directory the command runs in, which is where oxlint writes it.
18
- A commit without the file counts as empty, so the commit that first adds a baseline fails with every entry appearing.
18
+ It reads `oxlint-suppressions.json` and `stylelint-suppressions.json` at a base and at a head, from the directory the command runs in, which is where oxlint and stylelint write them.
19
+ A commit without a file counts it as empty, so the commit that first adds a baseline fails with every entry appearing.
19
20
 
20
21
  ## Arguments
21
22
 
@@ -33,22 +34,23 @@ With one it compares that commit with its parent, or with the empty tree for a r
33
34
  | --- | --- |
34
35
  | 0 | no count rose and no entry appeared |
35
36
  | 1 | a count rose or an entry appeared |
36
- | 2 | a ref does not resolve, the parent exists but is not in the clone, or the file is not oxlint's count per rule per file |
37
+ | 2 | a ref does not resolve, the parent exists but is not in the clone, or a file is not a count per rule per file |
37
38
 
38
39
  ## Sample output
39
40
 
40
41
  ```
41
- suppressions-ratchet: 2 count(s) in oxlint-suppressions.json rose or appeared; fix the site instead of suppressing it:
42
- src/added.ts typescript/no-unsafe-type-assertion appeared with 1
43
- src/dispatch.ts typescript/no-non-null-assertion rose from 12 to 13
42
+ suppressions-ratchet: 2 count(s) rose or appeared; fix the site instead of suppressing it:
43
+ oxlint-suppressions.json src/dispatch.ts typescript/no-non-null-assertion rose from 12 to 13
44
+ stylelint-suppressions.json src/styles.css selector-max-specificity appeared with 1
44
45
  ```
45
46
 
46
47
  ## When it runs
47
48
 
48
49
  `checks-lint` runs it over each pull request's range in every repository, as [checks-lint](checks-lint.md) says.
49
- A repository with no `oxlint-suppressions.json` passes, since both ends count as empty.
50
+ A repository with neither file passes, since both ends count as empty.
50
51
 
51
52
  ## Related topics
52
53
 
53
54
  - [The Effect rules](../configs/effect-rules.md)
55
+ - [checks-frontend-syntax](checks-frontend-syntax.md)
54
56
  - [checks-lint](checks-lint.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.44.0",
3
+ "version": "0.45.1",
4
4
  "description": "Deterministic checks shared across a set of TypeScript repositories",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -94,6 +94,7 @@
94
94
  "src/quality/presets/effect.language-service.json",
95
95
  "src/quality/presets/oxlint.ts",
96
96
  "src/quality/presets/dependency-cruiser.ts",
97
+ "src/quality/presets/stylelint.ts",
97
98
  "src/quality/effect-scope.ts",
98
99
  "src/quality/jsonc-patch.ts",
99
100
  "src/dependencies/imports.ts",
@@ -129,6 +130,10 @@
129
130
  "./dependency-cruiser": {
130
131
  "types": "./dist/presets/dependency-cruiser.d.ts",
131
132
  "default": "./dist/presets/dependency-cruiser.js"
133
+ },
134
+ "./stylelint": {
135
+ "types": "./dist/presets/stylelint.d.ts",
136
+ "default": "./dist/presets/stylelint.js"
132
137
  }
133
138
  },
134
139
  "bin": {
@@ -170,7 +175,7 @@
170
175
  },
171
176
  "scripts": {
172
177
  "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",
173
- "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 --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",
178
+ "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",
174
179
  "lint": "oxlint && bun src/core/lint.ts",
175
180
  "typecheck": "tsc --noEmit && effect-tsgo diagnostics --project tsconfig.json --format text --strict",
176
181
  "test": "bun src/testing/test.ts",
@@ -192,6 +197,8 @@
192
197
  "playwright-core": "1.63.0",
193
198
  "postcss-html": "2.0.0",
194
199
  "stylelint": "17.16.0",
200
+ "stylelint-config-standard": "40.0.0",
201
+ "stylelint-plugin-use-baseline": "1.4.7",
195
202
  "typescript": "7.0.2"
196
203
  },
197
204
  "peerDependenciesMeta": {
@@ -209,6 +216,12 @@
209
216
  },
210
217
  "stylelint": {
211
218
  "optional": true
219
+ },
220
+ "stylelint-config-standard": {
221
+ "optional": true
222
+ },
223
+ "stylelint-plugin-use-baseline": {
224
+ "optional": true
212
225
  }
213
226
  },
214
227
  "devDependencies": {
@@ -229,6 +242,8 @@
229
242
  "playwright-core": "1.63.0",
230
243
  "postcss-html": "2.0.0",
231
244
  "stylelint": "17.16.0",
245
+ "stylelint-config-standard": "40.0.0",
246
+ "stylelint-plugin-use-baseline": "1.4.7",
232
247
  "typescript": "7.0.2"
233
248
  },
234
249
  "dependencies": {
@@ -3,13 +3,15 @@ import { Console, Effect, Schema } from "effect";
3
3
  import { git, commitOf, parentOrEmptyTree, refArgs } from "../core/git.ts";
4
4
  import { runMain } from "../core/main.ts";
5
5
 
6
- export const SUPPRESSIONS = "oxlint-suppressions.json";
6
+ export const SUPPRESSIONS = ["oxlint-suppressions.json", "stylelint-suppressions.json"] as const;
7
+
8
+ export type SuppressionsFile = (typeof SUPPRESSIONS)[number];
7
9
 
8
10
  export type Suppressions = ReadonlyMap<string, ReadonlyMap<string, number>>;
9
11
 
10
- export type Rise =
11
- | { readonly kind: "rose"; readonly file: string; readonly rule: string; readonly base: number; readonly head: number }
12
- | { readonly kind: "appeared"; readonly file: string; readonly rule: string; readonly head: number };
12
+ type Place = { readonly list: SuppressionsFile; readonly file: string; readonly rule: string };
13
+
14
+ export type Rise = Place & ({ readonly kind: "rose"; readonly base: number; readonly head: number } | { readonly kind: "appeared"; readonly head: number });
13
15
 
14
16
  export type Ratchet = {
15
17
  readonly counted: number;
@@ -49,40 +51,44 @@ export const parseSuppressions = Effect.fn("parseSuppressions")(function* (
49
51
  return suppressions;
50
52
  });
51
53
 
52
- type Count = {
53
- readonly file: string;
54
- readonly rule: string;
55
- readonly count: number;
56
- };
54
+ type Count = Place & { readonly count: number };
57
55
 
58
- function countsOf(suppressions: Suppressions): readonly Count[] {
59
- return [...suppressions].flatMap(([file, rules]) => [...rules].map(([rule, count]) => ({ file, rule, count })));
56
+ function countsOf(list: SuppressionsFile, suppressions: Suppressions): readonly Count[] {
57
+ return [...suppressions].flatMap(([file, rules]) => [...rules].map(([rule, count]) => ({ list, file, rule, count })));
60
58
  }
61
59
 
62
- function riseOf({ file, rule, count }: Count, before: number | undefined): readonly Rise[] {
63
- if (before === undefined) return count > 0 ? [{ kind: "appeared", file, rule, head: count }] : [];
64
- return count > before ? [{ kind: "rose", file, rule, base: before, head: count }] : [];
60
+ function riseOf({ count, ...place }: Count, before: number | undefined): readonly Rise[] {
61
+ if (before === undefined) return count > 0 ? [{ ...place, kind: "appeared", head: count }] : [];
62
+ return count > before ? [{ ...place, kind: "rose", base: before, head: count }] : [];
65
63
  }
66
64
 
67
- export function compareSuppressions(base: Suppressions, head: Suppressions): Ratchet {
68
- const counts = countsOf(head);
65
+ export function compareSuppressions(list: SuppressionsFile, base: Suppressions, head: Suppressions): Ratchet {
66
+ const counts = countsOf(list, head);
69
67
  const rises = counts.flatMap((counted) => riseOf(counted, base.get(counted.file)?.get(counted.rule)));
70
- const lowered = countsOf(base).filter(({ file, rule, count }) => (head.get(file)?.get(rule) ?? 0) < count).length;
68
+ const lowered = countsOf(list, base).filter(({ file, rule, count }) => (head.get(file)?.get(rule) ?? 0) < count).length;
71
69
  rises.sort((a, b) => a.file.localeCompare(b.file) || a.rule.localeCompare(b.rule));
72
70
  return { counted: counts.length, lowered, rises };
73
71
  }
74
72
 
73
+ export function combined(ratchets: readonly Ratchet[]): Ratchet {
74
+ return {
75
+ counted: ratchets.reduce((sum, { counted }) => sum + counted, 0),
76
+ lowered: ratchets.reduce((sum, { lowered }) => sum + lowered, 0),
77
+ rises: ratchets.flatMap(({ rises }) => rises),
78
+ };
79
+ }
80
+
75
81
  function describeRise(rise: Rise): string {
76
82
  const change = rise.kind === "rose" ? `rose from ${rise.base} to ${rise.head}` : `appeared with ${rise.head}`;
77
- return `${rise.file} ${rise.rule} ${change}`;
83
+ return `${rise.list} ${rise.file} ${rise.rule} ${change}`;
78
84
  }
79
85
 
80
86
  export function report({ counted, lowered, rises }: Ratchet): string {
81
87
  if (rises.length === 0) {
82
- return `suppressions-ratchet: no count in ${SUPPRESSIONS} rose or appeared (${counted} at the head, ${lowered} lowered)`;
88
+ return `suppressions-ratchet: no count in ${SUPPRESSIONS.join(" or ")} rose or appeared (${counted} at the head, ${lowered} lowered)`;
83
89
  }
84
90
  return [
85
- `suppressions-ratchet: ${rises.length} count(s) in ${SUPPRESSIONS} rose or appeared; fix the site instead of suppressing it:`,
91
+ `suppressions-ratchet: ${rises.length} count(s) rose or appeared; fix the site instead of suppressing it:`,
86
92
  ...rises.map((rise) => ` ${describeRise(rise)}`),
87
93
  ].join("\n");
88
94
  }
@@ -94,22 +100,28 @@ const mergeBase = Effect.fn("mergeBase")(function* (base: string, head: string)
94
100
  return commit.trim();
95
101
  });
96
102
 
97
- const suppressionsAt = Effect.fn("suppressionsAt")(function* (treeish: string) {
98
- const blob = (yield* git(["ls-tree", "--object-only", treeish, "--", SUPPRESSIONS])).trim();
103
+ const suppressionsAt = Effect.fn("suppressionsAt")(function* (list: SuppressionsFile, treeish: string) {
104
+ const blob = (yield* git(["ls-tree", "--object-only", treeish, "--", list])).trim();
99
105
  if (blob === "") return new Map() satisfies Suppressions;
100
- return yield* parseSuppressions(yield* git(["cat-file", "blob", blob]), `${SUPPRESSIONS} at ${treeish}`);
106
+ return yield* parseSuppressions(yield* git(["cat-file", "blob", blob]), `${list} at ${treeish}`);
107
+ });
108
+
109
+ const compareAt = Effect.fn("compareAt")(function* (base: string, head: string) {
110
+ const ratchets: Ratchet[] = [];
111
+ for (const list of SUPPRESSIONS) ratchets.push(compareSuppressions(list, yield* suppressionsAt(list, base), yield* suppressionsAt(list, head)));
112
+ return combined(ratchets);
101
113
  });
102
114
 
103
115
  export const runRange = Effect.fn("runRange")(function* (base: string, head: string) {
104
116
  const headCommit = yield* commitOf(head);
105
117
  // At the base tip, a count the base branch lowered after the head branched off would read as a rise at the head.
106
118
  const branchPoint = yield* mergeBase(yield* commitOf(base), headCommit);
107
- return compareSuppressions(yield* suppressionsAt(branchPoint), yield* suppressionsAt(headCommit));
119
+ return yield* compareAt(branchPoint, headCommit);
108
120
  });
109
121
 
110
122
  export const runTip = Effect.fn("runTip")(function* (tip: string) {
111
123
  const tipCommit = yield* commitOf(tip);
112
- return compareSuppressions(yield* suppressionsAt(yield* parentOrEmptyTree(tipCommit)), yield* suppressionsAt(tipCommit));
124
+ return yield* compareAt(yield* parentOrEmptyTree(tipCommit), tipCommit);
113
125
  });
114
126
 
115
127
  const ratchet = Effect.gen(function* () {
@@ -56,12 +56,13 @@ export function inputsReport(name: string, inputs: readonly Input[], problems: r
56
56
  return [`${name}: ${problems.length} problem(s) in ${scanned}:`, ...problems.map((problem) => ` ${problem}`)].join("\n");
57
57
  }
58
58
 
59
- const declaredGate = Effect.fn("declaredGate")(function* <S, E>(
59
+ export const declaredGate = Effect.fn("declaredGate")(function* <S, E>(
60
60
  declared: Declared,
61
61
  scanInputs: (root: string, globs: readonly string[]) => Effect.Effect<S, E, BunServices.BunServices>,
62
62
  { report, passes }: Verdict<S>,
63
+ directory: string | undefined,
63
64
  ) {
64
- const root = (yield* Path.Path).resolve(process.argv[2] ?? ".");
65
+ const root = (yield* Path.Path).resolve(directory ?? ".");
65
66
  const scan = yield* scanInputs(root, yield* readInputs(root, declared));
66
67
  yield* Console.log(report(scan));
67
68
  return passes(scan);
@@ -72,5 +73,5 @@ export function runDeclaredGate<S, E extends { readonly message: string }>(
72
73
  scanInputs: (root: string, globs: readonly string[]) => Effect.Effect<S, E, BunServices.BunServices>,
73
74
  verdict: Verdict<S>,
74
75
  ): void {
75
- runMain(declared.name, declaredGate(declared, scanInputs, verdict));
76
+ runMain(declared.name, declaredGate(declared, scanInputs, verdict, process.argv[2]));
76
77
  }
@@ -40,6 +40,7 @@ const SQL: Syntax = { line: DASHES, block: SLASHES, quotes: STRINGS, regexLitera
40
40
  const LUA: Syntax = { line: DASHES, block: [["--[[", "]]"]], quotes: STRINGS, regexLiterals: false };
41
41
  const HASKELL: Syntax = { line: DASHES, block: [["{-", "-}"]], quotes: [escaping('"')], regexLiterals: false };
42
42
  const ML: Syntax = { line: [], block: [["(*", "*)"]], quotes: [escaping('"')], regexLiterals: false };
43
+ const CSS: Syntax = { line: [], block: SLASHES, quotes: STRINGS, regexLiterals: false };
43
44
 
44
45
  export const SYNTAXES: Readonly<Record<string, Syntax>> = {
45
46
  ts: JS,
@@ -78,6 +79,7 @@ export const SYNTAXES: Readonly<Record<string, Syntax>> = {
78
79
  yml: YAML,
79
80
  toml: HASH,
80
81
  ml: ML,
82
+ css: CSS,
81
83
  };
82
84
 
83
85
  const extensionOf = (path: string): string => path.slice(path.lastIndexOf(".") + 1);
@@ -247,13 +249,14 @@ export const REFUSED_DIRECTIVES = [
247
249
  "biome-ignore",
248
250
  "eslint-disable",
249
251
  "prettier-ignore",
252
+ "stylelint-disable",
250
253
  ] as const satisfies readonly string[];
251
254
 
252
255
  const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
253
256
 
254
257
  const directiveSource = (name: string): string => {
255
258
  const escaped = escapeRegExp(name);
256
- if (name === "eslint-disable") return `\\b${escaped}[\\w-]*`;
259
+ if (name.endsWith("-disable")) return `\\b${escaped}[\\w-]*`;
257
260
  return /^[\w]/.test(name) ? `\\b${escaped}\\b` : escaped;
258
261
  };
259
262
 
@@ -1,33 +1,47 @@
1
1
  #!/usr/bin/env bun
2
- import { Effect, Path, Schema } from "effect";
3
- import type { LintResult } from "stylelint";
4
- import { emptyInputs, inputsReport, runDeclaredGate, violationLine, type Declared, type Input, type Violation } from "../core/declared-inputs.ts";
5
- import { Usage } from "../core/main.ts";
2
+ import { Effect, FileSystem, Path, Schema } from "effect";
3
+ import type { Config, LinterOptions, LintResult } from "stylelint";
4
+ import { declaredGate, emptyInputs, inputsReport, violationLine, type Declared, type Input, type Violation } from "../core/declared-inputs.ts";
5
+ import { runMain, Usage } from "../core/main.ts";
6
+ import { MARKUP } from "./presets/stylelint.ts";
6
7
 
7
8
  const DECLARED: Declared = { name: "frontend-syntax", file: "frontend-syntax.json", declares: "inputs" };
8
9
 
10
+ const STYLELINT_CONFIG = "stylelint.config.ts";
11
+
12
+ const USAGE = "usage: frontend-syntax.ts [--fix] [--suppress] [<directory>]";
13
+
9
14
  const TRANSITION_ALL = "declaration-property-value-disallowed-list";
10
15
  const BODY_WIDE_USER_SELECT = "rule-selector-property-disallowed-list";
11
16
  const UNPARSED = "CssSyntaxError";
17
+ const UNPARSED_PART = "parseError";
12
18
 
13
19
  const RULES = {
14
20
  [TRANSITION_ALL]: [{ "/^(?:-[a-z]+-)?transition(?:-property)?$/": ["/(?:^|[\\s,])all(?:[\\s,]|$)/i"] }],
15
21
  [BODY_WIDE_USER_SELECT]: [{ "/(?:^|,)\\s*(?:html|body|:root|\\*|:global\\(\\s*(?:html|body|:root|\\*)\\s*\\))\\s*(?:,|$)/": ["/^(?:-[a-z]+-)?user-select$/"] }],
16
22
  } as const;
17
23
 
18
- const ADVICE: Readonly<Record<string, string>> = {
24
+ type Advice = Readonly<Record<string, string>>;
25
+
26
+ const JUDGES_ONLY_WHAT_PARSES = "The gate judges only what parses.";
27
+
28
+ const UNPARSED_ADVICE: Advice = { [UNPARSED]: JUDGES_ONLY_WHAT_PARSES, [UNPARSED_PART]: JUDGES_ONLY_WHAT_PARSES };
29
+
30
+ const KIT_ADVICE: Advice = {
19
31
  [TRANSITION_ALL]: "Name the properties the transition animates.",
20
32
  [BODY_WIDE_USER_SELECT]: "Leave text selectable across the page, and turn selection off only on the control that needs it.",
21
- [UNPARSED]: "The gate judges only what parses.",
33
+ ...UNPARSED_ADVICE,
22
34
  };
23
35
 
24
- const MARKUP = ["**/*.html", "**/*.astro", "**/*.vue", "**/*.svelte"];
25
-
26
36
  export type Scan = {
27
37
  readonly inputs: readonly Input[];
28
38
  readonly violations: readonly Violation[];
29
39
  };
30
40
 
41
+ type Options = { readonly fix: boolean; readonly suppress: boolean };
42
+
43
+ type Pass = { readonly config: Config; readonly advice: Advice; readonly refuses: string };
44
+
31
45
  class FrontendSyntaxError extends Schema.TaggedError<FrontendSyntaxError>()("FrontendSyntaxError", {
32
46
  message: Schema.String,
33
47
  }) {}
@@ -45,41 +59,98 @@ const loadPeer = <A>(name: string, load: () => Promise<A>) =>
45
59
  new Usage({ message: `cannot load ${name}, which a repository that declares ${DECLARED.file} installs beside the kit as an optional peer` }),
46
60
  });
47
61
 
48
- function violationsOf(result: LintResult, relative: (file: string) => string): readonly Violation[] {
62
+ const ConfigModule = Schema.Struct({ default: Schema.Record(Schema.String, Schema.Unknown) });
63
+
64
+ const decodeConfigModule = Schema.decodeUnknownEffect(ConfigModule);
65
+
66
+ const loadRepositoryConfig = Effect.fn("loadRepositoryConfig")(function* (root: string) {
67
+ const fs = yield* FileSystem.FileSystem;
68
+ const file = (yield* Path.Path).join(root, STYLELINT_CONFIG);
69
+ if (!(yield* fs.exists(file))) {
70
+ return yield* new Usage({
71
+ message: `${STYLELINT_CONFIG} is missing, and a repository that declares ${DECLARED.file} writes it with defineConfig from @avi2dg/checks/stylelint`,
72
+ });
73
+ }
74
+ const loaded: unknown = yield* Effect.tryPromise({
75
+ try: () => import(file),
76
+ catch: (cause) => new Usage({ message: `${STYLELINT_CONFIG} does not load: ${String(cause)}` }),
77
+ });
78
+ const module = yield* decodeConfigModule(loaded).pipe(
79
+ Effect.mapError(() => new Usage({ message: `${STYLELINT_CONFIG} has no default export of a stylelint config object` })),
80
+ );
81
+ return module.default;
82
+ });
83
+
84
+ function violationsOf(result: LintResult, relative: (file: string) => string, advice: Advice): readonly Violation[] {
49
85
  const file = relative(result.source ?? "");
50
- return result.warnings.map(({ line, column, rule, text }) => ({
51
- file,
52
- line,
53
- column,
54
- text: `${text.replace(` (${rule})`, "")}. ${ADVICE[rule] ?? ""}`.trim(),
55
- }));
86
+ const unparsed = result.parseErrors.map(({ line, column, stylelintType, text }) => ({ line, column, rule: stylelintType, text }));
87
+ return [...result.warnings, ...unparsed].map(({ line, column, rule, text }) => {
88
+ const advised = advice[rule];
89
+ return { file, line, column, text: advised === undefined ? text : `${text.replace(` (${rule})`, "")}. ${advised}` };
90
+ });
56
91
  }
57
92
 
58
- export const scanInputs = Effect.fn("scanInputs")(function* (root: string, globs: readonly string[]) {
59
- const path = yield* Path.Path;
60
- const stylelint = (yield* loadPeer("stylelint", () => import("stylelint"))).default;
61
- const html = (yield* loadPeer("postcss-html", () => import("postcss-html"))).default;
62
- const config = { rules: RULES, overrides: [{ files: MARKUP, customSyntax: html }] };
63
- const relative = (file: string): string => path.relative(root, file);
64
- const inputs: Input[] = [];
65
- const violations = new Map<string, Violation>();
66
- for (const glob of globs) {
93
+ const lintWith = (root: string, pass: Pass, options: Partial<LinterOptions>) =>
94
+ Effect.gen(function* () {
95
+ const stylelint = (yield* loadPeer("stylelint", () => import("stylelint"))).default;
96
+ const files = options.files ?? [];
67
97
  const linted: readonly LintResult[] = yield* Effect.tryPromise({
68
- try: () => stylelint.lint({ files: glob, cwd: root, config }),
69
- catch: (cause) => (isNoFilesFound(cause) ? new NoFilesFound() : new FrontendSyntaxError({ message: `stylelint fails on ${glob}: ${String(cause)}` })),
98
+ try: () => stylelint.lint({ ...options, cwd: root, configBasedir: root, config: pass.config, ignoreDisables: true }),
99
+ catch: (cause) => (isNoFilesFound(cause) ? new NoFilesFound() : new FrontendSyntaxError({ message: `stylelint fails on ${String(files)}: ${String(cause)}` })),
70
100
  }).pipe(
71
101
  Effect.map(({ results }) => results),
72
102
  Effect.catchTag("NoFilesFound", () => Effect.succeed([])),
73
103
  );
74
- const results = linted.filter(({ ignored }) => ignored !== true);
75
- const invalid = results.flatMap(({ invalidOptionWarnings }) => invalidOptionWarnings.map(({ text }) => text));
76
- if (invalid.length > 0) return yield* new FrontendSyntaxError({ message: `stylelint refuses the kit's rule options: ${invalid.join("; ")}` });
77
- inputs.push({ glob, files: results.map((result) => relative(result.source ?? "")).toSorted() });
78
- for (const violation of results.flatMap((result) => violationsOf(result, relative))) {
79
- violations.set(`${violation.file}:${violation.line}:${violation.column}:${violation.text}`, violation);
80
- }
104
+ const invalid = linted.flatMap(({ invalidOptionWarnings }) => invalidOptionWarnings.map(({ text }) => text));
105
+ if (invalid.length > 0) return yield* new FrontendSyntaxError({ message: `stylelint refuses ${pass.refuses}: ${[...new Set(invalid)].join("; ")}` });
106
+ return linted;
107
+ });
108
+
109
+ // stylelint applies stylelint-suppressions.json to every file it reads itself, and to no code it is handed.
110
+ const lintUnsuppressed = (root: string, pass: Pass, files: readonly string[]) =>
111
+ Effect.forEach(files, (file) =>
112
+ Effect.gen(function* () {
113
+ const code = yield* (yield* FileSystem.FileSystem).readFileString(file).pipe(
114
+ Effect.mapError((cause) => new FrontendSyntaxError({ message: `cannot read ${file}: ${String(cause)}` })),
115
+ );
116
+ return yield* lintWith(root, pass, { code, codeFilename: file });
117
+ }),
118
+ ).pipe(Effect.map((results) => results.flat()));
119
+
120
+ function isJudged({ ignored }: LintResult): boolean {
121
+ return ignored !== true;
122
+ }
123
+
124
+ const passesFor = Effect.fn("passesFor")(function* (root: string) {
125
+ const html = (yield* loadPeer("postcss-html", () => import("postcss-html"))).default;
126
+ const kit: Pass = { config: { rules: RULES, overrides: [{ files: [...MARKUP], customSyntax: html }] }, advice: KIT_ADVICE, refuses: "the kit's rule options" };
127
+ const repository: Pass = {
128
+ config: { ...(yield* loadRepositoryConfig(root)), quiet: false },
129
+ advice: UNPARSED_ADVICE,
130
+ refuses: `an option in ${STYLELINT_CONFIG}`,
131
+ };
132
+ return { kit, repository };
133
+ });
134
+
135
+ export const scanInputs = Effect.fn("scanInputs")(function* (root: string, globs: readonly string[], { fix, suppress }: Options) {
136
+ const path = yield* Path.Path;
137
+ // stylelint keys its suppressions file on the process directory rather than on its cwd option.
138
+ yield* Effect.sync(() => process.chdir(root));
139
+ const { kit, repository } = yield* passesFor(root);
140
+ if (suppress) yield* lintWith(root, repository, { files: [...globs], fix, suppressAll: true, allowEmptyInput: true });
141
+ const relative = (file: string): string => path.relative(root, file);
142
+ const inputs: Input[] = [];
143
+ const violations = new Map<string, Violation>();
144
+ for (const glob of globs) {
145
+ const matched = yield* lintWith(root, repository, { files: glob, fix });
146
+ const judged = matched.filter(isJudged);
147
+ const ruled = yield* lintUnsuppressed(root, kit, matched.flatMap(({ source }) => (source === undefined ? [] : [source])));
148
+ const files = new Set([...judged, ...ruled.filter(isJudged)].map((result) => relative(result.source ?? "")));
149
+ inputs.push({ glob, files: [...files].toSorted() });
150
+ const found = [...ruled.flatMap((result) => violationsOf(result, relative, kit.advice)), ...judged.flatMap((result) => violationsOf(result, relative, repository.advice))];
151
+ for (const violation of found) violations.set(`${violation.file}:${violation.line}:${violation.column}:${violation.text}`, violation);
81
152
  }
82
- const byPlace = (a: Violation, b: Violation): number => a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column;
153
+ const byPlace = (a: Violation, b: Violation): number => a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column || a.text.localeCompare(b.text);
83
154
  return { inputs, violations: [...violations.values()].toSorted(byPlace) } satisfies Scan;
84
155
  });
85
156
 
@@ -92,4 +163,19 @@ export function report({ inputs, violations }: Scan): string {
92
163
  return inputsReport(DECLARED.name, inputs, [...empty, ...violations.map(violationLine)]);
93
164
  }
94
165
 
95
- if (import.meta.main) runDeclaredGate(DECLARED, scanInputs, { report, passes });
166
+ type Arguments = Options & { readonly directory: string | undefined };
167
+
168
+ export const parseArguments = Effect.fn("parseArguments")(function* (args: readonly string[]) {
169
+ const flags = new Set(args.filter((arg) => arg.startsWith("--")));
170
+ const directories = args.filter((arg) => !arg.startsWith("--"));
171
+ const unknown = [...flags].filter((flag) => flag !== "--fix" && flag !== "--suppress");
172
+ if (unknown.length > 0 || directories.length > 1) return yield* new Usage({ message: USAGE });
173
+ return { fix: flags.has("--fix"), suppress: flags.has("--suppress"), directory: directories[0] } satisfies Arguments;
174
+ });
175
+
176
+ const gate = Effect.gen(function* () {
177
+ const { directory, ...options } = yield* parseArguments(process.argv.slice(2));
178
+ return yield* declaredGate(DECLARED, (root, globs) => scanInputs(root, globs, options), { report, passes }, directory);
179
+ });
180
+
181
+ if (import.meta.main) runMain(DECLARED.name, gate);
@@ -0,0 +1,103 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import type { Config } from "stylelint";
3
+
4
+ type Globs = readonly [string, ...string[]];
5
+
6
+ export type BaselineTarget = "widely" | "newly" | number;
7
+
8
+ export type ChecksStylelintConfig = Omit<Config, "extends" | "plugins"> & {
9
+ readonly baseline: BaselineTarget;
10
+ readonly tokens: Globs | false;
11
+ };
12
+
13
+ type Rules = NonNullable<Config["rules"]>;
14
+
15
+ const HTML_AND_ASTRO = ["**/*.html", "**/*.astro"] as const;
16
+
17
+ export const MARKUP = [...HTML_AND_ASTRO, "**/*.vue", "**/*.svelte"] as const;
18
+
19
+ // stylelint resolves a name from the repository, and the kit's peers sit beside the kit rather than under every repository it lints.
20
+ function peer(name: string): string {
21
+ return fileURLToPath(import.meta.resolve(name));
22
+ }
23
+
24
+ const CONVENTION_CHANGES = {
25
+ "no-descending-specificity": null,
26
+ "property-no-vendor-prefix": [true, { disableFix: true, ignoreProperties: ["-webkit-user-select"] }],
27
+ } as const satisfies Rules;
28
+
29
+ const SELECTOR_COMPLEXITY = {
30
+ "selector-max-specificity": "0,3,2",
31
+ "selector-max-compound-selectors": 3,
32
+ } as const satisfies Rules;
33
+
34
+ const COLOUR_FUNCTIONS = ["rgb", "rgba", "hsl", "hsla", "hwb", "lab", "lch", "oklab", "oklch", "color"] as const;
35
+
36
+ const DESIGN_TOKENS = {
37
+ "color-no-hex": true,
38
+ "color-named": "never",
39
+ "function-disallowed-list": COLOUR_FUNCTIONS.map((name) => `/^${name}$/i`),
40
+ "declaration-property-value-allowed-list": { "/^font-size$/i": ["/^var\\(\\s*--[^\\s,()]+\\s*\\)$/i", "/^inherit$/i"] },
41
+ } as const satisfies Rules;
42
+
43
+ const REFUSED_BASELINE = '@avi2dg/checks/stylelint: set baseline to "widely", "newly" or a year such as 2024';
44
+
45
+ const REFUSED_TOKENS = "@avi2dg/checks/stylelint: set tokens to the globs of the token files, or false to leave the design token rules off";
46
+
47
+ function baselineTarget(baseline: unknown): BaselineTarget {
48
+ if (baseline === "widely" || baseline === "newly") return baseline;
49
+ if (typeof baseline === "number" && Number.isInteger(baseline)) return baseline;
50
+ throw new TypeError(REFUSED_BASELINE);
51
+ }
52
+
53
+ function isGlobList(value: unknown): value is readonly string[] {
54
+ return Array.isArray(value) && value.every((glob) => typeof glob === "string" && glob !== "");
55
+ }
56
+
57
+ function tokenFiles(tokens: unknown): readonly string[] {
58
+ if (tokens === false) return [];
59
+ if (isGlobList(tokens) && tokens.length > 0) return tokens;
60
+ throw new TypeError(REFUSED_TOKENS);
61
+ }
62
+
63
+ const BASELINE_RULE = "plugin/use-baseline";
64
+
65
+ // A repository's Baseline entry replaces the kit's option object whole, so the @property pass goes into whichever entry lands.
66
+ function passProperty(entry: Rules[string]): unknown {
67
+ if (entry === null || entry === undefined) return entry;
68
+ const [primary, options = {}] = Array.isArray(entry) ? entry : [entry];
69
+ const ignored: unknown = Reflect.get(options, "ignoreAtRules") ?? [];
70
+ if (!Array.isArray(ignored)) return entry;
71
+ return [primary, { ...options, ignoreAtRules: ["property", ...ignored] }];
72
+ }
73
+
74
+ function designTokens(files: readonly string[]): Required<Pick<Config, "rules" | "overrides">> {
75
+ if (files.length === 0) return { rules: {}, overrides: [] };
76
+ const off = Object.fromEntries(Object.keys(DESIGN_TOKENS).map((rule) => [rule, null]));
77
+ return { rules: DESIGN_TOKENS, overrides: [{ files: [...files], rules: off }] };
78
+ }
79
+
80
+ // The types refuse a missing or malformed baseline or tokens, and the guards repeat it for a config no typecheck reads.
81
+ export function defineConfig({ baseline, tokens, rules, overrides = [], ...rest }: ChecksStylelintConfig): Config {
82
+ const available = baselineTarget(baseline);
83
+ const tokenRules = designTokens(tokenFiles(tokens));
84
+ const merged: Rules = {
85
+ ...CONVENTION_CHANGES,
86
+ ...SELECTOR_COMPLEXITY,
87
+ [BASELINE_RULE]: [true, { available }],
88
+ ...tokenRules.rules,
89
+ ...rules,
90
+ };
91
+ return {
92
+ ...rest,
93
+ extends: [peer("stylelint-config-standard")],
94
+ plugins: [peer("stylelint-plugin-use-baseline")],
95
+ rules: { ...merged, [BASELINE_RULE]: passProperty(merged[BASELINE_RULE]) },
96
+ overrides: [
97
+ { files: [...MARKUP], customSyntax: peer("postcss-html") },
98
+ { files: [...HTML_AND_ASTRO], rules: { "declaration-block-single-line-max-declarations": null } },
99
+ ...tokenRules.overrides,
100
+ ...overrides,
101
+ ],
102
+ };
103
+ }