stylelint-plugin-rhythmguard 3.10.2 → 3.12.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 (44) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +5 -1
  3. package/package.json +1 -1
  4. package/src/audit/args.js +22 -2
  5. package/src/audit/baseline.js +184 -27
  6. package/src/audit/codemod.js +39 -14
  7. package/src/audit/config.js +25 -31
  8. package/src/audit/contract.js +96 -10
  9. package/src/audit/render-github.js +4 -0
  10. package/src/audit/render-html.js +7 -0
  11. package/src/audit/render-markdown.js +27 -0
  12. package/src/audit/render-text.js +23 -1
  13. package/src/audit/report.js +60 -17
  14. package/src/audit/scan/files.js +154 -27
  15. package/src/audit/scan/stylesheets.js +87 -34
  16. package/src/audit/scan/templates.js +22 -7
  17. package/src/audit/token-chains.js +18 -7
  18. package/src/cli/audit.js +7 -3
  19. package/src/cli/doctor.js +48 -7
  20. package/src/cli/fix.js +56 -15
  21. package/src/cli/init.js +136 -23
  22. package/src/cli/quickstart.js +34 -8
  23. package/src/core/fs-cache.js +6 -0
  24. package/src/core/length.js +15 -0
  25. package/src/core/options.js +8 -1
  26. package/src/core/scale-inference.js +99 -68
  27. package/src/core/tailwind-class-analysis.js +26 -16
  28. package/src/core/tailwind-motion-analysis.js +12 -3
  29. package/src/core/token-index.js +76 -24
  30. package/src/core/token-map.js +23 -10
  31. package/src/core/token-source-entries.js +64 -0
  32. package/src/core/token-sources.js +361 -69
  33. package/src/core/value-nodes.js +280 -54
  34. package/src/eslint/literal-spans.js +319 -0
  35. package/src/eslint/rules/tailwind-class-use-motion-scale.js +9 -32
  36. package/src/eslint/rules/tailwind-class-use-scale.js +45 -40
  37. package/src/rules/no-offscale-transform/index.js +35 -14
  38. package/src/rules/prefer-token/index.js +72 -48
  39. package/src/rules/report.js +3 -1
  40. package/src/rules/use-motion-scale/index.js +68 -5
  41. package/src/rules/use-scale/index.js +54 -39
  42. package/types/__checks__/eslint-config.ts +37 -0
  43. package/types/audit.d.ts +10 -2
  44. package/types/shared.d.ts +21 -3
@@ -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,10 +59,11 @@ 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;
55
67
  changes.push({
56
68
  column: (decl.source && decl.source.start ? decl.source.start.column : 1) + declarationValueOffset(decl) + node.sourceIndex,
57
69
  from: node.value,
@@ -77,46 +89,59 @@ function declarationValueOffset(decl) {
77
89
  return decl.prop.length + between.length;
78
90
  }
79
91
 
92
+ /**
93
+ * Parse with the syntax that will write the file back. SCSS goes through
94
+ * postcss-scss's own parse and stringify: `postcss.parse` ignores a `syntax`
95
+ * option, so it would read `// a: 10px` as a declaration and rewrite it.
96
+ * Returns null for SCSS when postcss-scss cannot be resolved; such a file is
97
+ * skipped, never parsed as CSS.
98
+ */
80
99
  function parseStylesheet(filePath, source) {
81
100
  if (isScssFile(filePath)) {
82
101
  const syntaxPath = resolveScssSyntax();
83
102
  if (!syntaxPath) {
84
103
  return null;
85
104
  }
86
- return postcss.parse(source, { from: filePath, syntax: require(syntaxPath) });
105
+ const syntax = require(syntaxPath);
106
+ return { root: syntax.parse(source, { from: filePath }), syntax };
87
107
  }
88
- return postcss.parse(source, { from: filePath });
108
+ return { root: postcss.parse(source, { from: filePath }), syntax: null };
89
109
  }
90
110
 
91
111
  /**
92
- * Apply one replacement across a directory. Dry run unless `write`; returns
112
+ * Apply one replacement (`px` and `replacement`), or several in one pass
113
+ * (`replacements: [{ px, replacement }]`), across a directory. Dry run unless
114
+ * `write`; the dry run reports exactly what a write would write. Returns
93
115
  * the per-file changes and which files were skipped (unparseable, or SCSS
94
- * without postcss-scss).
116
+ * without postcss-scss). `ignorePatterns` are root-relative paths and globs,
117
+ * as the audit takes them; the audit's generated and vendored directories
118
+ * (node_modules, dist, build and the rest of SKIP_DIRS) are always skipped.
95
119
  */
96
- function runCodemod({ baseFontSize = 16, dir, ignorePatterns = [], properties = null, px, replacement, write = false }) {
120
+ function runCodemod({ baseFontSize = 16, dir, ignorePatterns = [], properties = null, px, replacement, replacements, write = false }) {
97
121
  const rootDir = path.resolve(process.cwd(), dir);
98
122
  const files = walkFiles(rootDir, ignorePatterns).cssFiles.filter(isCssFile).sort();
99
123
  const results = [];
100
124
  const skipped = [];
101
125
  for (const filePath of files) {
102
126
  const source = fs.readFileSync(filePath, 'utf8');
103
- let root;
127
+ let parsed;
104
128
  try {
105
- root = parseStylesheet(filePath, source);
129
+ parsed = parseStylesheet(filePath, source);
106
130
  } catch {
107
131
  skipped.push({ file: formatPath(filePath), reason: 'could not parse' });
108
132
  continue;
109
133
  }
110
- if (!root) {
111
- skipped.push({ file: formatPath(filePath), reason: 'install postcss-scss to rewrite SCSS' });
134
+ if (!parsed) {
135
+ skipped.push({ file: formatPath(filePath), reason: 'postcss-scss is not installed; install it to rewrite SCSS' });
112
136
  continue;
113
137
  }
114
- const changes = replaceLength(root, { baseFontSize, properties, px, replacement });
138
+ const { root, syntax } = parsed;
139
+ const changes = replaceLength(root, { baseFontSize, properties, px, replacement, replacements });
115
140
  if (changes.length === 0) {
116
141
  continue;
117
142
  }
118
143
  if (write) {
119
- fs.writeFileSync(filePath, root.toString());
144
+ fs.writeFileSync(filePath, syntax ? root.toString(syntax) : root.toString());
120
145
  }
121
146
  results.push({ changes, file: formatPath(filePath) });
122
147
  }
@@ -4,10 +4,8 @@ const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
  const { normalizeDecisions } = require('../core/decisions');
6
6
  const { SCALED_GROUPS, isGroupScalesMap } = require('../core/options');
7
- const {
8
- normalizeTokenKind,
9
- normalizeTokenSourceFormat,
10
- } = require('../core/token-sources');
7
+ const { normalizeTokenKind } = require('../core/token-sources');
8
+ const { normalizeTokenSourceEntry } = require('../core/token-source-entries');
11
9
  const {
12
10
  parseBaseFontSize,
13
11
  parseNonNegativeInteger,
@@ -15,6 +13,7 @@ const {
15
13
  parsePercentage,
16
14
  parsePositiveInteger,
17
15
  parseScale,
16
+ validateScale,
18
17
  } = require('./args');
19
18
  const {
20
19
  DEFAULT_IGNORE_PATH,
@@ -117,9 +116,9 @@ function applyAuditConfig(parsed, configResult) {
117
116
  }
118
117
  applyConfigScalar(next, audit, cliOptions, 'scale', 'scale', (value) => {
119
118
  if (Array.isArray(value)) {
120
- return value;
119
+ return validateScale(value, 'audit.scale');
121
120
  }
122
- return parseScale(String(value));
121
+ return parseScale(String(value), 'audit.scale');
123
122
  });
124
123
 
125
124
  next.decisions = normalizeDecisions(configResult.decisions, { baseFontSize: next.baseFontSize });
@@ -189,33 +188,14 @@ function normalizeConfigTokenSources(sources, baseDir) {
189
188
  throw new Error('Invalid Rhythmguard config: audit.tokenSources must be an array.');
190
189
  }
191
190
 
191
+ // The lint reads the same entries through the same normaliser, so both see the same files.
192
192
  return sources.map((source) => {
193
- if (typeof source === 'string') {
194
- return {
195
- baseDir,
196
- format: 'auto',
197
- path: source,
198
- };
199
- }
200
-
201
- if (!source || typeof source !== 'object' || Array.isArray(source)) {
202
- throw new Error('Invalid Rhythmguard config: audit.tokenSources entries must be strings or objects.');
203
- }
204
-
205
- if (typeof source.path !== 'string' || source.path.trim().length === 0) {
206
- throw new Error('Invalid Rhythmguard config: token source objects must include a path.');
193
+ const { error, source: normalized } = normalizeTokenSourceEntry(source, baseDir);
194
+ if (error) {
195
+ // A bad format already reads "Invalid token source format ..."; structural problems name the config.
196
+ throw new Error(/^Invalid /.test(error) ? error : `Invalid Rhythmguard config: ${error}`);
207
197
  }
208
-
209
- if (source.tokenPattern !== undefined && (typeof source.tokenPattern !== 'string' || source.tokenPattern.trim().length === 0)) {
210
- throw new Error('Invalid Rhythmguard config: token source tokenPattern must be a non-empty string.');
211
- }
212
-
213
- return {
214
- baseDir,
215
- format: normalizeTokenSourceFormat(source.format || 'auto'),
216
- path: source.path,
217
- ...(source.tokenPattern ? { tokenPattern: source.tokenPattern } : {}),
218
- };
198
+ return normalized;
219
199
  });
220
200
  }
221
201
 
@@ -240,6 +220,19 @@ function loadIgnorePatterns(ignorePath) {
240
220
  .filter(Boolean);
241
221
  }
242
222
 
223
+ /**
224
+ * The ignore list a run uses: the ignore file (.rhythmguardignore unless
225
+ * --ignore-path or audit.ignorePath names another), then audit.ignore and
226
+ * --ignore. `parsed` is the options after applyAuditConfig. The audit and
227
+ * `rhythmguard fix` both call this, so they never disagree on what is skipped.
228
+ */
229
+ function resolveIgnorePatterns(parsed) {
230
+ return [
231
+ ...loadIgnorePatterns(parsed.ignorePath),
232
+ ...parsed.ignorePatterns,
233
+ ];
234
+ }
235
+
243
236
  module.exports = {
244
237
  applyAuditConfig,
245
238
  applyConfigScalar,
@@ -248,4 +241,5 @@ module.exports = {
248
241
  normalizeCliTokenSources,
249
242
  normalizeConfigTokenSources,
250
243
  parseBooleanOption,
244
+ resolveIgnorePatterns,
251
245
  };
@@ -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
  }
@@ -231,6 +233,8 @@ function buildReport({
231
233
  externalTokenDefinitions,
232
234
  includeMotion,
233
235
  motionFindings,
236
+ parseErrorFindings = [],
237
+ readSource,
234
238
  scale,
235
239
  scanScope,
236
240
  scssFiles = 0,
@@ -267,18 +271,24 @@ function buildReport({
267
271
  ...tailwindFindings.map((finding) => finding.file),
268
272
  ])).slice(0, 10);
269
273
 
270
- const totalFiles = cssFiles.length + templateFiles.length;
274
+ // A stylesheet skipped for want of postcss-scss, or excluded by the project's
275
+ // .stylelintignore, was not audited: it is neither scanned nor clean.
276
+ const cssFilesScanned = Math.max(0, cssFiles.length - scssSkipped - stylelintIgnored);
277
+ const totalFiles = cssFilesScanned + templateFiles.length;
271
278
  const totalWarnings = cssFindings.length + motionFindings.length + tailwindFindings.length;
272
279
  const filesWithIssues = issueFiles.size;
273
- const scaleCleanliness = totalFiles > 0
274
- ? Math.max(0, Math.round(((totalFiles - filesWithIssues) / totalFiles) * 100))
275
- : 100;
280
+ const scaleCleanliness = Math.floor(computeScaleCleanliness({
281
+ filesWithIssues,
282
+ parseErrorFiles: countFiles(parseErrorFindings),
283
+ totalFiles,
284
+ }));
276
285
  const tokenContract = collectTokenContract({
277
286
  baseFontSize,
278
287
  cssFiles,
279
288
  cssFindings,
280
289
  externalTokenDefinitions,
281
290
  minCandidateCount: tokenCandidateMinCount,
291
+ readSource,
282
292
  tailwindFindings,
283
293
  tokenKind,
284
294
  tokenSourceReports,
@@ -286,12 +296,13 @@ function buildReport({
286
296
 
287
297
  return {
288
298
  config,
289
- cssFilesScanned: cssFiles.length,
299
+ cssFilesScanned,
290
300
  directory: dir,
291
301
  filesWithIssues,
292
302
  findings: {
293
303
  css: cssFindings,
294
304
  motion: motionFindings,
305
+ parseErrors: parseErrorFindings,
295
306
  tailwind: tailwindFindings,
296
307
  },
297
308
  formatVersion: 5,
@@ -306,7 +317,7 @@ function buildReport({
306
317
  scaleCleanliness,
307
318
  scanScope,
308
319
  scanned: {
309
- cssFiles: cssFiles.length,
320
+ cssFiles: cssFilesScanned,
310
321
  scssFiles,
311
322
  scssSkipped,
312
323
  stylelintIgnored,
@@ -317,6 +328,7 @@ function buildReport({
317
328
  cssWarnings: cssFindings.length,
318
329
  filesWithIssues,
319
330
  motionFindings: motionFindings.length,
331
+ parseErrors: parseErrorFindings.length,
320
332
  rawValueMatches: tokenContract.summary.rawValueMatches,
321
333
  missingTokens: tokenContract.summary.missingTokens,
322
334
  rawValueCandidates: tokenContract.summary.rawValueCandidates,
@@ -338,6 +350,36 @@ function buildReport({
338
350
  };
339
351
  }
340
352
 
353
+ /**
354
+ * The share of audited files with no finding, unrounded. A file that failed to
355
+ * parse was not audited, so it is out of the denominator. The report shows the
356
+ * floor of this, so it never reads 100% while a file has a finding, and the
357
+ * --min-cleanliness gate compares this exact value.
358
+ */
359
+ function computeScaleCleanliness({ filesWithIssues, parseErrorFiles = 0, totalFiles }) {
360
+ const auditedFiles = totalFiles - parseErrorFiles;
361
+ if (auditedFiles <= 0) {
362
+ return 100;
363
+ }
364
+ return Math.max(0, ((auditedFiles - filesWithIssues) * 100) / auditedFiles);
365
+ }
366
+
367
+ /** The unrounded cleanliness of a built report. */
368
+ function exactScaleCleanliness(report) {
369
+ if (!Number.isFinite(report.totalFiles) || !Number.isFinite(report.filesWithIssues)) {
370
+ return report.scaleCleanliness;
371
+ }
372
+ return computeScaleCleanliness({
373
+ filesWithIssues: report.filesWithIssues,
374
+ parseErrorFiles: countFiles((report.findings && report.findings.parseErrors) || []),
375
+ totalFiles: report.totalFiles,
376
+ });
377
+ }
378
+
379
+ function countFiles(findings) {
380
+ return new Set(findings.map((finding) => finding.file)).size;
381
+ }
382
+
341
383
  function countByValue(values) {
342
384
  const counts = {};
343
385
 
@@ -395,10 +437,52 @@ const AUDIT_JSON_SCHEMA = Object.freeze({
395
437
  baseline: { type: ['object', 'null'] },
396
438
  command: { type: 'object' },
397
439
  contracts: { type: 'object' },
398
- findings: { type: 'object' },
440
+ findings: {
441
+ additionalProperties: true,
442
+ properties: {
443
+ css: { type: 'array' },
444
+ motion: { type: 'array' },
445
+ parseErrors: {
446
+ description: 'Stylesheets that could not be parsed and so were not audited. Not counted as findings or in cleanliness.',
447
+ items: {
448
+ additionalProperties: true,
449
+ properties: {
450
+ column: { type: 'integer' },
451
+ file: { type: 'string' },
452
+ line: { type: 'integer' },
453
+ rule: { const: 'CssSyntaxError' },
454
+ text: { type: 'string' },
455
+ type: { const: 'parse-error' },
456
+ },
457
+ required: ['file', 'text', 'type'],
458
+ type: 'object',
459
+ },
460
+ type: 'array',
461
+ },
462
+ tailwind: { type: 'array' },
463
+ },
464
+ type: 'object',
465
+ },
399
466
  scanned: { type: 'object' },
400
467
  schemaVersion: { const: '2.0' },
401
- summary: { type: 'object' },
468
+ summary: {
469
+ additionalProperties: true,
470
+ properties: {
471
+ parseErrors: {
472
+ description: 'Number of stylesheets that could not be parsed.',
473
+ minimum: 0,
474
+ type: 'integer',
475
+ },
476
+ scaleCleanliness: {
477
+ description: 'Percentage of audited files with no finding, rounded down.',
478
+ maximum: 100,
479
+ minimum: 0,
480
+ type: 'integer',
481
+ },
482
+ totalFindings: { minimum: 0, type: 'integer' },
483
+ },
484
+ type: 'object',
485
+ },
402
486
  },
403
487
  required: ['schemaVersion', 'command', 'summary', 'scanned', 'contracts', 'findings'],
404
488
  title: 'Rhythmguard Audit Report',
@@ -414,8 +498,10 @@ module.exports = {
414
498
  collectTokenContract,
415
499
  collectTokenDefinitions,
416
500
  collectTokenUses,
501
+ computeScaleCleanliness,
417
502
  countByValue,
418
503
  createValueToTokensMap,
504
+ exactScaleCleanliness,
419
505
  mapTokenEntries,
420
506
  sortCountMap,
421
507
  sumCounts,
@@ -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
  }
@@ -28,6 +28,7 @@ function renderMarkdown(report) {
28
28
  ...(report.scanned && report.scanned.scssFiles > 0 ? [`| SCSS files scanned | ${report.scanned.scssFiles - report.scanned.scssSkipped} |`] : []),
29
29
  ...(report.scanned && report.scanned.scssSkipped > 0 ? [`| SCSS files skipped (install postcss-scss) | ${report.scanned.scssSkipped} |`] : []),
30
30
  ...(report.scanned && report.scanned.stylelintIgnored > 0 ? [`| Stylesheets ignored by .stylelintignore (not audited) | ${report.scanned.stylelintIgnored} |`] : []),
31
+ ...(parseErrors(report).length > 0 ? [`| Stylesheets that failed to parse (not audited) | ${parseErrors(report).length} |`] : []),
31
32
  `| Files with issues | ${report.filesWithIssues} |`,
32
33
  `| Total findings | ${report.totalWarnings} |`,
33
34
  `| Scale cleanliness | ${report.scaleCleanliness}% |`,
@@ -57,6 +58,7 @@ function renderMarkdown(report) {
57
58
  appendTokenChainsMarkdown(lines, report.tokenContract.chains);
58
59
  appendTokenLaddersMarkdown(lines, report.tokenContract.ladders);
59
60
  appendBaselineMarkdown(lines, report);
61
+ appendParseErrorsMarkdown(lines, report);
60
62
 
61
63
  if (report.topAffectedFiles.length > 0) {
62
64
  lines.push('## Top Affected Files');
@@ -82,6 +84,9 @@ function renderMarkdown(report) {
82
84
 
83
85
  lines.push('## Recommended Next Actions');
84
86
  lines.push('');
87
+ if (parseErrors(report).length > 0) {
88
+ lines.push('- Fix the parse errors above: those stylesheets were not audited.');
89
+ }
85
90
  if (report.totalWarnings === 0) {
86
91
  lines.push('- Keep Rhythmguard in CI to prevent new drift.');
87
92
  } else {
@@ -94,6 +99,27 @@ function renderMarkdown(report) {
94
99
  return `${lines.join('\n')}\n`;
95
100
  }
96
101
 
102
+ function parseErrors(report) {
103
+ return (report.findings && report.findings.parseErrors) || [];
104
+ }
105
+
106
+ function appendParseErrorsMarkdown(lines, report) {
107
+ const errors = parseErrors(report);
108
+ if (errors.length === 0) {
109
+ return;
110
+ }
111
+ lines.push('## Parse Errors');
112
+ lines.push('');
113
+ lines.push('These stylesheets could not be parsed, so they were not audited. They are not counted as findings or in scale cleanliness.');
114
+ lines.push('');
115
+ lines.push('| File | Error |');
116
+ lines.push('| --- | --- |');
117
+ for (const finding of errors) {
118
+ lines.push(`| \`${escapeMarkdown(`${finding.file}:${finding.line}:${finding.column}`)}\` | ${escapeMarkdown(finding.text)} |`);
119
+ }
120
+ lines.push('');
121
+ }
122
+
97
123
  function appendTokenChainsMarkdown(lines, chains) {
98
124
  if (!chains || chains.summary.total === 0) {
99
125
  return;
@@ -295,6 +321,7 @@ function appendMarkdownCounts(lines, title, counts, label = 'Value') {
295
321
  module.exports = {
296
322
  appendBaselineMarkdown,
297
323
  appendMarkdownCounts,
324
+ appendParseErrorsMarkdown,
298
325
  appendTokenContractMarkdown,
299
326
  renderMarkdown,
300
327
  };
@@ -22,6 +22,7 @@ function renderText(report) {
22
22
  ...(report.scanned && report.scanned.scssFiles > 0 ? [` SCSS files scanned ${String(report.scanned.scssFiles - report.scanned.scssSkipped).padStart(4)}`] : []),
23
23
  ...(report.scanned && report.scanned.scssSkipped > 0 ? [` SCSS files skipped ${String(report.scanned.scssSkipped).padStart(4)} (install postcss-scss to audit them)`] : []),
24
24
  ...(report.scanned && report.scanned.stylelintIgnored > 0 ? [` Ignored by .stylelintignore ${String(report.scanned.stylelintIgnored).padStart(4)} (not audited)`] : []),
25
+ ...(parseErrors(report).length > 0 ? [` Parse errors ${String(parseErrors(report).length).padStart(4)} (not audited)`] : []),
25
26
  ` Files with issues ${String(report.filesWithIssues).padStart(4)}`,
26
27
  ` Scale cleanliness ${scoreBar(report.scaleCleanliness)} ${report.scaleCleanliness}%`,
27
28
  ];
@@ -57,6 +58,7 @@ function renderText(report) {
57
58
  lines.push('');
58
59
  }
59
60
  appendBaselineText(lines, report);
61
+ appendParseErrorsText(lines, report);
60
62
 
61
63
  if (report.topAffectedFiles.length > 0) {
62
64
  lines.push(' ── TOP AFFECTED FILES ──');
@@ -68,7 +70,9 @@ function renderText(report) {
68
70
  lines.push('');
69
71
  }
70
72
 
71
- if (report.totalWarnings === 0) {
73
+ if (report.totalWarnings === 0 && parseErrors(report).length > 0) {
74
+ lines.push(` ! No findings in the stylesheets that parsed; ${parseErrors(report).length} could not be parsed and were not audited.`);
75
+ } else if (report.totalWarnings === 0) {
72
76
  lines.push(' ✓ No issues found. Your design-system rhythm is clean.');
73
77
  } else {
74
78
  lines.push(' → Run "npx stylelint --fix" for CSS declaration fixes.');
@@ -79,6 +83,23 @@ function renderText(report) {
79
83
  return `${lines.join('\n')}\n`;
80
84
  }
81
85
 
86
+ function parseErrors(report) {
87
+ return (report.findings && report.findings.parseErrors) || [];
88
+ }
89
+
90
+ function appendParseErrorsText(lines, report) {
91
+ const errors = parseErrors(report);
92
+ if (errors.length === 0) {
93
+ return;
94
+ }
95
+ lines.push(` ── PARSE ERRORS ── ${errors.length} not audited`);
96
+ lines.push('');
97
+ for (const finding of errors) {
98
+ lines.push(` ${finding.file}:${finding.line}:${finding.column} ${finding.text}`);
99
+ }
100
+ lines.push('');
101
+ }
102
+
82
103
  function appendTokenContractText(lines, tokenContract) {
83
104
  const {
84
105
  conflictingTokens,
@@ -184,6 +205,7 @@ function appendHistogram(lines, title, counts = {}) {
184
205
  module.exports = {
185
206
  appendBaselineText,
186
207
  appendHistogram,
208
+ appendParseErrorsText,
187
209
  appendTokenContractText,
188
210
  renderText,
189
211
  };