stylelint-plugin-rhythmguard 3.8.0 → 3.10.0

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
@@ -6,6 +6,52 @@ The format follows Keep a Changelog principles and semantic versioning.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [3.10.0] - 2026-10-15
10
+
11
+ ### Added
12
+
13
+ - The audit reports token ladders as `contracts.tokens.ladders`: custom properties that share a prefix and a size suffix (`xxs` to `xxl`, `sm`/`md`/`lg`, `1` to `n`) are evaluated at their smallest and largest value (a static length, `clamp()` bounds, the static arguments of `min()` and `max()`, through `var()`) and neighbours are compared at both ends. Collapsed steps (the same size), steps under `minStepPx` apart (default 2px, `audit.ladders.minStepPx`) and inverted steps are reported, and the Markdown report shows each such ladder as a min and max table. A fluid body ladder whose three smallest steps all start at 14px now says so. The section adds no findings and does not change the exit code. ([#205](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/205))
14
+ - `scales` on `use-scale` and `prefer-token`: a scale per property group (`spacing`, `radius`, `size`, `typography`), so a type ladder such as 14, 16, 18, 20, 24, 32, 40 can sit next to a 4px spacing scale in one rule block. A group takes values, a preset name or `"auto"`, which reads the group's own tokens (`--font-size-*` and `--text-*`, `--radius-*`). `propertyScales` still wins for its property. Findings checked against a group scale say so: `Use typography scale values (nearest: 16px or 18px).`; every other message is unchanged. The audit takes the same map as `audit.scales` and checks each group it names. ([#204](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/204))
15
+ - `checkMathBounds` on `use-scale` and `prefer-token`, and `--check-math-bounds` (or `audit.checkMathBounds`) on the audit: the minimum and maximum of `clamp()` and each length argument of `min()` and `max()` are checked like literals, with the position in the message (`Unexpected off-scale clamp() maximum "1.05rem" (16.8px). ...`). Before, wrapping a value in `clamp()` was a way to leave the scale without a finding. The preferred value of `clamp()` is fluid by design and never checked; neither is an expression, a `var()` or a viewport length. `--fix` and `fixWith: "token"` apply per bound. Off by default in 3.x because it adds findings to existing configs; on by default is a candidate for 4.0. ([#203](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/203))
16
+
17
+ ### Fixed
18
+
19
+ - `outline-offset` is no longer offered radius tokens. It is checked with the `radius` property group, so 3.9.0's token-kind matching pointed it at `--radius-*`: on one consumer, all 89 `prefer-token` warnings that named a radius token were on `outline-offset` (`outline-offset: -2px` read `Use calc(-1 * var(--radius-sm))`). It is the gap between the border edge and the outline, a spacing length, so it now takes spacing tokens, and under `scales` the spacing scale. Which properties each group checks is unchanged. A contract test asserts that no property but the corner radii resolves to a radius token.
20
+
21
+ ### Messages
22
+
23
+ - `prefer-token` on `outline-offset`, when a radius token held the value: was `Use var(--radius-*)`, now names the spacing token that holds it, or `No known token holds this value; ...` when none does.
24
+
25
+ ## [3.9.0] - 2026-10-15
26
+
27
+ ### Added
28
+
29
+ - `scale: "auto"` reads `@mozilla-protocol/tokens` when it is installed: `--spacing-xs` to `--spacing-2xl` (4 to 48px) from `dist/units/units.custom-properties.css`. Mozilla Protocol's benchmark row is measured against that package at 7.0.0, as a Protocol contributor pointed out in mozilla/protocol#1132: 19 off-scale values, against 18 on the `rhythmic-4` fallback, because 12px is not a Protocol step and 48px is.
30
+
31
+ ### Fixed
32
+
33
+ - `prefer-token` reads the token sources `use-scale` reads for `fixWith: "token"`: the linted stylesheet, `scaleSources`, the `audit.tokenSources` files in `.rhythmguardrc.json` and installed token packages ([#202](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/202)). Before, a token declared in a config token source was invisible to `prefer-token`, which then said no token held a value that one did; on one consumer, 478 of 611 baselined warnings were values an existing token holds. Both rules now resolve tokens through one function, so a token is known to both or to neither, and a contract test holds them to it. The fix writes such a token only when exactly one holds the value in the literal's unit, the same rule `fixWith: "token"` follows.
34
+ - Tokens are matched to the property's kind. A `tokenPattern` such as `^--space-|^--radius-` no longer lets `padding: 1rem` be pointed at, or fixed to, `--radius-xl` because it also holds 16px. Radius, typography and motion tokens serve their own properties; spacing and size tokens serve each other's. Applies to `prefer-token` and to `fixWith: "token"` in `use-scale` and `no-offscale-transform`.
35
+ - A rule's own `tokenPattern` applies to the token source files it reads as well as to the stylesheet, so `--radius-*` tokens in a config token source are found when the pattern asks for them.
36
+ - The audit no longer hangs on template files that contain an unclosed quote followed by escape-dense source. The Tailwind class-string scanner found string literals with a regex whose alternation was ambiguous on every backslash, so from an apostrophe in a comment or a quote inside a regex literal it backtracked exponentially over the `\{ \[ \(` of the next regex literal; on the Ace editor's `mode-alda.js` (10 KB, vendored by a MediaWiki skin) one `rhythmguard audit` ran for eleven hours before it was killed, and Bootswatch's audit took longer than three minutes. The finder is now a single linear pass: an escape never closes a literal, a `'` or `"` literal that reaches a line break is not a literal, a backtick literal may span lines. Both repositories audit in seconds. The benchmark gained five findings that an unclosed quote had been hiding (four in Ghost's `input-group.tsx`, one in Cal.com's `Button.tsx`).
37
+ - The audit no longer throws `All input files were ignored because of the ignore pattern` when a project's own `.stylelintignore` excludes every stylesheet the walk found. The files are counted as `scanned.stylelintIgnored` in the JSON contract and shown in text and Markdown output, so a clean report cannot hide that nothing was audited.
38
+
39
+ ### Messages
40
+
41
+ Message text changes are listed here, so teams that key a warning baseline on message text know which warnings will reappear as resolved-plus-new pairs (see [#172](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/172)).
42
+
43
+ - `prefer-token`, when a token in a config token source, `scaleSources` or an installed token package holds the value: was `No known token holds this value; use the nearest token or add one.`, now `Use var(--token) (path/to/tokens.css).` When a token in another unit holds it, the message names that token and the fix leaves the literal.
44
+ - `prefer-token`, when several tokens hold the value: was `No known token holds this value; ...`, now `Use one of the tokens that hold it: var(--a) or var(--b).`
45
+ - Unchanged: the message for a value no token holds, and every `use-scale` message.
46
+
47
+ ### Changed
48
+
49
+ - The post-publish smoke waits for the version through `npm view --prefer-online`, the client that will install it, instead of the raw packument; for 3.8.0 the packument listed the version a minute before `npm install` could resolve it and the smoke failed.
50
+ - The quiet benchmark installs the token packages maintainers named as the home of their scale. A manifest row lists them under `tokenPackages` with a pinned version; `npm run bench:quiet` installs them once under `benchmarks/quiet/packages/` with install scripts disabled and passes the allowlisted token files to the audit as explicit sources, so the row is measured against the ladder the maintainers pointed at instead of the `rhythmic-4` fallback or a set of consumer tokens. Carbon (`@carbon/layout`), Primer CSS and Primer React (`@primer/primitives`), mittwald Flow (`@mittwald/flow-design-tokens`) and AdminLTE (`bootstrap`) are measured this way; the SAP rows stay unmeasured because their theming package carries no ladder. The State of Spacing edition marks these rows `installed` and counts them.
51
+ - PatternFly's fourteen benchmark findings are labelled by review: four in a vendored Font Awesome partial and ten in documentation example CSS, none in component SCSS, as the reply in patternfly/patternfly#8579 said. Per-repo labels live in `benchmarks/quiet/labels/`.
52
+ - New README banner for 3.8.0. GitHub shows the transparent SVG; npmjs.com, which renders the README on a white page, gets the non-transparent PNG (`assets/rhythmguard-banner.png`): the release job swaps the banner line with `scripts/ci/npm-readme.mjs` just before `npm publish`, and a contract test fails if the banner line is not found exactly once. The npm page changes with the next published version.
53
+ - `rhythmguard` and `eslint-plugin-rhythmguard` are on npm (3.8.0, first published by hand on 2026-09-27). `npx rhythmguard` now works in a project that has installed nothing, and the README's first command and ESLint setup use the two packages. Later releases publish them from `release.yml` once each has a trusted publisher and `PUBLISH_COMPANIONS` is set.
54
+
9
55
  ## [3.8.0] - 2026-09-18
10
56
 
11
57
  ### Added
package/CONTRIBUTING.md CHANGED
@@ -6,7 +6,7 @@ Thank you for being here. Rhythmguard is a small, opinionated tool with one job:
6
6
 
7
7
  **Report a false positive.** A finding your team considers wrong is the most valuable report we get. It is how percentages and hairlines stopped being findings. Open a [false-positive report](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/new?template=false-positive.yml) with the CSS or class string and your config. No fix required.
8
8
 
9
- **Add a repository to the quiet benchmark.** The benchmark audits public design systems and fails CI when the findings change. Adding a repo is one JSON entry in `benchmarks/quiet/repos.json` plus `npm run bench:quiet -- --only <name>` to create its snapshot. Systems with tokens in unusual places are the most useful additions. The same results feed the dated [State of Spacing](./docs/STATE_OF_SPACING.md) editions, so every repository you add widens that report.
9
+ **Add a repository to the quiet benchmark.** The benchmark audits public design systems and fails CI when the findings change. Adding a repo is one JSON entry in `benchmarks/quiet/repos.json` plus `npm run bench:quiet -- --only <name>` to create its snapshot. Systems with tokens in unusual places are the most useful additions. When the scale ships in an npm package rather than in the repository, list it under `tokenPackages` with a pinned version (the package must be in `src/core/token-packages.json`); the bench installs it and measures the row against that ladder. The same results feed the dated [State of Spacing](./docs/STATE_OF_SPACING.md) editions, so every repository you add widens that report.
10
10
 
11
11
  **Review a drift list.** Each benchmark repo has a list of findings that heuristics call real drift. Labelling them as real or as an allowance in `benchmarks/quiet/labels/<repo>.json` with a one-line reason turns a heuristic number into a reviewed one, and any systematic pattern you find becomes a candidate default.
12
12
 
@@ -16,6 +16,8 @@ Thank you for being here. Rhythmguard is a small, opinionated tool with one job:
16
16
 
17
17
  **Contribute a community scale.** JSON files in `scales/community/`, scaffolded with `npm run scales:add -- --name my-team-scale --base 8 --steps 0,4,8,12,16,24,32` and checked with `npm run scales:validate`. Spec and policy in [`docs/COMMUNITY_SCALES.md`](./docs/COMMUNITY_SCALES.md).
18
18
 
19
+ The plan for the next nine months is in [`ROADMAP.md`](./ROADMAP.md), and every item there is an issue labelled [`roadmap`](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/labels/roadmap) with a milestone. Items labelled `decision` are open questions with the evidence that will settle them; a comment with evidence is a contribution.
20
+
19
21
  Issues labelled [`good first issue`](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/labels/good%20first%20issue) are scoped for a first PR. [`help wanted`](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/labels/help%20wanted) marks work the maintainer would like a second pair of hands on. If you want to work on something larger, open an issue first so we can agree the shape before you write code.
20
22
 
21
23
  ## Getting set up
@@ -27,7 +29,7 @@ npm ci
27
29
  npm test
28
30
  ```
29
31
 
30
- Node 20.19 or newer. No build step; the source runs as is. Tests use `node --test` through `scripts/test.mjs` and take about ten seconds. They are grouped by what they prove:
32
+ Node 20.19 or newer. No build step; the source runs as is. Tests use `node --test` through `scripts/test.mjs` and take about fifteen seconds. They are grouped by what they prove:
31
33
 
32
34
  | Folder | Proves | Run one |
33
35
  | --- | --- | --- |
@@ -48,7 +50,7 @@ Run these before you push. They are what CI runs, so a green local run means a g
48
50
  ```bash
49
51
  npm run lint # eslint
50
52
  npm run typecheck # the published TypeScript declarations against a consumer file and the examples
51
- npm test # 200+ tests, Stylelint 16
53
+ npm test # 300+ tests, on the Stylelint in the lockfile (17)
52
54
  ```
53
55
 
54
56
  Optional, depending on what you touched:
@@ -59,6 +61,8 @@ npm run test:pack-smoke # pack the tarball and install it in a temp proje
59
61
  npm run scales:validate # community scale JSON
60
62
  npm run bench:quiet -- --check # findings on the benchmark repos must match snapshots
61
63
  npm run build:agents # after editing the block in docs/FOR_AGENTS.md
64
+ npm run build:playground # after changing anything under src/ the rules use
65
+ npm run bench:agents -- --dry-run --limit 4 # the agent eval pipeline, no API key needed
62
66
  ```
63
67
 
64
68
  The local gate runs on whatever Node you have installed; CI runs Node 20 and 22. Node 20.19 is the floor, so avoid features that arrived in Node 21 or later. Glob patterns for `node --test` were one such feature, and they took a release run down.
@@ -67,7 +71,7 @@ If your shell's npm registry is overridden by a corporate `.npmrc`, add `--regis
67
71
 
68
72
  ### Pull requests from forks
69
73
 
70
- CI for this repository runs on self-hosted runners for pushes and same-repo branches. For pull requests from forks a separate job runs on GitHub-hosted runners with Node 22 and current Stylelint 16, covering lint, typecheck and the test suite. You will see its result on your PR. The full matrix and the benchmark run after merge.
74
+ CI for this repository runs on self-hosted runners for pushes and same-repo branches. For pull requests from forks a separate job runs on GitHub-hosted runners with Node 22 and the Stylelint in the lockfile (17), covering lint, typecheck and the test suite. You will see its result on your PR. The full matrix and the benchmark run after merge.
71
75
 
72
76
  ## Where things live
73
77
 
@@ -96,7 +100,8 @@ CI for this repository runs on self-hosted runners for pushes and same-repo bran
96
100
  ## Compatibility
97
101
 
98
102
  - Stylelint `^16.0.0 || ^17.0.0`. The 16.0.0 floor has known autofix differences; CI runs the floor suite against it.
99
- - Node `>=20.19.0`.
103
+ - Node `>=20.19.0` until 4.0, which raises it to 22.22 ([#172](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/172)).
104
+ - ESLint `>=8.0.0` for the companion rules; tested on 9.
100
105
  - CommonJS and ESM entry points; every export has a declaration under `types/`.
101
106
  - Three runtime dependencies: `known-css-properties`, `postcss` and `postcss-value-parser`. `postcss-scss`, `stylelint-config-tailwindcss` and `stylelint-plugin-logical-css` are optional peers and dev dependencies here. `test/contracts/architecture.test.js` fails if a runtime import is not declared.
102
107
 
@@ -106,7 +111,7 @@ The maintainer aims to respond to issues and PRs within a week. Small PRs merge
106
111
 
107
112
  ## Releases
108
113
 
109
- Maintainer-run. Bump `package.json` and `CHANGELOG.md`, create a GitHub release, and `release.yml` verifies on the self-hosted matrix and publishes through npm trusted publishing (OIDC) from a GitHub-hosted job. The checklist covers cutting a release while the farm is down. Provenance is automatic. The checklist is in [`docs/RELEASE_CHECKLIST.md`](./docs/RELEASE_CHECKLIST.md).
114
+ Maintainer-run. Bump `package.json`, the two companion packages under `packages/` and `CHANGELOG.md` to the same version, create a GitHub release, and `release.yml` verifies on the self-hosted matrix and publishes through npm trusted publishing (OIDC) from a GitHub-hosted job. The checklist covers cutting a release while the farm is down. Provenance is automatic. The checklist is in [`docs/RELEASE_CHECKLIST.md`](./docs/RELEASE_CHECKLIST.md).
110
115
 
111
116
  ## Benchmarking performance
112
117
 
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="https://raw.githubusercontent.com/petrilahdelma/stylelint-plugin-rhythmguard/main/assets/rhythmguard-banner.svg?v=12" width="100%" alt="Rhythmguard: stable local evidence for design system drift" />
2
+ <img src="https://raw.githubusercontent.com/petrilahdelma/stylelint-plugin-rhythmguard/main/assets/rhythmguard-banner.png?v=13" width="100%" alt="Rhythmguard: stable local evidence for design system drift" />
3
3
  </p>
4
4
 
5
5
  # stylelint-plugin-rhythmguard
@@ -7,18 +7,18 @@
7
7
  Nobody chose 13px. Rhythmguard catches off-scale spacing in CSS and Tailwind class strings, tells you the nearest steps on your scale, and snaps to them or to your tokens when you ask.
8
8
 
9
9
  [![CI](https://img.shields.io/github/actions/workflow/status/petrilahdelma/stylelint-plugin-rhythmguard/ci.yml?branch=main&label=ci)](https://github.com/petrilahdelma/stylelint-plugin-rhythmguard/actions/workflows/ci.yml)
10
- [![npm version](https://img.shields.io/npm/v/stylelint-plugin-rhythmguard?label=npm&color=1f6feb&r=380)](https://www.npmjs.com/package/stylelint-plugin-rhythmguard)
10
+ [![npm version](https://img.shields.io/npm/v/stylelint-plugin-rhythmguard?label=npm&color=1f6feb&r=3100)](https://www.npmjs.com/package/stylelint-plugin-rhythmguard)
11
11
  [![npm downloads](https://img.shields.io/npm/dm/stylelint-plugin-rhythmguard.svg)](https://www.npmjs.com/package/stylelint-plugin-rhythmguard)
12
12
  [![License: MIT](https://img.shields.io/badge/license-MIT-white.svg)](./LICENSE)
13
13
 
14
14
  Rhythmguard is scale-aware rather than a blanket ban: values on your scale pass, values off it are reported with the two nearest steps, and tokens are only ever suggested from a map you control. It works on CSS and SCSS declarations through Stylelint and on Tailwind class strings through an ESLint companion, and it ships an audit CLI so you can measure drift and ratchet it down before enforcing anything.
15
15
 
16
- What it is not: it does not check colors or hex values, and the Stylelint rules do not see Tailwind class strings (that is the separate ESLint companion below). Pair it with a color linter if you need one; do not expect one tool to do both.
16
+ What it is not: it does not check colors or hex values, and the Stylelint rules do not see Tailwind class strings (that is the separate ESLint companion below). It reads CSS and SCSS; Less and CSS-in-JS are not read. Pair it with a color linter if you need one; do not expect one tool to do both.
17
17
 
18
18
  ## Start here
19
19
 
20
20
  ```bash
21
- npx stylelint-plugin-rhythmguard
21
+ npx rhythmguard
22
22
  ```
23
23
 
24
24
  No install, no config. It detects your stack and token files, infers your spacing scale from your own tokens, audits the current directory, and prints the exact `.stylelintrc.json` (and ESLint snippet for Tailwind) to paste. This is the whole run on Bootstrap's `v6-dev` branch, unedited:
@@ -47,11 +47,15 @@ That enables `rhythmguard/use-scale` on spacing properties with the default 4px
47
47
  }
48
48
  ```
49
49
 
50
- For class strings in JSX, TSX, Vue, Svelte or Astro, add the ESLint companion. It is the same rules under two names: `stylelint-plugin-rhythmguard/eslint` if you already have this package, or [`eslint-plugin-rhythmguard`](packages/eslint-plugin-rhythmguard#readme) on its own.
50
+ For class strings in JSX, TSX, Vue, Svelte or Astro, add the ESLint companion, [`eslint-plugin-rhythmguard`](packages/eslint-plugin-rhythmguard#readme). It is the same rules as `stylelint-plugin-rhythmguard/eslint`, so either import works. The rules also run under Oxlint's JS plugins.
51
+
52
+ ```bash
53
+ npm install --save-dev eslint eslint-plugin-rhythmguard
54
+ ```
51
55
 
52
56
  ```js
53
57
  // eslint.config.js
54
- import rhythmguard from 'stylelint-plugin-rhythmguard/eslint';
58
+ import rhythmguard from 'eslint-plugin-rhythmguard';
55
59
 
56
60
  export default [
57
61
  {
@@ -80,6 +84,8 @@ Every rule validates its options up front. Unknown option names and wrong shapes
80
84
 
81
85
  ## Audit before you enforce
82
86
 
87
+ With the package installed, the `rhythmguard` command is available to `npx`:
88
+
83
89
  ```bash
84
90
  npx rhythmguard audit ./src --format markdown
85
91
  npx rhythmguard audit ./src --write-baseline
@@ -101,14 +107,15 @@ The audit scans CSS declarations, Tailwind class strings and your token contract
101
107
  - [For coding agents](docs/FOR_AGENTS.md): a paste-ready `AGENTS.md` block, installable with `npx rhythmguard init --agents all` for Claude Code, Cursor and Copilot
102
108
  - [Quiet benchmark](docs/QUIET_BENCHMARK.md): findings on public design systems, checked on every change
103
109
  - [State of Spacing](docs/STATE_OF_SPACING.md): dated editions of the same data, ranked by drift density, with the values and properties that drifted
104
- - [Agent evals](docs/AGENT_EVALS.md): does a finding get a coding agent to zero drift, in how many rounds, at what cost, against a rules-only control
110
+ - [Agent evals](docs/AGENT_EVALS.md): does a finding get a coding agent to zero drift, in how many rounds, at what cost, against a rules-only control. First edition (2026-10-10): every model fixed every finding in one round with or without the findings; fixing from findings cost Sonnet 5 less than half as much
105
111
  - [Architecture](docs/ARCHITECTURE.md): the layers, the rule kit, the invariants and where each is enforced
106
- - [Product direction](docs/STRATEGY_2026-09.md)
112
+ - [Roadmap](ROADMAP.md): what is planned from October 2026 to June 2027, and the checkpoint that decides the pace
113
+ - [Product analysis, September 2026](docs/STRATEGY_2026-09.md): the market data behind the roadmap
107
114
  - Browser playground: [petrilahdelma.github.io/stylelint-plugin-rhythmguard](https://petrilahdelma.github.io/stylelint-plugin-rhythmguard/) runs the real rules, bundled for the browser, on CSS or SCSS you paste; `scale: "auto"`, tokens and fixes included, and a test proves it reports what Stylelint reports
108
115
 
109
116
  ## Compatibility
110
117
 
111
- Stylelint 16 and 17. Node 20.19 or newer. Three runtime dependencies (`known-css-properties`, `postcss`, `postcss-value-parser`); `postcss-scss`, `stylelint-config-tailwindcss` and `stylelint-plugin-logical-css` are optional peers. CommonJS and ESM entry points, TypeScript declarations for every export. The CI matrix runs Node 20 and 22 against Stylelint 16.0.0, 16.x and 17.x. Upgrading from 2.x: [docs/MIGRATING_TO_3.md](docs/MIGRATING_TO_3.md).
118
+ Stylelint 16 and 17. Node 20.19 or newer; Node 20 itself is end of life, and 4.0 raises the floor to 22.22 ([#172](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/172)). The ESLint companion needs ESLint 8 or newer, is tested on ESLint 9, and has been checked by hand on ESLint 10 and Oxlint 1.85. Three runtime dependencies (`known-css-properties`, `postcss`, `postcss-value-parser`); `postcss-scss`, `stylelint-config-tailwindcss` and `stylelint-plugin-logical-css` are optional peers. CommonJS and ESM entry points, TypeScript declarations for every export. The CI matrix runs Node 20 and 22 against Stylelint 16.0.0, 16.x and 17.x. Upgrading from 2.x: [docs/MIGRATING_TO_3.md](docs/MIGRATING_TO_3.md).
112
119
 
113
120
  ## Contributing and support
114
121
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stylelint-plugin-rhythmguard",
3
- "version": "3.8.0",
3
+ "version": "3.10.0",
4
4
  "description": "Nobody chose 13px. Catches off-scale spacing in CSS and Tailwind class strings and snaps it to your scale or tokens. Stylelint rules, an ESLint companion, and an audit CLI.",
5
5
  "bin": {
6
6
  "rhythmguard": "src/cli/index.js"
@@ -158,18 +158,18 @@
158
158
  "postcss-value-parser": "^4.2.0"
159
159
  },
160
160
  "devDependencies": {
161
- "@anthropic-ai/sdk": "^0.126.0",
162
- "@eslint/js": "^9.39.5",
161
+ "@anthropic-ai/sdk": "^0.133.0",
162
+ "@eslint/js": "^10.0.0",
163
163
  "@types/node": "^22.20.1",
164
164
  "c8": "^12.0.0",
165
- "esbuild": "^0.25.10",
166
- "eslint": "^9.39.5",
165
+ "esbuild": "^0.28.0",
166
+ "eslint": "^10.0.0",
167
167
  "postcss-scss": "^4.0.9",
168
- "stylelint": "^16.15.0",
168
+ "stylelint": "^17.0.0",
169
169
  "stylelint-config-tailwindcss": "^1.0.1",
170
170
  "stylelint-plugin-logical-css": "^2.0.2",
171
171
  "stylelint-scales": "^5.0.0",
172
- "typescript": "^5.9.3"
172
+ "typescript": "^7.0.0"
173
173
  },
174
174
  "engines": {
175
175
  "node": ">=20.19.0"
package/src/audit/args.js CHANGED
@@ -54,6 +54,8 @@ const OPTIONS = [
54
54
  { flag: '--since', kind: 'value', value: '<git-ref>', help: 'Scan only changed files since a git ref',
55
55
  apply: (p, raw) => { p.since = parsePathOption(raw, '--since'); setFrom(p, 'since'); } },
56
56
  { flag: '--staged', kind: 'flag', help: 'Scan only staged files', apply: (p) => { p.staged = true; setFrom(p, 'staged'); } },
57
+ { flag: '--check-math-bounds', kind: 'flag', help: 'Check clamp() minimum and maximum and min()/max() arguments against the scale',
58
+ apply: (p) => { p.checkMathBounds = true; setFrom(p, 'checkMathBounds'); } },
57
59
  { flag: '--include-motion', kind: 'flag', help: 'Include opt-in motion duration/easing drift',
58
60
  apply: (p) => { p.includeMotion = true; setFrom(p, 'includeMotion'); } },
59
61
  { flag: '--token-source', kind: 'value', value: '<file>', help: 'External token source (repeatable, comma-separated)',
@@ -3,6 +3,7 @@
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
  const { normalizeDecisions } = require('../core/decisions');
6
+ const { SCALED_GROUPS, isGroupScalesMap } = require('../core/options');
6
7
  const {
7
8
  normalizeTokenKind,
8
9
  normalizeTokenSourceFormat,
@@ -45,7 +46,7 @@ function loadAuditConfig(parsed) {
45
46
  try {
46
47
  config = JSON.parse(fs.readFileSync(resolvedPath, 'utf8'));
47
48
  } catch (err) {
48
- throw new Error(`Invalid Rhythmguard config ${parsed.configPath}: ${err.message}`);
49
+ throw new Error(`Invalid Rhythmguard config ${parsed.configPath}: ${err.message}`, { cause: err });
49
50
  }
50
51
 
51
52
  if (!config || typeof config !== 'object' || Array.isArray(config)) {
@@ -109,6 +110,11 @@ function applyAuditConfig(parsed, configResult) {
109
110
  applyConfigScalar(next, audit, cliOptions, 'tokenKind', 'tokenKind', normalizeTokenKind);
110
111
  applyConfigScalar(next, audit, cliOptions, 'baseFontSize', 'baseFontSize', parseBaseFontSize);
111
112
  applyConfigScalar(next, audit, cliOptions, 'includeMotion', 'includeMotion', parseBooleanOption);
113
+ applyConfigScalar(next, audit, cliOptions, 'checkMathBounds', 'checkMathBounds', parseBooleanOption);
114
+ applyConfigScalar(next, audit, cliOptions, 'scales', 'scales', parseGroupScales);
115
+ if (audit.ladders !== undefined) {
116
+ next.ladderMinStepPx = parseLadderOptions(audit.ladders);
117
+ }
112
118
  applyConfigScalar(next, audit, cliOptions, 'scale', 'scale', (value) => {
113
119
  if (Array.isArray(value)) {
114
120
  return value;
@@ -130,6 +136,23 @@ function applyConfigScalar(target, audit, cliOptions, targetKey, configKey, pars
130
136
  target[targetKey] = parser(audit[configKey], configKey);
131
137
  }
132
138
 
139
+ function parseLadderOptions(value) {
140
+ const minStepPx = value && typeof value === 'object' && !Array.isArray(value) ? value.minStepPx : undefined;
141
+ if (typeof minStepPx !== 'number' || !Number.isFinite(minStepPx) || minStepPx < 0) {
142
+ throw new Error('ladders.minStepPx must be a non-negative number of px.');
143
+ }
144
+
145
+ return minStepPx;
146
+ }
147
+
148
+ function parseGroupScales(value, optionName) {
149
+ if (!isGroupScalesMap(value)) {
150
+ throw new Error(`${optionName} must map ${SCALED_GROUPS.join(', ')} to a scale, a preset name or "auto".`);
151
+ }
152
+
153
+ return value;
154
+ }
155
+
133
156
  function parseBooleanOption(value, optionName) {
134
157
  if (typeof value !== 'boolean') {
135
158
  throw new Error(`${optionName} must be a boolean.`);
@@ -26,7 +26,7 @@ function collectTokenContract({
26
26
  const matchesKind = createTokenKindMatcher(tokenKind);
27
27
 
28
28
  for (const filePath of cssFiles) {
29
- let source = '';
29
+ let source;
30
30
  try {
31
31
  source = fs.readFileSync(filePath, 'utf8');
32
32
  } catch {
@@ -235,6 +235,7 @@ function buildReport({
235
235
  scanScope,
236
236
  scssFiles = 0,
237
237
  scssSkipped = 0,
238
+ stylelintIgnored = 0,
238
239
  templateFiles,
239
240
  tailwindFindings,
240
241
  tokenCandidateMinCount,
@@ -308,6 +309,7 @@ function buildReport({
308
309
  cssFiles: cssFiles.length,
309
310
  scssFiles,
310
311
  scssSkipped,
312
+ stylelintIgnored,
311
313
  templateFiles: templateFiles.length,
312
314
  totalFiles,
313
315
  },
@@ -27,6 +27,7 @@ function renderMarkdown(report) {
27
27
  `| Template files scanned | ${report.templateFilesScanned} |`,
28
28
  ...(report.scanned && report.scanned.scssFiles > 0 ? [`| SCSS files scanned | ${report.scanned.scssFiles - report.scanned.scssSkipped} |`] : []),
29
29
  ...(report.scanned && report.scanned.scssSkipped > 0 ? [`| SCSS files skipped (install postcss-scss) | ${report.scanned.scssSkipped} |`] : []),
30
+ ...(report.scanned && report.scanned.stylelintIgnored > 0 ? [`| Stylesheets ignored by .stylelintignore (not audited) | ${report.scanned.stylelintIgnored} |`] : []),
30
31
  `| Files with issues | ${report.filesWithIssues} |`,
31
32
  `| Total findings | ${report.totalWarnings} |`,
32
33
  `| Scale cleanliness | ${report.scaleCleanliness}% |`,
@@ -54,6 +55,7 @@ function renderMarkdown(report) {
54
55
  appendMarkdownCounts(lines, 'Motion Rhythm Drift', report.motion.values);
55
56
  appendTokenContractMarkdown(lines, report.tokenContract);
56
57
  appendTokenChainsMarkdown(lines, report.tokenContract.chains);
58
+ appendTokenLaddersMarkdown(lines, report.tokenContract.ladders);
57
59
  appendBaselineMarkdown(lines, report);
58
60
 
59
61
  if (report.topAffectedFiles.length > 0) {
@@ -117,6 +119,39 @@ function appendTokenChainsMarkdown(lines, chains) {
117
119
  }
118
120
  }
119
121
 
122
+ const LADDER_PROBLEM_TEXT = Object.freeze({
123
+ collapsed: 'collapsed',
124
+ inverted: 'inverted',
125
+ 'too-close': 'too close',
126
+ });
127
+
128
+ function appendTokenLaddersMarkdown(lines, ladders) {
129
+ if (!ladders || ladders.entries.length === 0) {
130
+ return;
131
+ }
132
+ const { summary } = ladders;
133
+ lines.push('## Token Ladders');
134
+ lines.push('');
135
+ lines.push(`${ladders.entries.length} of ${summary.ladders} token ladders have steps that do not read as steps: ${summary.collapsed} collapsed, ${summary.tooClose} under ${summary.minStepPx}px apart, ${summary.inverted} inverted. Each step is shown at its smallest and largest value.`);
136
+ lines.push('');
137
+ for (const entry of ladders.entries.slice(0, 20)) {
138
+ const flagged = new Map();
139
+ for (const problem of entry.problems) {
140
+ const where = problem.end === 'both' ? '' : ` at ${problem.end}`;
141
+ const note = `${LADDER_PROBLEM_TEXT[problem.type]}${where} with \`${problem.tokens[0]}\``;
142
+ flagged.set(problem.tokens[1], [...(flagged.get(problem.tokens[1]) || []), note]);
143
+ }
144
+ lines.push(`**\`${escapeMarkdown(entry.prefix)}\`**`);
145
+ lines.push('');
146
+ lines.push('| Token | Min | Max | Problem |');
147
+ lines.push('| --- | --- | --- | --- |');
148
+ for (const step of entry.steps) {
149
+ lines.push(`| \`${escapeMarkdown(step.token)}\` | ${step.min || 'fluid'} | ${step.max || 'fluid'} | ${(flagged.get(step.token) || []).join('; ')} |`);
150
+ }
151
+ lines.push('');
152
+ }
153
+ }
154
+
120
155
  function appendTokenContractMarkdown(lines, tokenContract) {
121
156
  const {
122
157
  conflictingTokens,
@@ -21,6 +21,7 @@ function renderText(report) {
21
21
  ` Template files scanned ${String(report.templateFilesScanned).padStart(4)}`,
22
22
  ...(report.scanned && report.scanned.scssFiles > 0 ? [` SCSS files scanned ${String(report.scanned.scssFiles - report.scanned.scssSkipped).padStart(4)}`] : []),
23
23
  ...(report.scanned && report.scanned.scssSkipped > 0 ? [` SCSS files skipped ${String(report.scanned.scssSkipped).padStart(4)} (install postcss-scss to audit them)`] : []),
24
+ ...(report.scanned && report.scanned.stylelintIgnored > 0 ? [` Ignored by .stylelintignore ${String(report.scanned.stylelintIgnored).padStart(4)} (not audited)`] : []),
24
25
  ` Files with issues ${String(report.filesWithIssues).padStart(4)}`,
25
26
  ` Scale cleanliness ${scoreBar(report.scaleCleanliness)} ${report.scaleCleanliness}%`,
26
27
  ];
@@ -50,6 +51,11 @@ function renderText(report) {
50
51
  lines.push(` Token chains ${summary['on-scale']} of ${summary.total} resolve to the scale (${summary['off-scale']} off-scale, ${summary.unresolved} unresolved, ${summary.ambiguous} ambiguous)`);
51
52
  lines.push('');
52
53
  }
54
+ if (report.tokenContract.ladders && report.tokenContract.ladders.summary.ladders > 0) {
55
+ const { summary } = report.tokenContract.ladders;
56
+ lines.push(` Token ladders ${summary.ladders} checked (${summary.collapsed} collapsed, ${summary.tooClose} under ${summary.minStepPx}px apart, ${summary.inverted} inverted steps)`);
57
+ lines.push('');
58
+ }
53
59
  appendBaselineText(lines, report);
54
60
 
55
61
  if (report.topAffectedFiles.length > 0) {
@@ -26,6 +26,7 @@ const {
26
26
  } = require('../core/scale-inference');
27
27
  const { applyDecisions, buildDecisionPlan } = require('./decisions');
28
28
  const { collectDeclarations, resolveTokenChains, toChainsContract } = require('./token-chains');
29
+ const { resolveTokenLadders, toLaddersContract } = require('./token-ladders');
29
30
  const { assertDirectory, getScanFiles } = require('./scan/files');
30
31
  const { collectCssFindings, runStylelintAudit } = require('./scan/stylesheets');
31
32
  const { collectTailwindFindings, collectTailwindMotionFindings } = require('./scan/templates');
@@ -58,8 +59,10 @@ async function createAuditReport(options) {
58
59
 
59
60
  const lintOptions = {
60
61
  baseFontSize: parsed.baseFontSize,
62
+ checkMathBounds: parsed.checkMathBounds,
61
63
  includeMotion: parsed.includeMotion,
62
64
  scale: scale.values,
65
+ scales: parsed.scales,
63
66
  };
64
67
 
65
68
  const cssResults = await runStylelintAudit(cssFiles, lintOptions);
@@ -89,6 +92,7 @@ async function createAuditReport(options) {
89
92
  scanScope,
90
93
  scssFiles: cssResults.scssFiles || 0,
91
94
  scssSkipped: cssResults.scssSkipped || 0,
95
+ stylelintIgnored: cssResults.stylelintIgnored || 0,
92
96
  tailwindFindings: decided.tailwindFindings,
93
97
  templateFiles,
94
98
  tokenCandidateMinCount: parsed.tokenCandidateMinCount,
@@ -97,15 +101,21 @@ async function createAuditReport(options) {
97
101
  tokenSourceWarnings: tokenSourceResult.warnings,
98
102
  });
99
103
 
104
+ const declarations = collectDeclarations({
105
+ cssFiles,
106
+ externalDefinitions: tokenSourceResult.definitions,
107
+ skipFile: (file) => NON_AUTHORED_SEGMENT.test(file),
108
+ });
100
109
  report.tokenContract.chains = toChainsContract(resolveTokenChains({
101
110
  baseFontSize: parsed.baseFontSize,
102
- declarations: collectDeclarations({
103
- cssFiles,
104
- externalDefinitions: tokenSourceResult.definitions,
105
- skipFile: (file) => NON_AUTHORED_SEGMENT.test(file),
106
- }),
111
+ declarations,
107
112
  scale: scale.values,
108
113
  }));
114
+ report.tokenContract.ladders = toLaddersContract(resolveTokenLadders({
115
+ baseFontSize: parsed.baseFontSize,
116
+ declarations,
117
+ minStepPx: parsed.ladderMinStepPx,
118
+ }));
109
119
  report.decisions = decided.summary;
110
120
  report.decisionPlan = buildDecisionPlan({
111
121
  baseFontSize: parsed.baseFontSize,
@@ -80,7 +80,7 @@ function getGitChangedScanFiles(rootDir, ignorePatterns, parsed) {
80
80
  const args = parsed.staged
81
81
  ? ['diff', '--name-only', '--cached', '--diff-filter=ACMR', '--']
82
82
  : ['diff', '--name-only', '--diff-filter=ACMR', parsed.since, '--'];
83
- let output = '';
83
+ let output;
84
84
 
85
85
  try {
86
86
  output = execFileSync('git', args, {
@@ -90,7 +90,7 @@ function getGitChangedScanFiles(rootDir, ignorePatterns, parsed) {
90
90
  });
91
91
  } catch (err) {
92
92
  const stderr = err.stderr ? String(err.stderr).trim() : err.message;
93
- throw new Error(`Unable to read changed files from git: ${stderr}`);
93
+ throw new Error(`Unable to read changed files from git: ${stderr}`, { cause: err });
94
94
  }
95
95
 
96
96
  const cssFiles = [];
@@ -47,7 +47,10 @@ async function runStylelintAudit(cssFiles, options) {
47
47
  {
48
48
  decisions: false,
49
49
  baseFontSize: options.baseFontSize,
50
+ checkMathBounds: options.checkMathBounds === true,
50
51
  scale: options.scale,
52
+ // audit.scales adds the groups it names, each checked against its own scale.
53
+ ...(options.scales ? { propertyGroups: ['spacing', ...Object.keys(options.scales)], scales: options.scales } : {}),
51
54
  severity: 'warning',
52
55
  },
53
56
  ],
@@ -55,6 +58,7 @@ async function runStylelintAudit(cssFiles, options) {
55
58
  true,
56
59
  {
57
60
  baseFontSize: options.baseFontSize,
61
+ checkMathBounds: options.checkMathBounds === true,
58
62
  scale: options.scale,
59
63
  severity: 'warning',
60
64
  tokenMapFromCssCustomProperties: true,
@@ -77,7 +81,10 @@ async function runStylelintAudit(cssFiles, options) {
77
81
  const results = [];
78
82
 
79
83
  if (plainFiles.length > 0) {
84
+ // A project's own .stylelintignore may exclude every file we pass; that is
85
+ // a count to report, not a reason for the audit to throw.
80
86
  const result = await stylelint.lint({
87
+ allowEmptyInput: true,
81
88
  files: plainFiles,
82
89
  config: {
83
90
  plugins: [pluginPath],
@@ -92,6 +99,7 @@ async function runStylelintAudit(cssFiles, options) {
92
99
  const scssSyntax = resolveScssSyntax();
93
100
  if (scssSyntax) {
94
101
  const result = await stylelint.lint({
102
+ allowEmptyInput: true,
95
103
  files: scssFiles,
96
104
  config: {
97
105
  customSyntax: scssSyntax,
@@ -107,6 +115,10 @@ async function runStylelintAudit(cssFiles, options) {
107
115
 
108
116
  results.scssFiles = scssFiles.length;
109
117
  results.scssSkipped = scssSkipped;
118
+ // Stylelint returns no result at all for a file its ignore file excludes, so
119
+ // the difference between what was passed and what came back is that count.
120
+ const linted = plainFiles.length + scssFiles.length - scssSkipped;
121
+ results.stylelintIgnored = Math.max(0, linted - results.length);
110
122
  return results;
111
123
  }
112
124
 
@@ -119,10 +131,10 @@ function collectCssFindings(fileResults) {
119
131
  const text = warning.text || '';
120
132
  const source = readSourceOnce(sources, fileResult.source);
121
133
  const offScaleMatch = text.match(
122
- /Unexpected (?:off-scale value|transform translation value) "([^"]+)"/,
134
+ /Unexpected (?:off-scale (?:value|(?:clamp|min|max)\(\) (?:minimum|maximum|argument))|transform translation value) "([^"]+)"/,
123
135
  );
124
136
  const tokenMatch = text.match(
125
- /Unexpected raw scale value "([^"]+)"/,
137
+ /Unexpected raw (?:scale value|(?:clamp|min|max)\(\) (?:minimum|maximum|argument)) "([^"]+)"/,
126
138
  );
127
139
  const motionDurationMatch = text.match(
128
140
  /Unexpected (?:motion duration|negative motion duration) "([^"]+)"/,
@@ -18,7 +18,7 @@ function collectTailwindFindings(templateFiles, options) {
18
18
  const findings = [];
19
19
 
20
20
  for (const filePath of templateFiles) {
21
- let source = '';
21
+ let source;
22
22
  try {
23
23
  source = fs.readFileSync(filePath, 'utf8');
24
24
  } catch {
@@ -64,7 +64,7 @@ function collectTailwindMotionFindings(templateFiles, options) {
64
64
  const findings = [];
65
65
 
66
66
  for (const filePath of templateFiles) {
67
- let source = '';
67
+ let source;
68
68
  try {
69
69
  source = fs.readFileSync(filePath, 'utf8');
70
70
  } catch {
@@ -115,17 +115,51 @@ function buildTailwindMotionFindingText(token, analysis) {
115
115
  return `Unexpected Tailwind arbitrary motion duration "${token}". Use duration scale values.`;
116
116
  }
117
117
 
118
+ /**
119
+ * Linear scan for string literals. A regex did this before, and from every
120
+ * quote that never closes (an apostrophe in a comment, a quote inside a regex
121
+ * literal) it backtracked to the end of the file; on a 3.5 MB minified editor
122
+ * bundle that took hours. This walks the source once: an escape never closes a
123
+ * literal, a ' or " literal that reaches a line break is not a literal (the
124
+ * language agrees), and a backtick literal may span lines.
125
+ */
118
126
  function findStringLiterals(source) {
119
127
  const literals = [];
120
- const literalPattern = /(["'`])((?:\\[\s\S]|(?!\1)[\s\S])*?)\1/g;
121
- let match;
122
-
123
- while ((match = literalPattern.exec(source)) !== null) {
124
- literals.push({
125
- quote: match[1],
126
- value: match[2],
127
- valueStart: match.index + 1,
128
- });
128
+ const { length } = source;
129
+ let index = 0;
130
+
131
+ while (index < length) {
132
+ const quote = source[index];
133
+ if (quote !== '"' && quote !== "'" && quote !== '`') {
134
+ index += 1;
135
+ continue;
136
+ }
137
+
138
+ const valueStart = index + 1;
139
+ let cursor = valueStart;
140
+ let closed = false;
141
+ while (cursor < length) {
142
+ const char = source[cursor];
143
+ if (char === '\\') {
144
+ cursor += 2;
145
+ continue;
146
+ }
147
+ if (char === quote) {
148
+ closed = true;
149
+ break;
150
+ }
151
+ if (quote !== '`' && char === '\n') {
152
+ break;
153
+ }
154
+ cursor += 1;
155
+ }
156
+
157
+ if (closed) {
158
+ literals.push({ quote, value: source.slice(valueStart, cursor), valueStart });
159
+ index = cursor + 1;
160
+ } else {
161
+ index = valueStart;
162
+ }
129
163
  }
130
164
 
131
165
  return literals;
@@ -75,12 +75,15 @@ function createDefaultAuditOptions() {
75
75
  format: 'text',
76
76
  ignorePath: DEFAULT_IGNORE_PATH,
77
77
  ignorePatterns: [],
78
+ checkMathBounds: false,
78
79
  includeMotion: false,
80
+ ladderMinStepPx: 2,
79
81
  maxFindings: null,
80
82
  minCleanliness: null,
81
83
  noConfig: false,
82
84
  outputPath: null,
83
85
  scale: DEFAULT_SCALE,
86
+ scales: null,
84
87
  schema: false,
85
88
  since: null,
86
89
  sinceBaseline: false,