stylelint-plugin-rhythmguard 3.11.0 → 3.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +5 -1
  3. package/package.json +23 -1
  4. package/src/audit/args.js +23 -4
  5. package/src/audit/baseline.js +198 -28
  6. package/src/audit/codemod.js +76 -11
  7. package/src/audit/config.js +10 -30
  8. package/src/audit/contract.js +99 -10
  9. package/src/audit/decisions.js +45 -16
  10. package/src/audit/render-github.js +4 -0
  11. package/src/audit/render-html.js +7 -0
  12. package/src/audit/render-markdown.js +48 -20
  13. package/src/audit/render-text.js +24 -1
  14. package/src/audit/render-utils.js +28 -1
  15. package/src/audit/report.js +72 -13
  16. package/src/audit/scan/files.js +265 -44
  17. package/src/audit/scan/stylesheets.js +67 -34
  18. package/src/audit/scan/templates.js +22 -7
  19. package/src/audit/token-chains.js +22 -8
  20. package/src/audit/token-ladders.js +23 -2
  21. package/src/cli/doctor.js +76 -10
  22. package/src/cli/fix.js +20 -22
  23. package/src/cli/index.js +2 -2
  24. package/src/cli/init.js +176 -27
  25. package/src/cli/quickstart.js +79 -15
  26. package/src/configs/embed.js +2 -0
  27. package/src/configs/motion.js +1 -0
  28. package/src/configs/recommended.js +1 -0
  29. package/src/configs/strict.js +1 -0
  30. package/src/configs/tailwind.js +1 -0
  31. package/src/core/decisions.js +2 -1
  32. package/src/core/fs-cache.js +6 -0
  33. package/src/core/json.js +25 -0
  34. package/src/core/length.js +11 -0
  35. package/src/core/options.js +46 -21
  36. package/src/core/scale-inference.js +171 -85
  37. package/src/core/tailwind-class-analysis.js +25 -14
  38. package/src/core/tailwind-motion-analysis.js +12 -3
  39. package/src/core/token-index.js +5 -2
  40. package/src/core/token-map.js +2 -1
  41. package/src/core/token-source-entries.js +64 -0
  42. package/src/core/token-sources.js +363 -70
  43. package/src/core/value-nodes.js +280 -54
  44. package/src/eslint/index.js +14 -8
  45. package/src/eslint/literal-spans.js +94 -9
  46. package/src/eslint/rules/tailwind-class-use-motion-scale.js +1 -0
  47. package/src/eslint/rules/tailwind-class-use-scale.js +40 -11
  48. package/src/rules/no-offscale-transform/index.js +69 -44
  49. package/src/rules/prefer-token/index.js +61 -55
  50. package/src/rules/use-motion-scale/index.js +82 -6
  51. package/src/rules/use-scale/index.js +99 -112
  52. package/src/rules/validate.js +13 -4
  53. package/types/__checks__/consumer.ts +20 -1
  54. package/types/__checks__/eslint-config.ts +45 -0
  55. package/types/__checks__/shipped-configs.ts +96 -0
  56. package/types/audit.d.ts +108 -15
  57. package/types/index.d.ts +15 -0
  58. package/types/shared.d.ts +147 -23
@@ -9,7 +9,7 @@ const { numbersEqual, parseLengthToken, toPx } = require('../core/length');
9
9
  const { negateReplacement } = require('../core/token-index');
10
10
  const { isTokenFunction, propertyMatches, walkRootValueNodes } = require('../core/value-nodes');
11
11
  const { formatPath } = require('./shared');
12
- const { isCssFile, isScssFile, walkFiles } = require('./scan/files');
12
+ const { isCssFile, isPathInside, isScssFile, isTemplateFile, walkFiles } = require('./scan/files');
13
13
  const { resolveScssSyntax } = require('./scan/stylesheets');
14
14
 
15
15
  /**
@@ -22,10 +22,21 @@ const { resolveScssSyntax } = require('./scan/stylesheets');
22
22
  const TOKEN_FUNCTIONS = ['var', 'theme', 'token'];
23
23
  const ANY_TOKEN = /./;
24
24
 
25
- function replaceLength(root, { baseFontSize = 16, properties = null, px, replacement }) {
25
+ /**
26
+ * Several replacements (`rhythmguard fix --decided`) run in one pass over the
27
+ * original values: each literal is matched against the list once, and a value
28
+ * one replacement writes is never matched again, so decisions 10px -> 14px and
29
+ * 14px -> 16px leave padding: 10px at 14px. The first matching entry wins.
30
+ */
31
+ function normalizeReplacements({ px, replacement, replacements }) {
32
+ return Array.isArray(replacements) ? replacements : [{ px, replacement }];
33
+ }
34
+
35
+ function replaceLength(root, { baseFontSize = 16, properties = null, px, replacement, replacements }) {
26
36
  const patterns = properties && properties.length > 0
27
37
  ? properties.map((property) => property.toLowerCase())
28
38
  : PROPERTY_GROUP_PATTERNS.spacing;
39
+ const planned = normalizeReplacements({ px, replacement, replacements });
29
40
  const changes = [];
30
41
 
31
42
  root.walkDecls((decl) => {
@@ -48,14 +59,16 @@ function replaceLength(root, { baseFontSize = 16, properties = null, px, replace
48
59
  return false;
49
60
  }
50
61
  const nodePx = toPx(Math.abs(length.number), length.unit, baseFontSize);
51
- if (nodePx === null || !numbersEqual(nodePx, px)) {
62
+ const match = nodePx === null ? null : planned.find((entry) => numbersEqual(nodePx, entry.px));
63
+ if (!match) {
52
64
  return false;
53
65
  }
54
- const to = length.number < 0 ? negateReplacement(replacement) : replacement;
66
+ const to = length.number < 0 ? negateReplacement(match.replacement) : match.replacement;
67
+ const position = positionInDeclaration(decl, declarationValueOffset(decl) + node.sourceIndex);
55
68
  changes.push({
56
- column: (decl.source && decl.source.start ? decl.source.start.column : 1) + declarationValueOffset(decl) + node.sourceIndex,
69
+ column: position.column,
57
70
  from: node.value,
58
- line: decl.source && decl.source.start ? decl.source.start.line : 1,
71
+ line: position.line,
59
72
  property: decl.prop,
60
73
  to,
61
74
  });
@@ -77,6 +90,22 @@ function declarationValueOffset(decl) {
77
90
  return decl.prop.length + between.length;
78
91
  }
79
92
 
93
+ /**
94
+ * The line and column of `index`, an offset into the declaration as written
95
+ * (property, separator, value), counted from where the declaration starts. A
96
+ * value that spans lines puts later literals on later lines (#288).
97
+ */
98
+ function positionInDeclaration(decl, index) {
99
+ const start = decl.source && decl.source.start ? decl.source.start : { column: 1, line: 1 };
100
+ const raws = decl.raws || {};
101
+ const text = `${decl.prop}${typeof raws.between === 'string' ? raws.between : ': '}${decl.value}`.slice(0, index);
102
+ const lastBreak = text.lastIndexOf('\n');
103
+ const breaks = text.split('\n').length - 1;
104
+ return breaks === 0
105
+ ? { column: start.column + index, line: start.line }
106
+ : { column: index - lastBreak, line: start.line + breaks };
107
+ }
108
+
80
109
  /**
81
110
  * Parse with the syntax that will write the file back. SCSS goes through
82
111
  * postcss-scss's own parse and stringify: `postcss.parse` ignores a `syntax`
@@ -97,18 +126,36 @@ function parseStylesheet(filePath, source) {
97
126
  }
98
127
 
99
128
  /**
100
- * Apply one replacement across a directory. Dry run unless `write`; returns
129
+ * Apply one replacement (`px` and `replacement`), or several in one pass
130
+ * (`replacements: [{ px, replacement }]`), across a directory. Dry run unless
131
+ * `write`; the dry run reports exactly what a write would write. Returns
101
132
  * the per-file changes and which files were skipped (unparseable, or SCSS
102
133
  * without postcss-scss). `ignorePatterns` are root-relative paths and globs,
103
134
  * as the audit takes them; the audit's generated and vendored directories
104
135
  * (node_modules, dist, build and the rest of SKIP_DIRS) are always skipped.
105
136
  */
106
- function runCodemod({ baseFontSize = 16, dir, ignorePatterns = [], properties = null, px, replacement, write = false }) {
137
+ function runCodemod({ baseFontSize = 16, dir, ignorePatterns = [], properties = null, px, replacement, replacements, write = false }) {
107
138
  const rootDir = path.resolve(process.cwd(), dir);
108
- const files = walkFiles(rootDir, ignorePatterns).cssFiles.filter(isCssFile).sort();
139
+ const walked = walkFiles(rootDir, ignorePatterns);
140
+ const files = walked.cssFiles.filter(isCssFile).sort();
109
141
  const results = [];
110
- const skipped = [];
142
+ const skipped = walked.linksOutsideRoot
143
+ .filter((link) => !isTemplateFile(link))
144
+ .map((link) => ({ file: formatPath(path.join(rootDir, link)), reason: 'symbolic link to a path outside the directory; not followed' }));
145
+ const realRoot = fs.realpathSync(rootDir);
146
+ const rewritten = new Set();
111
147
  for (const filePath of files) {
148
+ // Never write through a symbolic link: a file is rewritten only at its own
149
+ // path inside the directory, and only once (#289).
150
+ const realPath = writableRealPath(filePath, rootDir, realRoot);
151
+ if (!realPath) {
152
+ skipped.push({ file: formatPath(filePath), reason: 'reached through a symbolic link; rewrite the file it points to instead' });
153
+ continue;
154
+ }
155
+ if (rewritten.has(realPath)) {
156
+ continue;
157
+ }
158
+ rewritten.add(realPath);
112
159
  const source = fs.readFileSync(filePath, 'utf8');
113
160
  let parsed;
114
161
  try {
@@ -122,7 +169,7 @@ function runCodemod({ baseFontSize = 16, dir, ignorePatterns = [], properties =
122
169
  continue;
123
170
  }
124
171
  const { root, syntax } = parsed;
125
- const changes = replaceLength(root, { baseFontSize, properties, px, replacement });
172
+ const changes = replaceLength(root, { baseFontSize, properties, px, replacement, replacements });
126
173
  if (changes.length === 0) {
127
174
  continue;
128
175
  }
@@ -134,6 +181,24 @@ function runCodemod({ baseFontSize = 16, dir, ignorePatterns = [], properties =
134
181
  return { results, skipped };
135
182
  }
136
183
 
184
+ /**
185
+ * The real path of `filePath` when it is the file's own path: no symbolic link
186
+ * between `rootDir` and the file, and the file inside `rootDir`. Null otherwise.
187
+ */
188
+ function writableRealPath(filePath, rootDir, realRoot) {
189
+ let realPath;
190
+ try {
191
+ if (fs.lstatSync(filePath).isSymbolicLink()) {
192
+ return null;
193
+ }
194
+ realPath = fs.realpathSync(filePath);
195
+ } catch {
196
+ return null;
197
+ }
198
+ const expected = path.join(realRoot, path.relative(rootDir, filePath));
199
+ return realPath === expected && isPathInside(realRoot, realPath) ? realPath : null;
200
+ }
201
+
137
202
  module.exports = {
138
203
  replaceLength,
139
204
  runCodemod,
@@ -3,11 +3,10 @@
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
  const { normalizeDecisions } = require('../core/decisions');
6
+ const { readJsonFile } = require('../core/json');
6
7
  const { SCALED_GROUPS, isGroupScalesMap } = require('../core/options');
7
- const {
8
- normalizeTokenKind,
9
- normalizeTokenSourceFormat,
10
- } = require('../core/token-sources');
8
+ const { normalizeTokenKind } = require('../core/token-sources');
9
+ const { normalizeTokenSourceEntry } = require('../core/token-source-entries');
11
10
  const {
12
11
  parseBaseFontSize,
13
12
  parseNonNegativeInteger,
@@ -45,7 +44,7 @@ function loadAuditConfig(parsed) {
45
44
 
46
45
  let config;
47
46
  try {
48
- config = JSON.parse(fs.readFileSync(resolvedPath, 'utf8'));
47
+ config = readJsonFile(resolvedPath);
49
48
  } catch (err) {
50
49
  throw new Error(`Invalid Rhythmguard config ${parsed.configPath}: ${err.message}`, { cause: err });
51
50
  }
@@ -190,33 +189,14 @@ function normalizeConfigTokenSources(sources, baseDir) {
190
189
  throw new Error('Invalid Rhythmguard config: audit.tokenSources must be an array.');
191
190
  }
192
191
 
192
+ // The lint reads the same entries through the same normaliser, so both see the same files.
193
193
  return sources.map((source) => {
194
- if (typeof source === 'string') {
195
- return {
196
- baseDir,
197
- format: 'auto',
198
- path: source,
199
- };
200
- }
201
-
202
- if (!source || typeof source !== 'object' || Array.isArray(source)) {
203
- throw new Error('Invalid Rhythmguard config: audit.tokenSources entries must be strings or objects.');
204
- }
205
-
206
- if (typeof source.path !== 'string' || source.path.trim().length === 0) {
207
- throw new Error('Invalid Rhythmguard config: token source objects must include a path.');
208
- }
209
-
210
- if (source.tokenPattern !== undefined && (typeof source.tokenPattern !== 'string' || source.tokenPattern.trim().length === 0)) {
211
- throw new Error('Invalid Rhythmguard config: token source tokenPattern must be a non-empty string.');
194
+ const { error, source: normalized } = normalizeTokenSourceEntry(source, baseDir);
195
+ if (error) {
196
+ // A bad format already reads "Invalid token source format ..."; structural problems name the config.
197
+ throw new Error(/^Invalid /.test(error) ? error : `Invalid Rhythmguard config: ${error}`);
212
198
  }
213
-
214
- return {
215
- baseDir,
216
- format: normalizeTokenSourceFormat(source.format || 'auto'),
217
- path: source.path,
218
- ...(source.tokenPattern ? { tokenPattern: source.tokenPattern } : {}),
219
- };
199
+ return normalized;
220
200
  });
221
201
  }
222
202
 
@@ -6,6 +6,7 @@ const {
6
6
  collectCssTokens,
7
7
  createTokenKindMatcher,
8
8
  getNormalizedValueKeys,
9
+ hasLineComments,
9
10
  } = require('../core/token-sources');
10
11
  const { formatPath } = require('./shared');
11
12
 
@@ -15,6 +16,7 @@ function collectTokenContract({
15
16
  cssFindings,
16
17
  externalTokenDefinitions,
17
18
  minCandidateCount,
19
+ readSource = (filePath) => fs.readFileSync(filePath, 'utf8'),
18
20
  tailwindFindings,
19
21
  tokenKind,
20
22
  tokenSourceReports,
@@ -28,7 +30,7 @@ function collectTokenContract({
28
30
  for (const filePath of cssFiles) {
29
31
  let source;
30
32
  try {
31
- source = fs.readFileSync(filePath, 'utf8');
33
+ source = readSource(filePath);
32
34
  } catch {
33
35
  continue;
34
36
  }
@@ -97,7 +99,7 @@ function collectTokenContract({
97
99
 
98
100
  /** Custom properties and Sass tokens declared in one stylesheet, added to the definitions map. */
99
101
  function collectTokenDefinitions(source, file, definitions, matchesKind, baseFontSize) {
100
- for (const { scope, token, value } of collectCssTokens(source, matchesKind)) {
102
+ for (const { scope, token, value } of collectCssTokens(source, matchesKind, { lineComments: hasLineComments(file) })) {
101
103
  addDefinition(definitions, { baseFontSize, file, scope, source: file, token, value });
102
104
  }
103
105
  }
@@ -230,7 +232,10 @@ function buildReport({
230
232
  dir,
231
233
  externalTokenDefinitions,
232
234
  includeMotion,
235
+ linksOutsideRoot = [],
233
236
  motionFindings,
237
+ parseErrorFindings = [],
238
+ readSource,
234
239
  scale,
235
240
  scanScope,
236
241
  scssFiles = 0,
@@ -267,18 +272,24 @@ function buildReport({
267
272
  ...tailwindFindings.map((finding) => finding.file),
268
273
  ])).slice(0, 10);
269
274
 
270
- const totalFiles = cssFiles.length + templateFiles.length;
275
+ // A stylesheet skipped for want of postcss-scss, or excluded by the project's
276
+ // .stylelintignore, was not audited: it is neither scanned nor clean.
277
+ const cssFilesScanned = Math.max(0, cssFiles.length - scssSkipped - stylelintIgnored);
278
+ const totalFiles = cssFilesScanned + templateFiles.length;
271
279
  const totalWarnings = cssFindings.length + motionFindings.length + tailwindFindings.length;
272
280
  const filesWithIssues = issueFiles.size;
273
- const scaleCleanliness = totalFiles > 0
274
- ? Math.max(0, Math.round(((totalFiles - filesWithIssues) / totalFiles) * 100))
275
- : 100;
281
+ const scaleCleanliness = Math.floor(computeScaleCleanliness({
282
+ filesWithIssues,
283
+ parseErrorFiles: countFiles(parseErrorFindings),
284
+ totalFiles,
285
+ }));
276
286
  const tokenContract = collectTokenContract({
277
287
  baseFontSize,
278
288
  cssFiles,
279
289
  cssFindings,
280
290
  externalTokenDefinitions,
281
291
  minCandidateCount: tokenCandidateMinCount,
292
+ readSource,
282
293
  tailwindFindings,
283
294
  tokenKind,
284
295
  tokenSourceReports,
@@ -286,12 +297,13 @@ function buildReport({
286
297
 
287
298
  return {
288
299
  config,
289
- cssFilesScanned: cssFiles.length,
300
+ cssFilesScanned,
290
301
  directory: dir,
291
302
  filesWithIssues,
292
303
  findings: {
293
304
  css: cssFindings,
294
305
  motion: motionFindings,
306
+ parseErrors: parseErrorFindings,
295
307
  tailwind: tailwindFindings,
296
308
  },
297
309
  formatVersion: 5,
@@ -306,17 +318,20 @@ function buildReport({
306
318
  scaleCleanliness,
307
319
  scanScope,
308
320
  scanned: {
309
- cssFiles: cssFiles.length,
321
+ cssFiles: cssFilesScanned,
310
322
  scssFiles,
311
323
  scssSkipped,
312
324
  stylelintIgnored,
313
325
  templateFiles: templateFiles.length,
314
326
  totalFiles,
327
+ // Symbolic links that resolve outside the scanned directory: not followed (#289).
328
+ ...(linksOutsideRoot.length > 0 ? { linksOutsideRoot: linksOutsideRoot.length } : {}),
315
329
  },
316
330
  summary: {
317
331
  cssWarnings: cssFindings.length,
318
332
  filesWithIssues,
319
333
  motionFindings: motionFindings.length,
334
+ parseErrors: parseErrorFindings.length,
320
335
  rawValueMatches: tokenContract.summary.rawValueMatches,
321
336
  missingTokens: tokenContract.summary.missingTokens,
322
337
  rawValueCandidates: tokenContract.summary.rawValueCandidates,
@@ -338,6 +353,36 @@ function buildReport({
338
353
  };
339
354
  }
340
355
 
356
+ /**
357
+ * The share of audited files with no finding, unrounded. A file that failed to
358
+ * parse was not audited, so it is out of the denominator. The report shows the
359
+ * floor of this, so it never reads 100% while a file has a finding, and the
360
+ * --min-cleanliness gate compares this exact value.
361
+ */
362
+ function computeScaleCleanliness({ filesWithIssues, parseErrorFiles = 0, totalFiles }) {
363
+ const auditedFiles = totalFiles - parseErrorFiles;
364
+ if (auditedFiles <= 0) {
365
+ return 100;
366
+ }
367
+ return Math.max(0, ((auditedFiles - filesWithIssues) * 100) / auditedFiles);
368
+ }
369
+
370
+ /** The unrounded cleanliness of a built report. */
371
+ function exactScaleCleanliness(report) {
372
+ if (!Number.isFinite(report.totalFiles) || !Number.isFinite(report.filesWithIssues)) {
373
+ return report.scaleCleanliness;
374
+ }
375
+ return computeScaleCleanliness({
376
+ filesWithIssues: report.filesWithIssues,
377
+ parseErrorFiles: countFiles((report.findings && report.findings.parseErrors) || []),
378
+ totalFiles: report.totalFiles,
379
+ });
380
+ }
381
+
382
+ function countFiles(findings) {
383
+ return new Set(findings.map((finding) => finding.file)).size;
384
+ }
385
+
341
386
  function countByValue(values) {
342
387
  const counts = {};
343
388
 
@@ -395,10 +440,52 @@ const AUDIT_JSON_SCHEMA = Object.freeze({
395
440
  baseline: { type: ['object', 'null'] },
396
441
  command: { type: 'object' },
397
442
  contracts: { type: 'object' },
398
- findings: { type: 'object' },
443
+ findings: {
444
+ additionalProperties: true,
445
+ properties: {
446
+ css: { type: 'array' },
447
+ motion: { type: 'array' },
448
+ parseErrors: {
449
+ description: 'Stylesheets that could not be parsed and so were not audited. Not counted as findings or in cleanliness.',
450
+ items: {
451
+ additionalProperties: true,
452
+ properties: {
453
+ column: { type: 'integer' },
454
+ file: { type: 'string' },
455
+ line: { type: 'integer' },
456
+ rule: { const: 'CssSyntaxError' },
457
+ text: { type: 'string' },
458
+ type: { const: 'parse-error' },
459
+ },
460
+ required: ['file', 'text', 'type'],
461
+ type: 'object',
462
+ },
463
+ type: 'array',
464
+ },
465
+ tailwind: { type: 'array' },
466
+ },
467
+ type: 'object',
468
+ },
399
469
  scanned: { type: 'object' },
400
470
  schemaVersion: { const: '2.0' },
401
- summary: { type: 'object' },
471
+ summary: {
472
+ additionalProperties: true,
473
+ properties: {
474
+ parseErrors: {
475
+ description: 'Number of stylesheets that could not be parsed.',
476
+ minimum: 0,
477
+ type: 'integer',
478
+ },
479
+ scaleCleanliness: {
480
+ description: 'Percentage of audited files with no finding, rounded down.',
481
+ maximum: 100,
482
+ minimum: 0,
483
+ type: 'integer',
484
+ },
485
+ totalFindings: { minimum: 0, type: 'integer' },
486
+ },
487
+ type: 'object',
488
+ },
402
489
  },
403
490
  required: ['schemaVersion', 'command', 'summary', 'scanned', 'contracts', 'findings'],
404
491
  title: 'Rhythmguard Audit Report',
@@ -414,8 +501,10 @@ module.exports = {
414
501
  collectTokenContract,
415
502
  collectTokenDefinitions,
416
503
  collectTokenUses,
504
+ computeScaleCleanliness,
417
505
  countByValue,
418
506
  createValueToTokensMap,
507
+ exactScaleCleanliness,
419
508
  mapTokenEntries,
420
509
  sortCountMap,
421
510
  sumCounts,
@@ -1,7 +1,8 @@
1
1
  'use strict';
2
2
 
3
+ const { propertyKindOf } = require('../core/css-vocabulary');
3
4
  const { decisionFor } = require('../core/decisions');
4
- const { formatLength, nearestScaleValues, parseLengthToken, toPx } = require('../core/length');
5
+ const { formatLength, nearestScaleValues, normalizeScale, parseLengthToken, toPx } = require('../core/length');
5
6
 
6
7
  /**
7
8
  * Decisions applied to audit findings, and the plan that proposes them.
@@ -43,18 +44,43 @@ function applyDecisions({ baseFontSize, cssFindings, decisions, tailwindFindings
43
44
  return { cssFindings: nextCss, suppressed, summary: { ...summary, suppressed }, tailwindFindings: nextTailwind };
44
45
  }
45
46
 
47
+ /**
48
+ * Which scale a finding was checked against, as use-scale picks it: the
49
+ * `audit.scales` entry for the property's group, else the project scale. The
50
+ * spacing group's entry is already the project scale (report.js). `key` names
51
+ * the group when it has a scale of its own; `scalePx` is null for a group set
52
+ * to "auto", whose scale the rule infers and the audit cannot see (#283).
53
+ */
54
+ function createFindingScaleResolver({ baseFontSize, groupScales, scale }) {
55
+ const projectScale = { key: '', scalePx: Array.isArray(scale) ? normalizeScale(scale, baseFontSize) : [] };
56
+ const byGroup = new Map();
57
+ for (const [group, entry] of Object.entries(groupScales || {})) {
58
+ if (group === 'spacing' || !entry) continue;
59
+ byGroup.set(group, { key: group, scalePx: Array.isArray(entry.scale) ? normalizeScale(entry.scale, baseFontSize) : null });
60
+ }
61
+ return (finding) => {
62
+ if (finding.token || !finding.property) return projectScale;
63
+ return byGroup.get(propertyKindOf(finding.property)) || projectScale;
64
+ };
65
+ }
66
+
46
67
  /**
47
68
  * 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).
69
+ * how often it occurs, the properties it sits on, the two nearest steps of the
70
+ * scale it was checked against, and the decision so far (`undecided` for
71
+ * anything nobody has looked at). A value found in groups with different
72
+ * scales (`15px` as a font size and as a padding) gets one entry per scale.
50
73
  */
51
- function buildDecisionPlan({ baseFontSize, decisions, offScaleFindings, scale }) {
74
+ function buildDecisionPlan({ baseFontSize, decisions, groupScales = {}, offScaleFindings, scale }) {
75
+ const scaleOf = createFindingScaleResolver({ baseFontSize, groupScales, scale });
76
+ const projectScale = scaleOf({});
52
77
  const groups = new Map();
53
78
  for (const finding of offScaleFindings) {
54
79
  const px = findingPx(finding, baseFontSize);
55
80
  if (px === null) continue;
56
- const key = String(px);
57
- const group = groups.get(key) || { count: 0, properties: new Map(), px, spellings: new Map() };
81
+ const checkedAgainst = scaleOf(finding);
82
+ const key = `${px}|${checkedAgainst.key}`;
83
+ const group = groups.get(key) || { count: 0, properties: new Map(), px, scale: checkedAgainst, spellings: new Map() };
58
84
  group.count += 1;
59
85
  const spelling = (finding.value || finding.rawValue).trim();
60
86
  group.spellings.set(spelling, (group.spellings.get(spelling) || 0) + 1);
@@ -64,35 +90,38 @@ function buildDecisionPlan({ baseFontSize, decisions, offScaleFindings, scale })
64
90
  }
65
91
 
66
92
  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);
93
+ const nearestFor = (px, scalePx) => {
94
+ const nearest = scalePx ? nearestScaleValues(px, scalePx) : null;
70
95
  return nearest ? [formatLength(nearest.lower, 'px'), formatLength(nearest.upper, 'px')] : [];
71
96
  };
97
+ const groupsForPx = (px) => [...groups.values()].filter((group) => group.px === px);
72
98
 
73
99
  const entries = [];
74
100
  const decided = new Set();
75
101
  for (const decision of decisions || []) {
76
- const group = groups.get(String(decision.px));
77
- decided.add(String(decision.px));
102
+ const found = groupsForPx(decision.px);
103
+ decided.add(decision.px);
104
+ // A decision covers the value on every property it names; its nearest steps
105
+ // come from the one scale its findings were checked against, if just one.
106
+ const scales = [...new Set(found.map((group) => group.scale))];
78
107
  entries.push({
79
108
  value: decision.value,
80
109
  decision: decision.decision,
81
110
  ...(decision.as ? { as: decision.as } : {}),
82
111
  ...(decision.reason ? { reason: decision.reason } : {}),
83
112
  ...(decision.properties ? { properties: decision.properties } : {}),
84
- count: group ? group.count : 0,
85
- nearest: nearestFor(decision.px),
113
+ count: found.reduce((sum, group) => sum + group.count, 0),
114
+ nearest: nearestFor(decision.px, (scales.length === 1 ? scales[0] : projectScale).scalePx),
86
115
  });
87
116
  }
88
- for (const [key, group] of groups) {
89
- if (decided.has(key)) continue;
117
+ for (const group of groups.values()) {
118
+ if (decided.has(group.px)) continue;
90
119
  entries.push({
91
120
  value: mostCommon(group.spellings)[0],
92
121
  decision: 'undecided',
93
122
  count: group.count,
94
123
  properties: mostCommon(group.properties).slice(0, 3),
95
- nearest: nearestFor(group.px),
124
+ nearest: nearestFor(group.px, group.scale.scalePx),
96
125
  });
97
126
  }
98
127
  entries.sort((a, b) => b.count - a.count || String(a.value).localeCompare(String(b.value)));
@@ -7,6 +7,10 @@ function renderGithub(report) {
7
7
  const lines = report.baseline
8
8
  ? [`::notice title=Rhythmguard audit::Since baseline: ${report.baseline.resolvedFindingsCount} resolved, ${report.baseline.newFindingsCount} new`]
9
9
  : [];
10
+ // A stylesheet that failed to parse was not audited: an error on its line.
11
+ lines.push(...((report.findings && report.findings.parseErrors) || []).map((finding) =>
12
+ `::error file=${escapeGithubProperty(finding.file)},line=${finding.line || 1},col=${finding.column || 1},title=${escapeGithubProperty(finding.rule || 'CssSyntaxError')}::${escapeGithubData(`Not audited: ${finding.text || ''}`)}`,
13
+ ));
10
14
  lines.push(...findings.map((finding) =>
11
15
  `::warning file=${escapeGithubProperty(finding.file)},line=${finding.line || 1},col=${finding.column || 1},title=${escapeGithubProperty(finding.rule || 'rhythmguard')}::${escapeGithubData(finding.text || '')}`,
12
16
  ));
@@ -33,7 +33,9 @@ function renderHtml(report) {
33
33
  metricHtml('Scale cleanliness', `${report.scaleCleanliness}%`),
34
34
  metricHtml('CSS files', report.cssFilesScanned),
35
35
  metricHtml('Template files', report.templateFilesScanned),
36
+ ...(parseErrorRows(report).length > 0 ? [metricHtml('Parse errors (not audited)', parseErrorRows(report).length)] : []),
36
37
  '</section>',
38
+ renderHtmlTable('Parse Errors (not audited)', ['File', 'Error'], parseErrorRows(report)),
37
39
  renderHtmlTable('CSS Off-Scale Properties', ['Property', 'Count'], Object.entries(report.offScaleProperties || {})),
38
40
  renderHtmlTable('Top Affected Files', ['File', 'Findings'], report.topAffectedFiles.map(({ file, count }) => [file, count])),
39
41
  renderHtmlTable('Token Contract Sources', ['File', 'Format', 'Tokens'], report.tokenContract.sources.map((source) => [
@@ -52,6 +54,11 @@ function renderHtml(report) {
52
54
  return `${lines.join('\n')}\n`;
53
55
  }
54
56
 
57
+ function parseErrorRows(report) {
58
+ return ((report.findings && report.findings.parseErrors) || [])
59
+ .map((finding) => [`${finding.file}:${finding.line}:${finding.column}`, finding.text]);
60
+ }
61
+
55
62
  function metricHtml(label, value) {
56
63
  return `<div class="metric"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
57
64
  }