stylelint-plugin-rhythmguard 3.5.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 +18 -0
- package/CONTRIBUTING.md +7 -4
- package/README.md +8 -10
- package/SECURITY.md +3 -2
- package/package.json +2 -1
- package/src/audit/args.js +1 -0
- package/src/audit/baseline.js +55 -11
- package/src/audit/codemod.js +129 -0
- package/src/audit/config.js +5 -0
- package/src/audit/contract.js +1 -0
- package/src/audit/decisions.js +105 -0
- package/src/audit/render-github.js +5 -2
- package/src/audit/render-markdown.js +31 -0
- package/src/audit/render-text.js +11 -0
- package/src/audit/report.js +30 -2
- package/src/audit/scan/stylesheets.js +1 -0
- package/src/audit/shared.js +2 -0
- package/src/audit/token-chains.js +157 -0
- package/src/cli/audit.js +5 -0
- package/src/cli/fix.js +151 -0
- package/src/cli/index.js +4 -0
- package/src/core/decisions.js +121 -0
- package/src/core/fs-cache.js +36 -0
- package/src/core/options.js +12 -0
- package/src/core/scale-inference.js +34 -37
- package/src/core/token-index.js +72 -0
- package/src/core/token-sources.js +3 -2
- package/src/rules/no-offscale-transform/index.js +24 -4
- package/src/rules/prefer-token/index.js +3 -19
- package/src/rules/report.js +6 -0
- package/src/rules/use-scale/index.js +33 -5
- package/types/audit.d.ts +31 -0
- package/types/shared.d.ts +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,24 @@ 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
|
+
|
|
9
27
|
## [3.5.0] - 2026-09-08
|
|
10
28
|
|
|
11
29
|
### 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
|
|
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
|
|
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
|
-
-
|
|
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=
|
|
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
|
[](https://github.com/petrilahdelma/stylelint-plugin-rhythmguard/actions/workflows/ci.yml)
|
|
10
|
-
[](https://www.npmjs.com/package/stylelint-plugin-rhythmguard)
|
|
10
|
+
[](https://www.npmjs.com/package/stylelint-plugin-rhythmguard)
|
|
11
11
|
[](https://www.npmjs.com/package/stylelint-plugin-rhythmguard)
|
|
12
12
|
[](./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.
|
|
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,
|
|
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.
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stylelint-plugin-rhythmguard",
|
|
3
|
-
"version": "3.
|
|
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)',
|
package/src/audit/baseline.js
CHANGED
|
@@ -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
|
|
25
|
+
const legacy = baseline.formatVersion !== BASELINE_FORMAT_VERSION;
|
|
16
26
|
const currentFindings = getAllFindings(report);
|
|
17
|
-
const currentKeys =
|
|
18
|
-
|
|
19
|
-
|
|
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:
|
|
41
|
-
formatVersion:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
};
|
package/src/audit/config.js
CHANGED
|
@@ -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
|
}
|
package/src/audit/contract.js
CHANGED
|
@@ -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 =
|
|
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(
|
|
@@ -16,6 +16,7 @@ function renderMarkdown(report) {
|
|
|
16
16
|
const lines = [
|
|
17
17
|
'# Rhythmguard Design-System Audit',
|
|
18
18
|
'',
|
|
19
|
+
...(report.baseline ? [`**Since baseline:** ${report.baseline.resolvedFindingsCount} resolved, ${report.baseline.newFindingsCount} new.`, ''] : []),
|
|
19
20
|
`Directory: \`${report.directory}\``,
|
|
20
21
|
'',
|
|
21
22
|
'## Summary',
|
|
@@ -36,6 +37,10 @@ function renderMarkdown(report) {
|
|
|
36
37
|
lines.push(`| Scale source | ${describeScaleSource(report.scale)} |`);
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
if (report.decisions) {
|
|
41
|
+
lines.push(`| Decisions | ${report.decisions.adopt + report.decisions.allow + report.decisions.snap + report.decisions.undecided} (${report.decisions.adopt} adopt, ${report.decisions.allow} allow, ${report.decisions.snap} snap, ${report.decisions.undecided} undecided); ${report.decisions.suppressed} findings suppressed |`);
|
|
42
|
+
}
|
|
43
|
+
|
|
39
44
|
if (report.baseline) {
|
|
40
45
|
lines.push(`| New findings | ${report.baseline.newFindingsCount} |`);
|
|
41
46
|
lines.push(`| Resolved findings | ${report.baseline.resolvedFindingsCount} |`);
|
|
@@ -48,6 +53,7 @@ function renderMarkdown(report) {
|
|
|
48
53
|
appendMarkdownCounts(lines, 'Tailwind Class-String Drift', report.tailwindArbitraryValues);
|
|
49
54
|
appendMarkdownCounts(lines, 'Motion Rhythm Drift', report.motion.values);
|
|
50
55
|
appendTokenContractMarkdown(lines, report.tokenContract);
|
|
56
|
+
appendTokenChainsMarkdown(lines, report.tokenContract.chains);
|
|
51
57
|
appendBaselineMarkdown(lines, report);
|
|
52
58
|
|
|
53
59
|
if (report.topAffectedFiles.length > 0) {
|
|
@@ -86,6 +92,31 @@ function renderMarkdown(report) {
|
|
|
86
92
|
return `${lines.join('\n')}\n`;
|
|
87
93
|
}
|
|
88
94
|
|
|
95
|
+
function appendTokenChainsMarkdown(lines, chains) {
|
|
96
|
+
if (!chains || chains.summary.total === 0) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const { summary } = chains;
|
|
100
|
+
lines.push('## Token Chains');
|
|
101
|
+
lines.push('');
|
|
102
|
+
const attention = ['off-scale', 'unresolved', 'ambiguous', 'computed', 'non-length']
|
|
103
|
+
.filter((outcome) => summary[outcome] > 0)
|
|
104
|
+
.map((outcome) => `${summary[outcome]} ${outcome}`);
|
|
105
|
+
lines.push(`${summary['on-scale']} of ${summary.total} spacing tokens resolve to the scale${attention.length > 0 ? `; ${attention.join(', ')}` : ''}. A token is followed through \`var()\` to its terminal value; the token layer, not the literal, is where a system like this keeps its discipline.`);
|
|
106
|
+
lines.push('');
|
|
107
|
+
if (chains.entries.length > 0) {
|
|
108
|
+
lines.push('| Token | Outcome | Detail |');
|
|
109
|
+
lines.push('| --- | --- | --- |');
|
|
110
|
+
for (const entry of chains.entries.slice(0, 50)) {
|
|
111
|
+
const detail = entry.reason
|
|
112
|
+
? entry.reason
|
|
113
|
+
: entry.terminals.map((terminal) => `\`${terminal}\``).join(', ');
|
|
114
|
+
lines.push(`| \`${escapeMarkdown(entry.token)}\` | ${entry.outcome} | ${detail}${entry.via.length > 0 ? ` via ${entry.via.map((name) => `\`${name}\``).join(' → ')}` : ''} |`);
|
|
115
|
+
}
|
|
116
|
+
lines.push('');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
89
120
|
function appendTokenContractMarkdown(lines, tokenContract) {
|
|
90
121
|
const {
|
|
91
122
|
conflictingTokens,
|