stylelint-plugin-rhythmguard 3.5.0 → 3.7.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/CONTRIBUTING.md +8 -5
  3. package/README.md +8 -10
  4. package/SECURITY.md +3 -2
  5. package/agents/claude-code/SKILL.md +1 -1
  6. package/agents/copilot/copilot-instructions.md +1 -1
  7. package/agents/cursor/rhythmguard.mdc +1 -1
  8. package/package.json +2 -1
  9. package/src/audit/args.js +1 -0
  10. package/src/audit/baseline.js +55 -11
  11. package/src/audit/codemod.js +129 -0
  12. package/src/audit/config.js +5 -0
  13. package/src/audit/contract.js +1 -0
  14. package/src/audit/decisions.js +105 -0
  15. package/src/audit/render-github.js +5 -2
  16. package/src/audit/render-markdown.js +31 -0
  17. package/src/audit/render-text.js +11 -0
  18. package/src/audit/report.js +30 -2
  19. package/src/audit/scan/stylesheets.js +1 -0
  20. package/src/audit/scan/templates.js +2 -4
  21. package/src/audit/shared.js +2 -0
  22. package/src/audit/token-chains.js +157 -0
  23. package/src/cli/audit.js +5 -0
  24. package/src/cli/fix.js +151 -0
  25. package/src/cli/index.js +4 -0
  26. package/src/core/decisions.js +121 -0
  27. package/src/core/fs-cache.js +36 -0
  28. package/src/core/length.js +1 -0
  29. package/src/core/options.js +40 -0
  30. package/src/core/scale-inference.js +34 -37
  31. package/src/core/tailwind-class-analysis.js +59 -3
  32. package/src/core/token-index.js +82 -0
  33. package/src/core/token-map.js +50 -13
  34. package/src/core/token-sources.js +3 -2
  35. package/src/eslint/rules/tailwind-class-use-motion-scale.js +6 -2
  36. package/src/eslint/rules/tailwind-class-use-scale.js +13 -13
  37. package/src/rules/no-offscale-transform/index.js +27 -6
  38. package/src/rules/prefer-token/index.js +20 -29
  39. package/src/rules/report.js +6 -0
  40. package/src/rules/use-motion-scale/index.js +12 -8
  41. package/src/rules/use-scale/index.js +35 -6
  42. package/types/audit.d.ts +31 -0
  43. package/types/shared.d.ts +6 -0
package/CHANGELOG.md CHANGED
@@ -6,6 +6,37 @@ The format follows Keep a Changelog principles and semantic versioning.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [3.7.0] - 2026-09-17
10
+
11
+ ### Added
12
+
13
+ - Every rule accepts `note`, a sentence of up to 200 characters appended to each finding after the built-in text: `... (nearest: 12px or 16px). See docs/spacing.md for approved exceptions.` The built-in text is unchanged, so tooling that matches on it keeps working. Stylelint and ESLint rules alike. ([#134](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/134))
14
+
15
+ ### Changed
16
+
17
+ - `prefer-token` names the token it would write and where it was read from: `Unexpected raw scale value "12px". Use var(--space-3) (src/tokens.css).` The origin is the linted stylesheet, the `tokenMapFile` or the Tailwind config; an inline `tokenMap` entry is named without one, and a negative literal as `calc(-1 * var(--space-3))`. When no mapping holds the value the message says `No known token holds this value; use the nearest token or add one.` It said `Use design tokens for scale decisions.` in every case. ([#133](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/133))
18
+ - `use-scale` and `no-offscale-transform` with `fixWith: "token"` append `var(--space-3) holds 12px.` when exactly one token qualifies, so the message names what the fix writes. ([#133](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/133))
19
+ - `tailwind-class-use-scale` names the utility classes for the two nearest steps, with their px values, and its fix writes the utility class instead of an on-scale arbitrary value: `p-[13px]` reads `Use "p-3" (12px) or "p-4" (16px)` and becomes `p-3`; `md:-m-[13px]!` becomes `md:-m-3!`; `p-[0.8125rem]` becomes `p-3`. The class number is the snapped value divided by the new `spacingUnit` option (4px, Tailwind's `--spacing`); when it is not a quarter step the fix keeps the arbitrary form, and `spacingUnit: false` keeps it always. The audit's Tailwind findings carry the same text and `fixedToken`. ([#132](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/132))
20
+ - The release workflow verifies and publishes on GitHub-hosted runners, and the post-publish smoke runs there too. The first 3.7.0 release waited 24 hours for a self-hosted runner that never registered and was cancelled; a release no longer depends on the farm. Pushes and pull requests still use it.
21
+
22
+ ## [3.6.0] - 2026-09-09
23
+
24
+ ### Added
25
+
26
+ - **Token chains.** The audit follows every spacing-named custom property through `var()` references to its terminal values and reports, in `contracts.tokens.chains` and a Markdown section, whether each resolves on the scale, off it, to an undeclared token or a cycle, to several lengths at once (themes), to a computed expression, or to a non-length. Fallbacks are honoured; declarations from token sources and installed packages count. This is the measurement token-layered systems need, where a literal scan finds almost nothing. ([#110](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/110))
27
+ - `rhythmguard fix <dir> --value <length> --to <replacement>` replaces one spacing literal everywhere it appears, matching by px, keeping the sign, never touching token definitions, token functions or non-spacing properties; dry run until `--write`, idempotent, SCSS through `postcss-scss`. `--decided` executes every `snap` decision whose `to` names the replacement. `postcss` is now a declared runtime dependency (the rules always received its AST; the codemod parses files itself).
28
+ - `use-scale` and `no-offscale-transform` accept `fixWith: "token"`: autofix writes `var(--name)` when exactly one custom property holds the snapped value in the literal's own unit, read from the stylesheet, `scaleSources`, the config file's token sources and installed token packages, and the literal otherwise. A rem literal never becomes a px token, and two tokens with the same value never become a guess. Default stays `"value"`; the default flips in 4.0.
29
+ - **Decisions.** A `decisions` section in `.rhythmguardrc.json` records what a team decided about each off-scale value: `adopt` (part of the scale, optionally naming the token it should become), `allow` (intentional, optionally on some properties only), `snap` (a slip to fix) or `undecided`. Adopted and allowed values stop being findings in the Stylelint rules and the audit alike; values match by px so `10px` and `0.625rem` are one decision. `rhythmguard audit --plan` proposes the section from the current findings and keeps decisions already made. The audit reports counts in `contracts.decisions`. Rules accept `decisions: false` to ignore the section. Docs: `docs/AUDIT.md#decisions`.
30
+
31
+ ### Changed
32
+
33
+ - Baselines key findings by content (rule, file, property, value, occurrence) instead of line and column, so moving or reformatting code no longer produces matching "new" and "resolved" pairs while a second identical off-scale declaration is still new. Baseline files are `formatVersion: 2`; version 1 files still compare and upgrade on the next `--write-baseline`. Text, Markdown and GitHub output lead with `Since baseline: N resolved, M new`.
34
+
35
+ ### Fixed
36
+
37
+ - Token values of the form `calc(var(--x) * 2)` are no longer read as a 2px token; the calc forms require a unit on the factor, as in Radix's `calc(4px * var(--scaling))`.
38
+ - `prefer-token` autofix writes a negative token as `calc(-1 * var(--token))`. It wrote `-var(--token)`, which is not valid CSS.
39
+
9
40
  ## [3.5.0] - 2026-09-08
10
41
 
11
42
  ### Fixed
package/CONTRIBUTING.md CHANGED
@@ -27,7 +27,7 @@ npm ci
27
27
  npm test
28
28
  ```
29
29
 
30
- Node 20.19 or newer. No build step; the source runs as is. Tests use `node --test` and take about ten seconds. They are grouped by what they prove:
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:
31
31
 
32
32
  | Folder | Proves | Run one |
33
33
  | --- | --- | --- |
@@ -58,8 +58,11 @@ npm run test:compat-floor # Stylelint 16.0.0, the oldest supported
58
58
  npm run test:pack-smoke # pack the tarball and install it in a temp project
59
59
  npm run scales:validate # community scale JSON
60
60
  npm run bench:quiet -- --check # findings on the benchmark repos must match snapshots
61
+ npm run build:agents # after editing the block in docs/FOR_AGENTS.md
61
62
  ```
62
63
 
64
+ 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.
65
+
63
66
  If your shell's npm registry is overridden by a corporate `.npmrc`, add `--registry https://registry.npmjs.org` to `npm ci` and prefix `test:pack-smoke` with `npm_config_registry=https://registry.npmjs.org`.
64
67
 
65
68
  ### Pull requests from forks
@@ -78,7 +81,7 @@ CI for this repository runs on self-hosted runners for pushes and same-repo bran
78
81
 
79
82
  **The benchmark is the regression suite for real code.** If your change alters findings on any benchmark repository, `npm run bench:quiet -- --check` fails and prints the diff. Review it. If the new findings are right, run `npm run bench:quiet -- --update-snapshots`, commit `benchmarks/quiet/snapshots/`, and say in the PR why they are right.
80
83
 
81
- **Fixes are deterministic.** Autofix snaps to the nearest scale step or to an explicit token map. It never guesses a token. Keep that property.
84
+ **Fixes are deterministic.** Autofix snaps to the nearest scale step, or with `fixWith: "token"` writes the one custom property that holds that value in the same unit. It never guesses a token: two candidates, a unit mismatch or a Sass variable all mean the literal is written instead. `rhythmguard fix` follows the same rule. Keep that property.
82
85
 
83
86
  **Prose without em dashes.** House style, applied to docs and messages alike.
84
87
 
@@ -88,14 +91,14 @@ CI for this repository runs on self-hosted runners for pushes and same-repo bran
88
91
  - Minor: new options, presets, sources, or behaviour that does not change existing reports.
89
92
  - Major: any change to default reports for existing configs, to autofix behaviour, or to exported entry points.
90
93
 
91
- `configs/recommended`, `configs/strict`, `configs/tailwind` and `configs/embed` stay stable within a major. The `embed` config's shape is frozen for 2.x because shared configs depend on it.
94
+ `configs/recommended`, `configs/strict`, `configs/tailwind`, `configs/motion` and `configs/embed` stay stable within a major. The `embed` config's shape is frozen within a major because shared configs depend on it.
92
95
 
93
96
  ## Compatibility
94
97
 
95
98
  - Stylelint `^16.0.0 || ^17.0.0`. The 16.0.0 floor has known autofix differences; CI runs the floor suite against it.
96
99
  - Node `>=20.19.0`.
97
100
  - CommonJS and ESM entry points; every export has a declaration under `types/`.
98
- - One runtime dependency, `known-css-properties`. `postcss-scss`, `stylelint-config-tailwindcss` and `stylelint-plugin-logical-css` are optional peers and dev dependencies here.
101
+ - 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.
99
102
 
100
103
  ## Review
101
104
 
@@ -103,7 +106,7 @@ The maintainer aims to respond to issues and PRs within a week. Small PRs merge
103
106
 
104
107
  ## Releases
105
108
 
106
- 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. Provenance is automatic. The checklist is in [`docs/RELEASE_CHECKLIST.md`](./docs/RELEASE_CHECKLIST.md).
109
+ Maintainer-run. Bump `package.json` and `CHANGELOG.md`, create a GitHub release, and `release.yml` verifies the matrix and publishes through npm trusted publishing (OIDC), on GitHub-hosted runners so a release does not depend on the self-hosted farm. Provenance is automatic. The checklist is in [`docs/RELEASE_CHECKLIST.md`](./docs/RELEASE_CHECKLIST.md).
107
110
 
108
111
  ## Benchmarking performance
109
112
 
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=7" width="100%" alt="Rhythmguard banner showing spacing scale ruler and lint output" />
2
+ <img src="https://raw.githubusercontent.com/petrilahdelma/stylelint-plugin-rhythmguard/main/assets/rhythmguard-banner.svg?v=10" width="100%" alt="Rhythmguard: stable local evidence for design system drift" />
3
3
  </p>
4
4
 
5
5
  # stylelint-plugin-rhythmguard
@@ -7,13 +7,13 @@
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)](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=370)](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
- 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 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.
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. SCSS is audited when `postcss-scss` is installed.
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.
17
17
 
18
18
  ## Start here
19
19
 
@@ -81,11 +81,9 @@ npx rhythmguard audit ./src --since-baseline --fail-on-new-drift
81
81
  npx rhythmguard audit ./src --format github
82
82
  ```
83
83
 
84
- The audit scans CSS declarations, Tailwind class strings and your token contract, prints a scale-cleanliness score, and supports baselines so legacy codebases can gate only new drift. Formats: text, Markdown, JSON 2.0, HTML, and GitHub Actions annotations. Full reference in [docs/AUDIT.md](docs/AUDIT.md), rollout recipe in [docs/CI_ADOPTION.md](docs/CI_ADOPTION.md).
84
+ The audit scans CSS declarations, Tailwind class strings and your token contract, prints a scale-cleanliness score, and supports baselines so legacy codebases can gate only new drift. Formats: text, Markdown, JSON 2.0, HTML, GitHub Actions annotations, and a shields.io badge document for your README. Full reference in [docs/AUDIT.md](docs/AUDIT.md), rollout recipe and the badge workflow in [docs/CI_ADOPTION.md](docs/CI_ADOPTION.md).
85
85
 
86
- `npx rhythmguard init` writes a starter config for your stack. `npx rhythmguard doctor` checks the setup.
87
-
88
- A README badge comes from the same audit: `--format badge` writes a shields.io endpoint document, see [`docs/CI_ADOPTION.md`](./docs/CI_ADOPTION.md#5-show-a-badge).
86
+ `npx rhythmguard audit ./src --plan` turns the report into a proposed `decisions` section (adopt a value, allow it, or snap it), and `npx rhythmguard fix ./src --value 10px --to "var(--space-sm)" --write` executes one decision as one reviewable change; see [decisions](docs/AUDIT.md#decisions). `npx rhythmguard init` writes a starter config for your stack, and `init --agents all` installs the coding-agent instructions. `npx rhythmguard doctor` checks the setup.
89
87
 
90
88
  ## Guides
91
89
 
@@ -99,11 +97,11 @@ A README badge comes from the same audit: `--format badge` writes a shields.io e
99
97
  - [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
100
98
  - [Architecture](docs/ARCHITECTURE.md): the layers, the rule kit, the invariants and where each is enforced
101
99
  - [Product direction](docs/STRATEGY_2026-09.md)
102
- - Browser playground: [petrilahdelma.github.io/stylelint-plugin-rhythmguard](https://petrilahdelma.github.io/stylelint-plugin-rhythmguard/)
100
+ - Browser playground: [petrilahdelma.github.io/stylelint-plugin-rhythmguard](https://petrilahdelma.github.io/stylelint-plugin-rhythmguard/), a simplified re-implementation for trying values in the browser; the real rules run under Node ([#57](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/57) tracks bundling them)
103
101
 
104
102
  ## Compatibility
105
103
 
106
- Stylelint 16 and 17. Node 20.19 or newer. One runtime dependency (`known-css-properties`); `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).
104
+ 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).
107
105
 
108
106
  ## Contributing and support
109
107
 
package/SECURITY.md CHANGED
@@ -4,8 +4,9 @@
4
4
 
5
5
  | Version | Supported |
6
6
  | --- | --- |
7
- | 2.x | Yes |
8
- | < 2.0 | No, upgrade to 2.x |
7
+ | 3.x | Yes |
8
+ | 2.x | Security fixes only until 2027-03-01 |
9
+ | < 2.0 | No, upgrade to 3.x |
9
10
 
10
11
  ## Reporting a Vulnerability
11
12
 
@@ -11,7 +11,7 @@ This project enforces its spacing scale with stylelint-plugin-rhythmguard.
11
11
  - A finding names the value and the two nearest on-scale values. Prefer the design token for that value when one exists; otherwise use the nearest on-scale value. Never invent a new spacing value.
12
12
  - Do not use `--fix` on spacing findings unless the task says so. Snapping a value can change layout; choose the value deliberately.
13
13
  - Values of one pixel or less (hairlines) and percentages are allowed and are not findings.
14
- - Tailwind arbitrary spacing values such as `p-[13px]` are findings too; use the scale utility (`p-3`) or an on-scale arbitrary value.
14
+ - Tailwind arbitrary spacing values such as `p-[13px]` are findings too. The finding names the two utility classes to choose from (`p-3` or `p-4`); write one of those, not another arbitrary value.
15
15
  - When the audit's `contracts.scale.offScaleProperties` table is dominated by margins on sibling elements, put a `gap` on the parent instead of fixing each margin. The parent owns the spacing between its children.
16
16
  - If the audit reports the scale source as `fallback`, the project has no discoverable spacing tokens. Ask before adding any; do not guess a scale.
17
17
 
@@ -6,7 +6,7 @@ This project enforces its spacing scale with stylelint-plugin-rhythmguard.
6
6
  - A finding names the value and the two nearest on-scale values. Prefer the design token for that value when one exists; otherwise use the nearest on-scale value. Never invent a new spacing value.
7
7
  - Do not use `--fix` on spacing findings unless the task says so. Snapping a value can change layout; choose the value deliberately.
8
8
  - Values of one pixel or less (hairlines) and percentages are allowed and are not findings.
9
- - Tailwind arbitrary spacing values such as `p-[13px]` are findings too; use the scale utility (`p-3`) or an on-scale arbitrary value.
9
+ - Tailwind arbitrary spacing values such as `p-[13px]` are findings too. The finding names the two utility classes to choose from (`p-3` or `p-4`); write one of those, not another arbitrary value.
10
10
  - When the audit's `contracts.scale.offScaleProperties` table is dominated by margins on sibling elements, put a `gap` on the parent instead of fixing each margin. The parent owns the spacing between its children.
11
11
  - If the audit reports the scale source as `fallback`, the project has no discoverable spacing tokens. Ask before adding any; do not guess a scale.
12
12
 
@@ -12,7 +12,7 @@ This project enforces its spacing scale with stylelint-plugin-rhythmguard.
12
12
  - A finding names the value and the two nearest on-scale values. Prefer the design token for that value when one exists; otherwise use the nearest on-scale value. Never invent a new spacing value.
13
13
  - Do not use `--fix` on spacing findings unless the task says so. Snapping a value can change layout; choose the value deliberately.
14
14
  - Values of one pixel or less (hairlines) and percentages are allowed and are not findings.
15
- - Tailwind arbitrary spacing values such as `p-[13px]` are findings too; use the scale utility (`p-3`) or an on-scale arbitrary value.
15
+ - Tailwind arbitrary spacing values such as `p-[13px]` are findings too. The finding names the two utility classes to choose from (`p-3` or `p-4`); write one of those, not another arbitrary value.
16
16
  - When the audit's `contracts.scale.offScaleProperties` table is dominated by margins on sibling elements, put a `gap` on the parent instead of fixing each margin. The parent owns the spacing between its children.
17
17
  - If the audit reports the scale source as `fallback`, the project has no discoverable spacing tokens. Ask before adding any; do not guess a scale.
18
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stylelint-plugin-rhythmguard",
3
- "version": "3.5.0",
3
+ "version": "3.7.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"
@@ -147,6 +147,7 @@
147
147
  },
148
148
  "dependencies": {
149
149
  "known-css-properties": "^0.37.0",
150
+ "postcss": "^8.4.0",
150
151
  "postcss-value-parser": "^4.2.0"
151
152
  },
152
153
  "devDependencies": {
package/src/audit/args.js CHANGED
@@ -29,6 +29,7 @@ const OPTIONS = [
29
29
  { flag: '--json', kind: 'flag', help: 'Alias for --format json', apply: (p) => { p.format = 'json'; } },
30
30
  { flag: '--markdown', kind: 'flag', help: 'Alias for --format markdown', apply: (p) => { p.format = 'markdown'; } },
31
31
  { flag: '--schema', kind: 'flag', help: 'Print the audit JSON schema and exit', apply: (p) => { p.schema = true; p.format = 'json'; } },
32
+ { flag: '--plan', kind: 'flag', help: 'Print a proposed decisions section for .rhythmguardrc.json: one entry per off-scale value', apply: (p) => { p.plan = true; } },
32
33
  { flag: '--output', kind: 'value', value: '<file>', help: 'Write json, markdown, html or badge output to a file',
33
34
  apply: (p, raw) => { p.outputPath = parsePathOption(raw, '--output'); setFrom(p, 'outputPath'); } },
34
35
  { flag: '--config', kind: 'value', value: '<file>', help: 'Load audit config (default: .rhythmguardrc.json when present)',
@@ -4,6 +4,16 @@ const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
  const { formatPath } = require('./shared');
6
6
 
7
+ /**
8
+ * Baseline keys name a finding by what it is, not where it sits: rule, type,
9
+ * file, property (or Tailwind class), value, and the occurrence index among
10
+ * identical findings in that file. Inserting a comment above a declaration no
11
+ * longer turns it into one "resolved" and one "new"; adding a second identical
12
+ * off-scale declaration still counts as new. Version 1 baselines keyed by line
13
+ * and column are still compared with the key they were written with.
14
+ */
15
+ const BASELINE_FORMAT_VERSION = 2;
16
+
7
17
  function applyBaselineComparison(report, baselinePath) {
8
18
  const resolvedPath = path.resolve(process.cwd(), baselinePath);
9
19
  if (!fs.existsSync(resolvedPath)) {
@@ -12,11 +22,15 @@ function applyBaselineComparison(report, baselinePath) {
12
22
 
13
23
  const baseline = JSON.parse(fs.readFileSync(resolvedPath, 'utf8'));
14
24
  const baselineFindings = Array.isArray(baseline.findings) ? baseline.findings : [];
15
- const baselineKeys = new Set(baselineFindings.map((finding) => finding.key || createFindingKey(finding)));
25
+ const legacy = baseline.formatVersion !== BASELINE_FORMAT_VERSION;
16
26
  const currentFindings = getAllFindings(report);
17
- const currentKeys = new Set(currentFindings.map(createFindingKey));
18
- const newFindings = currentFindings.filter((finding) => !baselineKeys.has(createFindingKey(finding)));
19
- const resolvedFindings = baselineFindings.filter((finding) => !currentKeys.has(finding.key || createFindingKey(finding)));
27
+ const currentKeys = legacy
28
+ ? new Map(currentFindings.map((finding) => [finding, createLegacyFindingKey(finding)]))
29
+ : assignFindingKeys(currentFindings);
30
+ const baselineKeys = new Set(baselineFindings.map((finding) => finding.key || createLegacyFindingKey(finding)));
31
+ const currentKeySet = new Set(currentKeys.values());
32
+ const newFindings = currentFindings.filter((finding) => !baselineKeys.has(currentKeys.get(finding)));
33
+ const resolvedFindings = baselineFindings.filter((finding) => !currentKeySet.has(finding.key || createLegacyFindingKey(finding)));
20
34
 
21
35
  report.baseline = {
22
36
  baselineFindings: baselineFindings.length,
@@ -31,14 +45,16 @@ function applyBaselineComparison(report, baselinePath) {
31
45
 
32
46
  function writeBaseline(report, baselinePath) {
33
47
  const resolvedPath = path.resolve(process.cwd(), baselinePath);
48
+ const findings = getAllFindings(report);
49
+ const keys = assignFindingKeys(findings);
34
50
  fs.mkdirSync(path.dirname(resolvedPath), { recursive: true });
35
51
  fs.writeFileSync(
36
52
  resolvedPath,
37
53
  `${JSON.stringify({
38
54
  createdAt: new Date().toISOString(),
39
55
  directory: report.directory,
40
- findings: getAllFindings(report).map(toBaselineFinding),
41
- formatVersion: 1,
56
+ findings: findings.map((finding) => toBaselineFinding(finding, keys.get(finding))),
57
+ formatVersion: BASELINE_FORMAT_VERSION,
42
58
  summary: {
43
59
  scaleCleanliness: report.scaleCleanliness,
44
60
  totalFindings: report.totalWarnings,
@@ -60,12 +76,13 @@ function getAllFindings(report) {
60
76
  ];
61
77
  }
62
78
 
63
- function toBaselineFinding(finding) {
79
+ function toBaselineFinding(finding, key) {
64
80
  return {
65
81
  column: finding.column,
66
82
  file: finding.file,
67
- key: createFindingKey(finding),
83
+ key,
68
84
  line: finding.line,
85
+ property: finding.property || undefined,
69
86
  rule: finding.rule,
70
87
  text: finding.text,
71
88
  token: finding.token,
@@ -74,7 +91,33 @@ function toBaselineFinding(finding) {
74
91
  };
75
92
  }
76
93
 
77
- function createFindingKey(finding) {
94
+ function findingIdentity(finding) {
95
+ return [
96
+ finding.rule || '',
97
+ finding.type || '',
98
+ finding.file || '',
99
+ finding.property || finding.token || '',
100
+ finding.value || finding.rawValue || '',
101
+ ].join('\u001f');
102
+ }
103
+
104
+ /** Content keys for a set of findings: identity plus the occurrence index in source order. */
105
+ function assignFindingKeys(findings) {
106
+ const ordered = [...findings].sort((a, b) =>
107
+ String(a.file).localeCompare(String(b.file)) || (a.line || 0) - (b.line || 0) || (a.column || 0) - (b.column || 0));
108
+ const seen = new Map();
109
+ const keys = new Map();
110
+ for (const finding of ordered) {
111
+ const identity = findingIdentity(finding);
112
+ const occurrence = seen.get(identity) || 0;
113
+ seen.set(identity, occurrence + 1);
114
+ keys.set(finding, `${identity}\u001f${occurrence}`);
115
+ }
116
+ return keys;
117
+ }
118
+
119
+ /** The version 1 key: position-based, kept so existing baseline files keep working. */
120
+ function createLegacyFindingKey(finding) {
78
121
  return [
79
122
  finding.rule || '',
80
123
  finding.type || '',
@@ -105,10 +148,11 @@ function getAuditFailures(report, parsed) {
105
148
  }
106
149
 
107
150
  module.exports = {
151
+ BASELINE_FORMAT_VERSION,
108
152
  applyBaselineComparison,
109
- createFindingKey,
153
+ assignFindingKeys,
154
+ createLegacyFindingKey,
110
155
  getAllFindings,
111
156
  getAuditFailures,
112
- toBaselineFinding,
113
157
  writeBaseline,
114
158
  };
@@ -0,0 +1,129 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const postcss = require('postcss');
6
+ const valueParser = require('postcss-value-parser');
7
+ const { PROPERTY_GROUP_PATTERNS } = require('../core/css-vocabulary');
8
+ const { numbersEqual, parseLengthToken, toPx } = require('../core/length');
9
+ const { negateReplacement } = require('../core/token-index');
10
+ const { isTokenFunction, propertyMatches, walkRootValueNodes } = require('../core/value-nodes');
11
+ const { formatPath } = require('./shared');
12
+ const { isCssFile, isScssFile, walkFiles } = require('./scan/files');
13
+ const { resolveScssSyntax } = require('./scan/stylesheets');
14
+
15
+ /**
16
+ * The targeted codemod: one value, one replacement, reviewable as one change.
17
+ * It walks declaration values the way the rules do, so `10px` inside `110px`,
18
+ * inside `var(--x, 10px)`, on a non-spacing property or in a token definition
19
+ * is never touched. Matching is by px, so `0.625rem` is the same value as
20
+ * `10px`. The sign is kept: a negative literal becomes a negated replacement.
21
+ */
22
+ const TOKEN_FUNCTIONS = ['var', 'theme', 'token'];
23
+ const ANY_TOKEN = /./;
24
+
25
+ function replaceLength(root, { baseFontSize = 16, properties = null, px, replacement }) {
26
+ const patterns = properties && properties.length > 0
27
+ ? properties.map((property) => property.toLowerCase())
28
+ : PROPERTY_GROUP_PATTERNS.spacing;
29
+ const changes = [];
30
+
31
+ root.walkDecls((decl) => {
32
+ const prop = decl.prop.toLowerCase();
33
+ if (prop.startsWith('--') || prop.startsWith('$') || !propertyMatches(prop, patterns)) {
34
+ return;
35
+ }
36
+ const parsed = valueParser(decl.value);
37
+ let changed = false;
38
+ walkRootValueNodes(parsed, (node) => {
39
+ if (node.type === 'function') {
40
+ // true skips the children: nothing inside var()/theme()/token() is ours to rewrite
41
+ return isTokenFunction(node, TOKEN_FUNCTIONS, ANY_TOKEN);
42
+ }
43
+ if (node.type !== 'word') {
44
+ return false;
45
+ }
46
+ const length = parseLengthToken(node.value);
47
+ if (!length || length.number === 0 || length.unit === '%' || length.unit === '') {
48
+ return false;
49
+ }
50
+ const nodePx = toPx(Math.abs(length.number), length.unit, baseFontSize);
51
+ if (nodePx === null || !numbersEqual(nodePx, px)) {
52
+ return false;
53
+ }
54
+ const to = length.number < 0 ? negateReplacement(replacement) : replacement;
55
+ changes.push({
56
+ column: (decl.source && decl.source.start ? decl.source.start.column : 1) + declarationValueOffset(decl) + node.sourceIndex,
57
+ from: node.value,
58
+ line: decl.source && decl.source.start ? decl.source.start.line : 1,
59
+ property: decl.prop,
60
+ to,
61
+ });
62
+ node.value = to;
63
+ changed = true;
64
+ return false;
65
+ });
66
+ if (changed) {
67
+ decl.value = parsed.toString();
68
+ }
69
+ });
70
+
71
+ return changes;
72
+ }
73
+
74
+ function declarationValueOffset(decl) {
75
+ const raws = decl.raws || {};
76
+ const between = typeof raws.between === 'string' ? raws.between : ': ';
77
+ return decl.prop.length + between.length;
78
+ }
79
+
80
+ function parseStylesheet(filePath, source) {
81
+ if (isScssFile(filePath)) {
82
+ const syntaxPath = resolveScssSyntax();
83
+ if (!syntaxPath) {
84
+ return null;
85
+ }
86
+ return postcss.parse(source, { from: filePath, syntax: require(syntaxPath) });
87
+ }
88
+ return postcss.parse(source, { from: filePath });
89
+ }
90
+
91
+ /**
92
+ * Apply one replacement across a directory. Dry run unless `write`; returns
93
+ * the per-file changes and which files were skipped (unparseable, or SCSS
94
+ * without postcss-scss).
95
+ */
96
+ function runCodemod({ baseFontSize = 16, dir, ignorePatterns = [], properties = null, px, replacement, write = false }) {
97
+ const rootDir = path.resolve(process.cwd(), dir);
98
+ const files = walkFiles(rootDir, ignorePatterns).cssFiles.filter(isCssFile).sort();
99
+ const results = [];
100
+ const skipped = [];
101
+ for (const filePath of files) {
102
+ const source = fs.readFileSync(filePath, 'utf8');
103
+ let root;
104
+ try {
105
+ root = parseStylesheet(filePath, source);
106
+ } catch {
107
+ skipped.push({ file: formatPath(filePath), reason: 'could not parse' });
108
+ continue;
109
+ }
110
+ if (!root) {
111
+ skipped.push({ file: formatPath(filePath), reason: 'install postcss-scss to rewrite SCSS' });
112
+ continue;
113
+ }
114
+ const changes = replaceLength(root, { baseFontSize, properties, px, replacement });
115
+ if (changes.length === 0) {
116
+ continue;
117
+ }
118
+ if (write) {
119
+ fs.writeFileSync(filePath, root.toString());
120
+ }
121
+ results.push({ changes, file: formatPath(filePath) });
122
+ }
123
+ return { results, skipped };
124
+ }
125
+
126
+ module.exports = {
127
+ replaceLength,
128
+ runCodemod,
129
+ };
@@ -2,6 +2,7 @@
2
2
 
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
+ const { normalizeDecisions } = require('../core/decisions');
5
6
  const {
6
7
  normalizeTokenKind,
7
8
  normalizeTokenSourceFormat,
@@ -58,6 +59,7 @@ function loadAuditConfig(parsed) {
58
59
 
59
60
  return {
60
61
  audit,
62
+ decisions: config.decisions,
61
63
  file: formatPath(resolvedPath),
62
64
  rootDir: path.dirname(resolvedPath),
63
65
  };
@@ -76,6 +78,7 @@ function applyAuditConfig(parsed, configResult) {
76
78
 
77
79
  if (!configResult) {
78
80
  next.tokenSources = normalizeCliTokenSources(parsed.tokenSources, parsed.tokenSourceFormat);
81
+ next.decisions = [];
79
82
  delete next.cliOptions;
80
83
  return next;
81
84
  }
@@ -113,6 +116,8 @@ function applyAuditConfig(parsed, configResult) {
113
116
  return parseScale(String(value));
114
117
  });
115
118
 
119
+ next.decisions = normalizeDecisions(configResult.decisions, { baseFontSize: next.baseFontSize });
120
+
116
121
  delete next.cliOptions;
117
122
  return next;
118
123
  }
@@ -365,6 +365,7 @@ function toAuditContractReport(report) {
365
365
  scanScope: report.scanScope.mode,
366
366
  },
367
367
  contracts: {
368
+ decisions: report.decisions || null,
368
369
  motion: report.motion,
369
370
  scale: {
370
371
  cleanliness: report.scaleCleanliness,
@@ -0,0 +1,105 @@
1
+ 'use strict';
2
+
3
+ const { decisionFor } = require('../core/decisions');
4
+ const { formatLength, nearestScaleValues, parseLengthToken, toPx } = require('../core/length');
5
+
6
+ /**
7
+ * Decisions applied to audit findings, and the plan that proposes them.
8
+ * Adopted and allowed values are removed from the findings; snap and undecided
9
+ * ones stay. The plan lists every off-scale value seen, keeping decisions
10
+ * already written and adding `undecided` entries for the rest.
11
+ */
12
+ function findingPx(finding, baseFontSize) {
13
+ const raw = finding.value || finding.rawValue;
14
+ const parsed = typeof raw === 'string' ? parseLengthToken(raw.trim()) : null;
15
+ return parsed ? toPx(Math.abs(parsed.number), parsed.unit || 'px', baseFontSize) : null;
16
+ }
17
+
18
+ function findingProperty(finding) {
19
+ return finding.property || (finding.token ? 'class-string' : null);
20
+ }
21
+
22
+ function applyDecisions({ baseFontSize, cssFindings, decisions, tailwindFindings }) {
23
+ if (!decisions || decisions.length === 0) {
24
+ return { cssFindings, suppressed: 0, summary: null, tailwindFindings };
25
+ }
26
+ let suppressed = 0;
27
+ const keep = (finding) => {
28
+ if (finding.type !== 'off-scale' && !finding.token) {
29
+ return true;
30
+ }
31
+ const px = findingPx(finding, baseFontSize);
32
+ const decision = px === null ? null : decisionFor(px, findingProperty(finding), decisions);
33
+ if (decision && (decision.decision === 'adopt' || decision.decision === 'allow')) {
34
+ suppressed += 1;
35
+ return false;
36
+ }
37
+ return true;
38
+ };
39
+ const nextCss = cssFindings.filter(keep);
40
+ const nextTailwind = tailwindFindings.filter(keep);
41
+ const summary = { adopt: 0, allow: 0, snap: 0, undecided: 0 };
42
+ for (const decision of decisions) summary[decision.decision] += 1;
43
+ return { cssFindings: nextCss, suppressed, summary: { ...summary, suppressed }, tailwindFindings: nextTailwind };
44
+ }
45
+
46
+ /**
47
+ * One proposed entry per distinct off-scale px value: the most common spelling,
48
+ * how often it occurs, the properties it sits on, the two nearest scale steps,
49
+ * and the decision so far (`undecided` for anything nobody has looked at).
50
+ */
51
+ function buildDecisionPlan({ baseFontSize, decisions, offScaleFindings, scale }) {
52
+ const groups = new Map();
53
+ for (const finding of offScaleFindings) {
54
+ const px = findingPx(finding, baseFontSize);
55
+ if (px === null) continue;
56
+ const key = String(px);
57
+ const group = groups.get(key) || { count: 0, properties: new Map(), px, spellings: new Map() };
58
+ group.count += 1;
59
+ const spelling = (finding.value || finding.rawValue).trim();
60
+ group.spellings.set(spelling, (group.spellings.get(spelling) || 0) + 1);
61
+ const property = findingProperty(finding);
62
+ if (property) group.properties.set(property, (group.properties.get(property) || 0) + 1);
63
+ groups.set(key, group);
64
+ }
65
+
66
+ const mostCommon = (map) => [...map.entries()].sort((a, b) => b[1] - a[1]).map(([name]) => name);
67
+ const scalePx = Array.isArray(scale) ? scale.map(Number).filter(Number.isFinite) : [];
68
+ const nearestFor = (px) => {
69
+ const nearest = nearestScaleValues(px, scalePx);
70
+ return nearest ? [formatLength(nearest.lower, 'px'), formatLength(nearest.upper, 'px')] : [];
71
+ };
72
+
73
+ const entries = [];
74
+ const decided = new Set();
75
+ for (const decision of decisions || []) {
76
+ const group = groups.get(String(decision.px));
77
+ decided.add(String(decision.px));
78
+ entries.push({
79
+ value: decision.value,
80
+ decision: decision.decision,
81
+ ...(decision.as ? { as: decision.as } : {}),
82
+ ...(decision.reason ? { reason: decision.reason } : {}),
83
+ ...(decision.properties ? { properties: decision.properties } : {}),
84
+ count: group ? group.count : 0,
85
+ nearest: nearestFor(decision.px),
86
+ });
87
+ }
88
+ for (const [key, group] of groups) {
89
+ if (decided.has(key)) continue;
90
+ entries.push({
91
+ value: mostCommon(group.spellings)[0],
92
+ decision: 'undecided',
93
+ count: group.count,
94
+ properties: mostCommon(group.properties).slice(0, 3),
95
+ nearest: nearestFor(group.px),
96
+ });
97
+ }
98
+ entries.sort((a, b) => b.count - a.count || String(a.value).localeCompare(String(b.value)));
99
+ return entries;
100
+ }
101
+
102
+ module.exports = {
103
+ applyDecisions,
104
+ buildDecisionPlan,
105
+ };
@@ -4,9 +4,12 @@ const { getAllFindings } = require('./baseline');
4
4
 
5
5
  function renderGithub(report) {
6
6
  const findings = getAllFindings(report);
7
- const lines = findings.map((finding) =>
7
+ const lines = report.baseline
8
+ ? [`::notice title=Rhythmguard audit::Since baseline: ${report.baseline.resolvedFindingsCount} resolved, ${report.baseline.newFindingsCount} new`]
9
+ : [];
10
+ lines.push(...findings.map((finding) =>
8
11
  `::warning file=${escapeGithubProperty(finding.file)},line=${finding.line || 1},col=${finding.column || 1},title=${escapeGithubProperty(finding.rule || 'rhythmguard')}::${escapeGithubData(finding.text || '')}`,
9
- );
12
+ ));
10
13
  const findingWord = findings.length === 1 ? 'finding' : 'findings';
11
14
  const fileWord = report.filesWithIssues === 1 ? 'file' : 'files';
12
15
  lines.push(