stylelint-plugin-rhythmguard 3.0.0 → 3.1.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 +11 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/audit/args.js +7 -1
- package/src/audit/contract.js +5 -0
- package/src/audit/render-html.js +1 -0
- package/src/audit/render-markdown.js +4 -3
- package/src/audit/render-text.js +4 -3
- package/src/audit/scan.js +73 -0
- package/src/cli/quickstart.js +4 -0
- package/types/audit.d.ts +7 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,17 @@ The format follows Keep a Changelog principles and semantic versioning.
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [3.1.0] - 2026-09-06
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Audit findings for CSS declarations carry the declaration's `property`, recovered from the source at the warning position (`null` inside at-rules). Off-scale findings are counted by property in `offScaleProperties`, exposed as `contracts.scale.offScaleProperties`, rendered as a histogram in text, Markdown and HTML output and as a `Top properties` line in the quickstart. Baseline keys and benchmark snapshots are unchanged. ([#64](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/64))
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- `rhythmguard audit --help` describes what `--scale auto` reads and in which order, SCSS scanning through `postcss-scss`, and the value, property and file breakdowns.
|
|
18
|
+
- CI and release workflows no longer run the full suite on the Stylelint 16.0.0 floor as an allowed-to-fail step; that step produced an error annotation on every green run. The blocking floor suite is unchanged. Actions `checkout` and `setup-node` moved to v5.
|
|
19
|
+
|
|
9
20
|
## [3.0.0] - 2026-09-06
|
|
10
21
|
|
|
11
22
|
Upgrade notes: [`docs/MIGRATING_TO_3.md`](./docs/MIGRATING_TO_3.md).
|
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=5" width="100%" alt="Rhythmguard banner showing spacing scale ruler and lint output" />
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
# stylelint-plugin-rhythmguard
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stylelint-plugin-rhythmguard",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.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"
|
package/src/audit/args.js
CHANGED
|
@@ -37,8 +37,14 @@ Options:
|
|
|
37
37
|
--token-kind <kind> Token kind: spacing, radius, typography, size, motion, all (default: spacing)
|
|
38
38
|
--token-candidate-min-count <n> Minimum repeated raw value count for token candidates (default: 2)
|
|
39
39
|
--scale <values|auto> Comma-separated scale values (default: 0,4,8,12,16,24,32);
|
|
40
|
-
auto infers the scale from token sources, then
|
|
40
|
+
auto infers the scale from token sources, then --space-*/--spacing-*
|
|
41
|
+
custom properties and Sass variables in scanned CSS/SCSS, then
|
|
42
|
+
installed design-token packages, and reports where it came from
|
|
41
43
|
--base-font-size <number> px base for rem/em conversion (default: 16)
|
|
44
|
+
|
|
45
|
+
Scans .css files, and .scss files when postcss-scss is installed. Reports drift by
|
|
46
|
+
value, by property and by file; text output prints histograms, markdown is PR-ready,
|
|
47
|
+
json is the stable 2.0 contract.
|
|
42
48
|
`;
|
|
43
49
|
|
|
44
50
|
function parseArgs(argv) {
|
package/src/audit/contract.js
CHANGED
|
@@ -268,6 +268,9 @@ function buildReport({
|
|
|
268
268
|
const offScaleValues = countByValue(cssFindings
|
|
269
269
|
.filter((finding) => finding.type === 'off-scale' && finding.value)
|
|
270
270
|
.map((finding) => finding.value));
|
|
271
|
+
const offScaleProperties = countByValue(cssFindings
|
|
272
|
+
.filter((finding) => finding.type === 'off-scale' && finding.property)
|
|
273
|
+
.map((finding) => finding.property));
|
|
271
274
|
const tokenOpportunities = countByValue(cssFindings
|
|
272
275
|
.filter((finding) => finding.type === 'token-opportunity' && finding.value)
|
|
273
276
|
.map((finding) => finding.value));
|
|
@@ -319,6 +322,7 @@ function buildReport({
|
|
|
319
322
|
findings: motionFindings.length,
|
|
320
323
|
values: Object.fromEntries(sortCountMap(motionValues).slice(0, 10)),
|
|
321
324
|
},
|
|
325
|
+
offScaleProperties: Object.fromEntries(sortCountMap(offScaleProperties).slice(0, 10)),
|
|
322
326
|
offScaleValues: Object.fromEntries(sortCountMap(offScaleValues).slice(0, 10)),
|
|
323
327
|
scale: scale || null,
|
|
324
328
|
scaleCleanliness,
|
|
@@ -388,6 +392,7 @@ function toAuditContractReport(report) {
|
|
|
388
392
|
scale: {
|
|
389
393
|
cleanliness: report.scaleCleanliness,
|
|
390
394
|
files: report.scale ? report.scale.files : [],
|
|
395
|
+
offScaleProperties: report.offScaleProperties || {},
|
|
391
396
|
offScaleValues: report.offScaleValues,
|
|
392
397
|
source: report.scale ? report.scale.source : 'default',
|
|
393
398
|
tokenOpportunities: report.tokenOpportunities,
|
package/src/audit/render-html.js
CHANGED
|
@@ -34,6 +34,7 @@ function renderHtml(report) {
|
|
|
34
34
|
metricHtml('CSS files', report.cssFilesScanned),
|
|
35
35
|
metricHtml('Template files', report.templateFilesScanned),
|
|
36
36
|
'</section>',
|
|
37
|
+
renderHtmlTable('CSS Off-Scale Properties', ['Property', 'Count'], Object.entries(report.offScaleProperties || {})),
|
|
37
38
|
renderHtmlTable('Top Affected Files', ['File', 'Findings'], report.topAffectedFiles.map(({ file, count }) => [file, count])),
|
|
38
39
|
renderHtmlTable('Token Contract Sources', ['File', 'Format', 'Tokens'], report.tokenContract.sources.map((source) => [
|
|
39
40
|
source.file,
|
|
@@ -40,6 +40,7 @@ function renderMarkdown(report) {
|
|
|
40
40
|
lines.push('');
|
|
41
41
|
|
|
42
42
|
appendMarkdownCounts(lines, 'CSS Off-Scale Values', report.offScaleValues);
|
|
43
|
+
appendMarkdownCounts(lines, 'CSS Off-Scale Properties', report.offScaleProperties, 'Property');
|
|
43
44
|
appendMarkdownCounts(lines, 'CSS Token Opportunities', report.tokenOpportunities);
|
|
44
45
|
appendMarkdownCounts(lines, 'Tailwind Class-String Drift', report.tailwindArbitraryValues);
|
|
45
46
|
appendMarkdownCounts(lines, 'Motion Rhythm Drift', report.motion.values);
|
|
@@ -204,8 +205,8 @@ function appendBaselineMarkdown(lines, report) {
|
|
|
204
205
|
}
|
|
205
206
|
}
|
|
206
207
|
|
|
207
|
-
function appendMarkdownCounts(lines, title, counts) {
|
|
208
|
-
const entries = sortCountMap(counts);
|
|
208
|
+
function appendMarkdownCounts(lines, title, counts, label = 'Value') {
|
|
209
|
+
const entries = sortCountMap(counts || {});
|
|
209
210
|
|
|
210
211
|
if (entries.length === 0) {
|
|
211
212
|
return;
|
|
@@ -213,7 +214,7 @@ function appendMarkdownCounts(lines, title, counts) {
|
|
|
213
214
|
|
|
214
215
|
lines.push(`## ${title}`);
|
|
215
216
|
lines.push('');
|
|
216
|
-
lines.push(
|
|
217
|
+
lines.push(`| ${label} | Count |`);
|
|
217
218
|
lines.push('| --- | ---: |');
|
|
218
219
|
for (const [value, count] of entries) {
|
|
219
220
|
lines.push(`| \`${escapeMarkdown(value)}\` | ${count} |`);
|
package/src/audit/render-text.js
CHANGED
|
@@ -33,6 +33,7 @@ function renderText(report) {
|
|
|
33
33
|
lines.push('');
|
|
34
34
|
|
|
35
35
|
appendHistogram(lines, 'CSS OFF-SCALE VALUES', report.offScaleValues);
|
|
36
|
+
appendHistogram(lines, 'CSS OFF-SCALE PROPERTIES', report.offScaleProperties);
|
|
36
37
|
appendHistogram(lines, 'CSS TOKEN OPPORTUNITIES', report.tokenOpportunities);
|
|
37
38
|
appendHistogram(lines, 'TAILWIND CLASS-STRING DRIFT', report.tailwindArbitraryValues);
|
|
38
39
|
appendHistogram(lines, 'MOTION RHYTHM DRIFT', report.motion.values);
|
|
@@ -145,9 +146,9 @@ function appendBaselineText(lines, report) {
|
|
|
145
146
|
}
|
|
146
147
|
}
|
|
147
148
|
|
|
148
|
-
function appendHistogram(lines, title, counts) {
|
|
149
|
-
const entries = sortCountMap(counts);
|
|
150
|
-
const total = sumCounts(counts);
|
|
149
|
+
function appendHistogram(lines, title, counts = {}) {
|
|
150
|
+
const entries = sortCountMap(counts || {});
|
|
151
|
+
const total = sumCounts(counts || {});
|
|
151
152
|
|
|
152
153
|
if (entries.length === 0) {
|
|
153
154
|
return;
|
package/src/audit/scan.js
CHANGED
|
@@ -342,10 +342,12 @@ async function runStylelintAudit(cssFiles, options) {
|
|
|
342
342
|
|
|
343
343
|
function collectCssFindings(fileResults) {
|
|
344
344
|
const findings = [];
|
|
345
|
+
const sources = new Map();
|
|
345
346
|
|
|
346
347
|
for (const fileResult of fileResults) {
|
|
347
348
|
for (const warning of fileResult.warnings || []) {
|
|
348
349
|
const text = warning.text || '';
|
|
350
|
+
const source = readSourceOnce(sources, fileResult.source);
|
|
349
351
|
const offScaleMatch = text.match(
|
|
350
352
|
/Unexpected (?:off-scale value|transform translation value) "([^"]+)"/,
|
|
351
353
|
);
|
|
@@ -363,6 +365,9 @@ function collectCssFindings(fileResults) {
|
|
|
363
365
|
column: warning.column || 1,
|
|
364
366
|
file: formatPath(fileResult.source),
|
|
365
367
|
line: warning.line || 1,
|
|
368
|
+
property: source === null
|
|
369
|
+
? null
|
|
370
|
+
: findDeclarationProperty(source, warning.line || 1, warning.column || 1),
|
|
366
371
|
rule: warning.rule || 'rhythmguard',
|
|
367
372
|
text,
|
|
368
373
|
type: getCssFindingType({ motionDurationMatch, motionEasingMatch, tokenMatch }),
|
|
@@ -379,6 +384,73 @@ function collectCssFindings(fileResults) {
|
|
|
379
384
|
return findings;
|
|
380
385
|
}
|
|
381
386
|
|
|
387
|
+
function readSourceOnce(cache, filePath) {
|
|
388
|
+
if (!filePath) {
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
if (!cache.has(filePath)) {
|
|
392
|
+
try {
|
|
393
|
+
cache.set(filePath, fs.readFileSync(filePath, 'utf8'));
|
|
394
|
+
} catch {
|
|
395
|
+
cache.set(filePath, null);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return cache.get(filePath);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const DECLARATION_BOUNDARY = new Set([';', '{', '}']);
|
|
402
|
+
const DECLARATION_HEAD_PATTERN = /^\s*(?:(?:\/\*[\s\S]*?\*\/|\/\/[^\n]*)\s*)*(--[\w-]+|[a-zA-Z][\w-]*)\s*:/;
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Stylelint warnings carry a position but not the declaration node. Recover the
|
|
406
|
+
* property by walking from the warning position back to the previous declaration
|
|
407
|
+
* boundary and reading the `property:` head, skipping block and Sass line comments.
|
|
408
|
+
* Sass interpolation is blanked first so `#{...}` braces do not act as boundaries. Returns null when the position is not
|
|
409
|
+
* inside a declaration, for example inside an at-rule.
|
|
410
|
+
*/
|
|
411
|
+
function findDeclarationProperty(source, line, column) {
|
|
412
|
+
const offset = positionToOffset(source, line, column);
|
|
413
|
+
if (offset === null) {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const text = source.replace(/#\{[^}]*\}/g, (match) => ' '.repeat(match.length));
|
|
418
|
+
let start = offset;
|
|
419
|
+
while (start > 0 && !DECLARATION_BOUNDARY.has(text[start - 1])) {
|
|
420
|
+
start -= 1;
|
|
421
|
+
}
|
|
422
|
+
const backward = text.slice(start, offset).match(DECLARATION_HEAD_PATTERN);
|
|
423
|
+
if (backward) {
|
|
424
|
+
return backward[1];
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
let end = offset;
|
|
428
|
+
while (end < text.length && !DECLARATION_BOUNDARY.has(text[end])) {
|
|
429
|
+
end += 1;
|
|
430
|
+
}
|
|
431
|
+
const forward = text.slice(offset, end).match(DECLARATION_HEAD_PATTERN);
|
|
432
|
+
return forward ? forward[1] : null;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function positionToOffset(source, line, column) {
|
|
436
|
+
if (!Number.isInteger(line) || line < 1) {
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
let offset = 0;
|
|
440
|
+
let currentLine = 1;
|
|
441
|
+
while (currentLine < line) {
|
|
442
|
+
const newline = source.indexOf('\n', offset);
|
|
443
|
+
if (newline === -1) {
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
offset = newline + 1;
|
|
447
|
+
currentLine += 1;
|
|
448
|
+
}
|
|
449
|
+
const lineEnd = source.indexOf('\n', offset);
|
|
450
|
+
const lineLength = (lineEnd === -1 ? source.length : lineEnd) - offset;
|
|
451
|
+
return offset + Math.min(Math.max((column || 1) - 1, 0), lineLength);
|
|
452
|
+
}
|
|
453
|
+
|
|
382
454
|
function getCssFindingValue({
|
|
383
455
|
motionDurationMatch,
|
|
384
456
|
motionEasingMatch,
|
|
@@ -581,6 +653,7 @@ module.exports = {
|
|
|
581
653
|
collectTailwindMotionFindings,
|
|
582
654
|
createIgnoreMatchers,
|
|
583
655
|
escapeRegExp,
|
|
656
|
+
findDeclarationProperty,
|
|
584
657
|
findStringLiterals,
|
|
585
658
|
getCssFindingType,
|
|
586
659
|
getCssFindingValue,
|
package/src/cli/quickstart.js
CHANGED
|
@@ -177,6 +177,10 @@ async function run() {
|
|
|
177
177
|
if (topValues.length > 0) {
|
|
178
178
|
out.push(` Top values ${topValues.map(([value, count]) => `${value} ×${count}`).join(', ')}`);
|
|
179
179
|
}
|
|
180
|
+
const topProperties = topEntries(report.offScaleProperties);
|
|
181
|
+
if (topProperties.length > 0) {
|
|
182
|
+
out.push(` Top properties ${topProperties.map(([property, count]) => `${property} ×${count}`).join(', ')}`);
|
|
183
|
+
}
|
|
180
184
|
const topFiles = (report.topAffectedFiles || []).slice(0, 3);
|
|
181
185
|
if (topFiles.length > 0) {
|
|
182
186
|
out.push(` Top files ${topFiles.map((entry) => `${entry.file} (${entry.count})`).join(', ')}`);
|
package/types/audit.d.ts
CHANGED
|
@@ -77,7 +77,8 @@ export interface AuditFinding {
|
|
|
77
77
|
key?: string;
|
|
78
78
|
line?: number;
|
|
79
79
|
message: string;
|
|
80
|
-
property
|
|
80
|
+
/** CSS property of the declaration, recovered from the source. Null when the position is not inside a declaration. CSS findings only. */
|
|
81
|
+
property?: string | null;
|
|
81
82
|
rule?: string;
|
|
82
83
|
type: string;
|
|
83
84
|
value?: string;
|
|
@@ -113,6 +114,10 @@ export interface AuditReport {
|
|
|
113
114
|
motion: AuditFinding[];
|
|
114
115
|
tailwind: AuditFinding[];
|
|
115
116
|
};
|
|
117
|
+
/** Off-scale CSS findings counted by property, top ten. */
|
|
118
|
+
offScaleProperties?: Record<string, number>;
|
|
119
|
+
/** Off-scale CSS findings counted by value, top ten. */
|
|
120
|
+
offScaleValues?: Record<string, number>;
|
|
116
121
|
scanned: AuditScanned;
|
|
117
122
|
summary: AuditSummary;
|
|
118
123
|
[key: string]: unknown;
|
|
@@ -130,6 +135,7 @@ export interface AuditContractReport {
|
|
|
130
135
|
scale: {
|
|
131
136
|
cleanliness?: unknown;
|
|
132
137
|
files: string[];
|
|
138
|
+
offScaleProperties?: Record<string, number>;
|
|
133
139
|
offScaleValues?: unknown;
|
|
134
140
|
source: AuditScaleSource;
|
|
135
141
|
tokenOpportunities?: unknown;
|