stylelint-plugin-rhythmguard 3.4.0 → 3.6.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,35 @@ The format follows Keep a Changelog principles and semantic versioning.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [3.6.0] - 2026-09-09
10
+
11
+ ### Added
12
+
13
+ - **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))
14
+ - `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).
15
+ - `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.
16
+ - **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`.
17
+
18
+ ### Changed
19
+
20
+ - 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`.
21
+
22
+ ### Fixed
23
+
24
+ - 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))`.
25
+ - `prefer-token` autofix writes a negative token as `calc(-1 * var(--token))`. It wrote `-var(--token)`, which is not valid CSS.
26
+
27
+ ## [3.5.0] - 2026-09-08
28
+
29
+ ### Fixed
30
+
31
+ - `audit.tokenSources` entries in `.rhythmguardrc.json` and typed sources passed to the API keep their `tokenPattern`. Both normalisers dropped it, so a per-source pattern was silently ignored by the audit while the same key worked for the package allowlist.
32
+
33
+ ### Added
34
+
35
+ - Token-package discovery reads five more packages, each named by its maintainers in the audit issues: `bootstrap` (`$spacer`, `$spacers`; Bootstrap-derived systems such as AdminLTE inherit it), `@patternfly/patternfly`, `@mittwald/flow-design-tokens` (`--size-px--*` and `--size-rem--*`, one ladder in two units), `govuk-frontend` and `nhsuk-frontend`. A contract test checks every allowlist entry.
36
+ - The State of Spacing edition marks rows whose scale lives in a dependency package the benchmark's sparse checkout cannot see, and rows whose maintainers said there is no spacing scale by design; neither is counted.
37
+
9
38
  ## [3.4.0] - 2026-09-06
10
39
 
11
40
  ### 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
 
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=9" 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=360)](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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stylelint-plugin-rhythmguard",
3
- "version": "3.4.0",
3
+ "version": "3.6.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
  }
@@ -141,6 +146,7 @@ function normalizeCliTokenSources(sources, format) {
141
146
  baseDir: source.baseDir || process.cwd(),
142
147
  format: source.format || format,
143
148
  path: source.path || source.file,
149
+ ...(typeof source.tokenPattern === 'string' && source.tokenPattern ? { tokenPattern: source.tokenPattern } : {}),
144
150
  };
145
151
  }
146
152
  return {
@@ -177,10 +183,15 @@ function normalizeConfigTokenSources(sources, baseDir) {
177
183
  throw new Error('Invalid Rhythmguard config: token source objects must include a path.');
178
184
  }
179
185
 
186
+ if (source.tokenPattern !== undefined && (typeof source.tokenPattern !== 'string' || source.tokenPattern.trim().length === 0)) {
187
+ throw new Error('Invalid Rhythmguard config: token source tokenPattern must be a non-empty string.');
188
+ }
189
+
180
190
  return {
181
191
  baseDir,
182
192
  format: normalizeTokenSourceFormat(source.format || 'auto'),
183
193
  path: source.path,
194
+ ...(source.tokenPattern ? { tokenPattern: source.tokenPattern } : {}),
184
195
  };
185
196
  });
186
197
  }
@@ -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(