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 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=4" 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=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.0.0",
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 scanned CSS
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) {
@@ -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,
@@ -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('| Value | Count |');
217
+ lines.push(`| ${label} | Count |`);
217
218
  lines.push('| --- | ---: |');
218
219
  for (const [value, count] of entries) {
219
220
  lines.push(`| \`${escapeMarkdown(value)}\` | ${count} |`);
@@ -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,
@@ -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?: string;
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;