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
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const fs = require('node:fs');
3
+ const path = require('node:path');
4
4
 
5
5
  const {
6
6
  createTokenKindMatcher,
@@ -14,7 +14,7 @@ const { createDefaultAuditOptions } = require('./shared');
14
14
  const {
15
15
  applyAuditConfig,
16
16
  loadAuditConfig,
17
- loadIgnorePatterns,
17
+ resolveIgnorePatterns,
18
18
  } = require('./config');
19
19
  const { buildReport, collectTokenDefinitions } = require('./contract');
20
20
  const { DEFAULT_SCALE, formatPath } = require('./shared');
@@ -27,22 +27,20 @@ const {
27
27
  const { applyDecisions, buildDecisionPlan } = require('./decisions');
28
28
  const { collectDeclarations, resolveTokenChains, toChainsContract } = require('./token-chains');
29
29
  const { resolveTokenLadders, toLaddersContract } = require('./token-ladders');
30
- const { assertDirectory, getScanFiles } = require('./scan/files');
30
+ const { assertDirectory, getScanFiles, readSourceFromDisk } = require('./scan/files');
31
31
  const { collectCssFindings, runStylelintAudit } = require('./scan/stylesheets');
32
32
  const { collectTailwindFindings, collectTailwindMotionFindings } = require('./scan/templates');
33
33
 
34
34
  async function createAuditReport(options) {
35
35
  const parsed = normalizeCreateAuditOptions(options);
36
36
  const resolvedDir = assertDirectory(parsed.dir);
37
- let ignorePatterns;
38
- ignorePatterns = [
39
- ...loadIgnorePatterns(parsed.ignorePath),
40
- ...parsed.ignorePatterns,
41
- ];
37
+ const ignorePatterns = resolveIgnorePatterns(parsed);
42
38
 
43
39
  const scanFiles = getScanFiles(resolvedDir, ignorePatterns, parsed);
44
40
 
45
41
  const { cssFiles, scanScope, templateFiles } = scanFiles;
42
+ // A --staged run reads each changed file as the index holds it.
43
+ const readSource = scanFiles.readSource || readSourceFromDisk;
46
44
 
47
45
  const tokenSourceResult = parseTokenSources({
48
46
  baseFontSize: parsed.baseFontSize,
@@ -50,23 +48,34 @@ async function createAuditReport(options) {
50
48
  tokenKind: parsed.tokenKind,
51
49
  });
52
50
 
51
+ // The spacing scale always reads spacing tokens; --token-kind shapes the token contract only (issue #260).
52
+ const spacingSourceResult = parsed.tokenKind === 'spacing'
53
+ ? tokenSourceResult
54
+ : parseTokenSources({ baseFontSize: parsed.baseFontSize, sources: parsed.tokenSources, tokenKind: 'spacing' });
55
+
53
56
  const scale = resolveAuditScale({
54
57
  baseFontSize: parsed.baseFontSize,
55
58
  cssFiles,
59
+ readSource,
56
60
  requested: parsed.scale,
57
- tokenSourceResult,
61
+ tokenSourceResult: spacingSourceResult,
58
62
  });
59
63
 
60
64
  const lintOptions = {
61
65
  baseFontSize: parsed.baseFontSize,
62
66
  checkMathBounds: parsed.checkMathBounds,
63
67
  includeMotion: parsed.includeMotion,
68
+ readSource: scanFiles.readSource,
64
69
  scale: scale.values,
65
70
  scales: parsed.scales,
66
71
  };
67
72
 
68
73
  const cssResults = await runStylelintAudit(cssFiles, lintOptions);
69
- const stylelintFindings = collectCssFindings(cssResults);
74
+ const collectedCssFindings = collectCssFindings(cssResults, readSource);
75
+ // A stylesheet that failed to parse was not audited; it is reported apart
76
+ // and is neither a finding nor a clean file.
77
+ const parseErrorFindings = collectedCssFindings.filter((finding) => finding.type === 'parse-error');
78
+ const stylelintFindings = collectedCssFindings.filter((finding) => finding.type !== 'parse-error');
70
79
  const decided = applyDecisions({
71
80
  baseFontSize: parsed.baseFontSize,
72
81
  cssFindings: stylelintFindings.filter((finding) => !finding.type.startsWith('motion-')),
@@ -88,6 +97,8 @@ async function createAuditReport(options) {
88
97
  externalTokenDefinitions: tokenSourceResult.definitions,
89
98
  includeMotion: parsed.includeMotion,
90
99
  motionFindings,
100
+ parseErrorFindings,
101
+ readSource,
91
102
  scale,
92
103
  scanScope,
93
104
  scssFiles: cssResults.scssFiles || 0,
@@ -104,11 +115,13 @@ async function createAuditReport(options) {
104
115
  const declarations = collectDeclarations({
105
116
  cssFiles,
106
117
  externalDefinitions: tokenSourceResult.definitions,
118
+ readSource,
107
119
  skipFile: (file) => NON_AUTHORED_SEGMENT.test(file),
108
120
  });
109
121
  report.tokenContract.chains = toChainsContract(resolveTokenChains({
110
122
  baseFontSize: parsed.baseFontSize,
111
123
  declarations,
124
+ references: collectDeclarations({ cssFiles: [], externalDefinitions: chainDefinitions(parsed) }),
112
125
  scale: scale.values,
113
126
  }));
114
127
  report.tokenContract.ladders = toLaddersContract(resolveTokenLadders({
@@ -127,17 +140,47 @@ async function createAuditReport(options) {
127
140
  scale: scale.values,
128
141
  });
129
142
 
130
- if (parsed.sinceBaseline) {
131
- applyBaselineComparison(report, parsed.baselinePath);
132
- }
143
+ if (parsed.sinceBaseline || parsed.writeBaseline) {
144
+ const unauditedFiles = [
145
+ ...(cssResults.unauditedFiles || []),
146
+ ...parseErrorFindings.map((finding) => path.resolve(process.cwd(), finding.file)),
147
+ ];
148
+ const unaudited = new Set(unauditedFiles);
149
+ const baselineScope = {
150
+ auditedFiles: [...cssFiles, ...templateFiles].filter((file) => !unaudited.has(file)),
151
+ scanRoot: resolvedDir,
152
+ scoped: scanScope.mode !== 'full',
153
+ unauditedFiles,
154
+ };
155
+
156
+ if (parsed.sinceBaseline) {
157
+ applyBaselineComparison(report, parsed.baselinePath, baselineScope);
158
+ }
133
159
 
134
- if (parsed.writeBaseline) {
135
- writeBaseline(report, parsed.baselinePath);
160
+ if (parsed.writeBaseline) {
161
+ writeBaseline(report, parsed.baselinePath, baselineScope);
162
+ }
136
163
  }
137
164
 
138
165
  return report;
139
166
  }
140
167
 
168
+ /**
169
+ * Every custom property a chain can end in: all tokens in the token sources,
170
+ * whatever --token-kind says and whatever name pattern a source sets, plus
171
+ * installed token packages (issue #259). A chain through `--size-8` or a
172
+ * package's `--space-2` resolves instead of reading "not declared"; these are
173
+ * looked up only, never reported as chains of their own.
174
+ */
175
+ function chainDefinitions(parsed) {
176
+ const unfiltered = (source) => (source && typeof source === 'object' ? { ...source, tokenPattern: null } : source);
177
+ return parseTokenSources({
178
+ baseFontSize: parsed.baseFontSize,
179
+ sources: [...parsed.tokenSources, ...discoverTokenPackages(process.cwd())].map(unfiltered),
180
+ tokenKind: 'all',
181
+ }).definitions;
182
+ }
183
+
141
184
  const NON_AUTHORED_SEGMENT = /(^|\/)(test|tests|__tests__|spec|specs|fixtures?|__fixtures__|__snapshots__|vendor|third[-_]?party|storybook-static)(\/|$)/i;
142
185
 
143
186
  /**
@@ -145,7 +188,7 @@ const NON_AUTHORED_SEGMENT = /(^|\/)(test|tests|__tests__|spec|specs|fixtures?|_
145
188
  * "auto": external token sources, then spacing custom properties across the scanned
146
189
  * CSS, then the default scale. First source that yields values wins.
147
190
  */
148
- function resolveAuditScale({ baseFontSize, cssFiles, requested, tokenSourceResult }) {
191
+ function resolveAuditScale({ baseFontSize, cssFiles, readSource = readSourceFromDisk, requested, tokenSourceResult }) {
149
192
  if (Array.isArray(requested)) {
150
193
  return {
151
194
  files: [],
@@ -178,7 +221,7 @@ function resolveAuditScale({ baseFontSize, cssFiles, requested, tokenSourceResul
178
221
  }
179
222
  let text;
180
223
  try {
181
- text = fs.readFileSync(filePath, 'utf8');
224
+ text = readSource(filePath);
182
225
  } catch {
183
226
  continue;
184
227
  }
@@ -77,39 +77,39 @@ function getScanFiles(rootDir, ignorePatterns, parsed) {
77
77
  }
78
78
 
79
79
  function getGitChangedScanFiles(rootDir, ignorePatterns, parsed) {
80
- const args = parsed.staged
81
- ? ['diff', '--name-only', '--cached', '--diff-filter=ACMR', '--']
82
- : ['diff', '--name-only', '--diff-filter=ACMR', parsed.since, '--'];
83
- let output;
84
-
85
- try {
86
- output = execFileSync('git', args, {
87
- cwd: process.cwd(),
88
- encoding: 'utf8',
89
- stdio: ['ignore', 'pipe', 'pipe'],
90
- });
91
- } catch (err) {
92
- const stderr = err.stderr ? String(err.stderr).trim() : err.message;
93
- throw new Error(`Unable to read changed files from git: ${stderr}`, { cause: err });
94
- }
95
-
80
+ // Git runs in the scanned directory, so the repository is the one that holds
81
+ // the files, whichever directory the command was started from.
82
+ const topLevel = runGit(rootDir, ['rev-parse', '--show-toplevel']).replace(/\r?\n$/, '');
83
+ const changed = parsed.staged
84
+ ? listStagedFiles(rootDir)
85
+ : listFilesChangedSince(rootDir, parsed.since);
86
+
87
+ // git reports the top level with symlinks resolved (/private/var on macOS);
88
+ // compare in that form, then name files under the directory as it was given.
89
+ const realRootDir = fs.realpathSync(rootDir);
96
90
  const cssFiles = [];
97
91
  const templateFiles = [];
98
92
  const ignoreMatchers = createIgnoreMatchers(ignorePatterns);
99
93
  const seen = new Set();
100
- const changedFiles = output.split(/\r?\n/)
101
- .map((filePath) => filePath.trim())
102
- .filter(Boolean);
103
-
104
- for (const filePath of changedFiles) {
105
- const fullPath = path.resolve(process.cwd(), filePath);
94
+ const stagedBlobs = new Map();
106
95
 
107
- if (!isPathInside(rootDir, fullPath) || seen.has(fullPath) || !fs.existsSync(fullPath)) {
96
+ for (const entry of changed) {
97
+ const realPath = path.resolve(topLevel, entry.path);
98
+ if (!isPathInside(realRootDir, realPath)) {
99
+ continue;
100
+ }
101
+ const fullPath = path.join(rootDir, path.relative(realRootDir, realPath));
102
+ if (seen.has(fullPath)) {
108
103
  continue;
109
104
  }
110
105
 
111
- const stat = fs.statSync(fullPath);
112
- if (!stat.isFile()) {
106
+ if (parsed.staged) {
107
+ // The index entry is what gets committed: a regular file blob, whatever
108
+ // the working tree holds now, or whether the file is still there at all.
109
+ if (!REGULAR_FILE_MODES.has(entry.mode)) {
110
+ continue;
111
+ }
112
+ } else if (!fs.existsSync(fullPath) || !fs.statSync(fullPath).isFile()) {
113
113
  continue;
114
114
  }
115
115
 
@@ -123,18 +123,143 @@ function getGitChangedScanFiles(rootDir, ignorePatterns, parsed) {
123
123
  cssFiles.push(fullPath);
124
124
  } else if (isTemplateFile(fullPath)) {
125
125
  templateFiles.push(fullPath);
126
+ } else {
127
+ continue;
128
+ }
129
+ if (parsed.staged) {
130
+ stagedBlobs.set(fullPath, entry.blob);
126
131
  }
127
132
  }
128
133
 
129
- return {
134
+ const result = {
130
135
  cssFiles,
131
136
  scanScope: {
132
- changedFiles: changedFiles.length,
137
+ changedFiles: changed.length,
133
138
  mode: parsed.staged ? 'staged' : 'since',
134
139
  since: parsed.since,
135
140
  },
136
141
  templateFiles,
137
142
  };
143
+
144
+ if (parsed.staged) {
145
+ result.readSource = createStagedSourceReader(rootDir, stagedBlobs);
146
+ }
147
+
148
+ return result;
149
+ }
150
+
151
+ const REGULAR_FILE_MODES = new Set(['100644', '100755']);
152
+
153
+ // -z prints names raw and NUL-separated: no quoting of spaces or non-ASCII
154
+ // bytes. diff.relative is pinned off so every name is relative to the
155
+ // repository top level, whatever the user's git config says.
156
+ function listFilesChangedSince(rootDir, since) {
157
+ const output = runGit(rootDir, ['-c', 'diff.relative=false', 'diff', '--name-only', '-z', '--diff-filter=ACMR', since, '--']);
158
+ return output.split('\0').filter(Boolean).map((filePath) => ({ path: filePath }));
159
+ }
160
+
161
+ /**
162
+ * Staged files with the mode and blob id the index holds for each. `--raw -z`
163
+ * prints `:<old mode> <new mode> <old id> <new id> <status>` and then the path,
164
+ * or two paths (from, then to) for a rename or a copy.
165
+ */
166
+ function listStagedFiles(rootDir) {
167
+ const output = runGit(rootDir, [
168
+ '-c', 'diff.relative=false',
169
+ 'diff', '--cached', '--raw', '-z', '--no-abbrev', '--no-color', '--diff-filter=ACMR', '--',
170
+ ]);
171
+ const fields = output.split('\0');
172
+ const entries = [];
173
+ for (let index = 0; index < fields.length; index++) {
174
+ const header = fields[index];
175
+ if (!header.startsWith(':')) {
176
+ continue;
177
+ }
178
+ const [, mode, , blob, status = ''] = header.slice(1).split(' ');
179
+ const pathCount = /^[RC]/.test(status) ? 2 : 1;
180
+ const filePath = fields[index + pathCount];
181
+ index += pathCount;
182
+ if (filePath) {
183
+ entries.push({ blob, mode, path: filePath });
184
+ }
185
+ }
186
+ return entries;
187
+ }
188
+
189
+ /**
190
+ * Reads staged files from their index blobs, fetched in one `git cat-file
191
+ * --batch` call, and any other path from disk. Each blob comes back as a
192
+ * `<id> blob <size>` line followed by exactly <size> bytes and a newline.
193
+ */
194
+ function createStagedSourceReader(rootDir, stagedBlobs) {
195
+ const contents = new Map();
196
+ const ids = Array.from(new Set(stagedBlobs.values()));
197
+ if (ids.length > 0) {
198
+ const output = runGit(rootDir, ['cat-file', '--batch'], `${ids.join('\n')}\n`, null);
199
+ const byId = new Map();
200
+ let offset = 0;
201
+ while (offset < output.length) {
202
+ const headerEnd = output.indexOf(0x0a, offset);
203
+ if (headerEnd === -1) {
204
+ break;
205
+ }
206
+ const [id, type, size] = output.subarray(offset, headerEnd).toString('utf8').split(' ');
207
+ offset = headerEnd + 1;
208
+ if (type !== 'blob') {
209
+ continue;
210
+ }
211
+ const length = Number(size);
212
+ byId.set(id, output.subarray(offset, offset + length).toString('utf8'));
213
+ offset += length + 1;
214
+ }
215
+ for (const [filePath, id] of stagedBlobs) {
216
+ if (byId.has(id)) {
217
+ contents.set(filePath, byId.get(id));
218
+ }
219
+ }
220
+ }
221
+
222
+ return (filePath) => (contents.has(filePath)
223
+ ? contents.get(filePath)
224
+ : readSourceFromDisk(filePath));
225
+ }
226
+
227
+ /** Reads a scanned file from disk. A staged run swaps in a reader for the index. */
228
+ function readSourceFromDisk(filePath) {
229
+ return fs.readFileSync(filePath, 'utf8');
230
+ }
231
+
232
+ /**
233
+ * The repository top level for a directory, with symlinks resolved, or null
234
+ * when the directory is not inside a git work tree (or git is not installed).
235
+ */
236
+ function findGitTopLevel(dir) {
237
+ try {
238
+ const output = execFileSync('git', ['rev-parse', '--show-toplevel'], {
239
+ cwd: dir,
240
+ encoding: 'utf8',
241
+ stdio: ['ignore', 'pipe', 'ignore'],
242
+ }).replace(/\r?\n$/, '');
243
+ return output ? path.resolve(output) : null;
244
+ } catch {
245
+ return null;
246
+ }
247
+ }
248
+
249
+ // With `encoding` null the output comes back as a Buffer (blob sizes are bytes).
250
+ function runGit(cwd, args, input, encoding = 'utf8') {
251
+ try {
252
+ return execFileSync('git', args, {
253
+ cwd,
254
+ encoding,
255
+ input,
256
+ maxBuffer: 1024 * 1024 * 1024,
257
+ stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
258
+ });
259
+ } catch (err) {
260
+ const stderr = err.stderr ? String(err.stderr).trim() : err.message;
261
+ throw new Error(`Unable to read changed files from git: ${stderr}`, { cause: err });
262
+ }
138
263
  }
139
264
 
140
265
  function isPathInside(rootDir, filePath) {
@@ -247,6 +372,7 @@ module.exports = {
247
372
  assertDirectory,
248
373
  createIgnoreMatchers,
249
374
  escapeRegExp,
375
+ findGitTopLevel,
250
376
  getGitChangedScanFiles,
251
377
  getScanFiles,
252
378
  globToRegExp,
@@ -255,6 +381,7 @@ module.exports = {
255
381
  isPathInside,
256
382
  isScssFile,
257
383
  isTemplateFile,
384
+ readSourceFromDisk,
258
385
  shouldIgnorePath,
259
386
  shouldIgnoreRelativeFile,
260
387
  toPosixRelativePath,
@@ -5,13 +5,13 @@
5
5
  * warning into a finding, and recover the CSS property at the warning position
6
6
  * from the source, since Stylelint reports positions rather than nodes.
7
7
  */
8
- const fs = require('node:fs');
8
+ const path = require('node:path');
9
9
  const {
10
10
  DEFAULT_AUDIT_TOKEN_PATTERN,
11
11
  formatPath,
12
12
  pluginPath,
13
13
  } = require('../shared');
14
- const { isScssFile } = require('./files');
14
+ const { isScssFile, readSourceFromDisk } = require('./files');
15
15
 
16
16
  let scssSyntaxCache;
17
17
 
@@ -37,6 +37,8 @@ async function runStylelintAudit(cssFiles, options) {
37
37
  const empty = [];
38
38
  empty.scssFiles = 0;
39
39
  empty.scssSkipped = 0;
40
+ empty.stylelintIgnored = 0;
41
+ empty.unauditedFiles = [];
40
42
  return empty;
41
43
  }
42
44
 
@@ -80,56 +82,106 @@ async function runStylelintAudit(cssFiles, options) {
80
82
  const scssFiles = cssFiles.filter(isScssFile);
81
83
  const results = [];
82
84
 
85
+ // A project's own .stylelintignore may exclude every file we pass; that is
86
+ // a count to report, not a reason for the audit to throw.
87
+ const lint = (files, config) => (options.readSource
88
+ ? lintSources(stylelint, files, config, options.readSource)
89
+ : stylelint.lint({ allowEmptyInput: true, config, files }).then((result) => result.results || []));
90
+
83
91
  if (plainFiles.length > 0) {
84
- // A project's own .stylelintignore may exclude every file we pass; that is
85
- // a count to report, not a reason for the audit to throw.
86
- const result = await stylelint.lint({
87
- allowEmptyInput: true,
88
- files: plainFiles,
89
- config: {
90
- plugins: [pluginPath],
91
- rules,
92
- },
93
- });
94
- results.push(...(result.results || []));
92
+ results.push(...await lint(plainFiles, { plugins: [pluginPath], rules }));
95
93
  }
96
94
 
97
- let scssSkipped = 0;
95
+ let skippedScssFiles = [];
98
96
  if (scssFiles.length > 0) {
99
97
  const scssSyntax = resolveScssSyntax();
100
98
  if (scssSyntax) {
101
- const result = await stylelint.lint({
102
- allowEmptyInput: true,
103
- files: scssFiles,
104
- config: {
105
- customSyntax: scssSyntax,
106
- plugins: [pluginPath],
107
- rules,
108
- },
109
- });
110
- results.push(...(result.results || []));
99
+ results.push(...await lint(scssFiles, { customSyntax: scssSyntax, plugins: [pluginPath], rules }));
111
100
  } else {
112
- scssSkipped = scssFiles.length;
101
+ skippedScssFiles = scssFiles;
113
102
  }
114
103
  }
115
104
 
116
- results.scssFiles = scssFiles.length;
117
- results.scssSkipped = scssSkipped;
105
+ assertNoInvalidOptions(results);
106
+
118
107
  // Stylelint returns no result at all for a file its ignore file excludes, so
119
- // the difference between what was passed and what came back is that count.
120
- const linted = plainFiles.length + scssFiles.length - scssSkipped;
121
- results.stylelintIgnored = Math.max(0, linted - results.length);
108
+ // a file passed in with no result back is one it ignored.
109
+ const returned = new Set(results.map((result) => path.resolve(String(result.source))));
110
+ const skipped = new Set(skippedScssFiles);
111
+ const ignoredFiles = cssFiles.filter((file) => !skipped.has(file) && !returned.has(path.resolve(file)));
112
+
113
+ results.scssFiles = scssFiles.length;
114
+ results.scssSkipped = skippedScssFiles.length;
115
+ results.stylelintIgnored = ignoredFiles.length;
116
+ // Neither kind was audited: they count as neither scanned nor clean.
117
+ results.unauditedFiles = [...skippedScssFiles, ...ignoredFiles];
122
118
  return results;
123
119
  }
124
120
 
125
- function collectCssFindings(fileResults) {
121
+ /** Lints each file from the text the reader returns (the staged blob in a --staged run). */
122
+ async function lintSources(stylelint, files, config, readSource) {
123
+ const results = [];
124
+ for (const file of files) {
125
+ let code;
126
+ try {
127
+ code = readSource(file);
128
+ } catch {
129
+ continue;
130
+ }
131
+ const result = await stylelint.lint({ allowEmptyInput: true, code, codeFilename: file, config });
132
+ results.push(...(result.results || []));
133
+ }
134
+ return results;
135
+ }
136
+
137
+ /**
138
+ * Stylelint does not run a rule whose options it rejects: it reports an
139
+ * invalidOptionWarning and no findings for that rule. Read as findings, that is
140
+ * a clean audit and a passing gate, so any rejected option stops the run.
141
+ */
142
+ function assertNoInvalidOptions(results) {
143
+ const texts = new Set();
144
+ for (const result of results) {
145
+ for (const warning of result.invalidOptionWarnings || []) {
146
+ texts.add(warning.text);
147
+ }
148
+ }
149
+
150
+ if (texts.size > 0) {
151
+ throw new Error(`Stylelint rejected the audit's rule options, so no findings could be collected: ${Array.from(texts).join('; ')}`);
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Stylelint reports a stylesheet it cannot parse as a single CssSyntaxError
157
+ * warning and runs no rule on it. That is not drift: it is a file the audit
158
+ * could not read, kept apart as a `parse-error` finding.
159
+ */
160
+ function isParseErrorWarning(warning) {
161
+ return warning.rule === 'CssSyntaxError';
162
+ }
163
+
164
+ function collectCssFindings(fileResults, readSource) {
126
165
  const findings = [];
127
166
  const sources = new Map();
128
167
 
129
168
  for (const fileResult of fileResults) {
130
169
  for (const warning of fileResult.warnings || []) {
131
170
  const text = warning.text || '';
132
- const source = readSourceOnce(sources, fileResult.source);
171
+ if (isParseErrorWarning(warning)) {
172
+ findings.push({
173
+ column: warning.column || 1,
174
+ file: formatPath(fileResult.source),
175
+ line: warning.line || 1,
176
+ property: null,
177
+ rule: warning.rule,
178
+ text,
179
+ type: 'parse-error',
180
+ value: null,
181
+ });
182
+ continue;
183
+ }
184
+ const source = readSourceOnce(sources, fileResult.source, readSource);
133
185
  const offScaleMatch = text.match(
134
186
  /Unexpected (?:off-scale (?:value|(?:clamp|min|max)\(\) (?:minimum|maximum|argument))|transform translation value) "([^"]+)"/,
135
187
  );
@@ -166,13 +218,13 @@ function collectCssFindings(fileResults) {
166
218
  return findings;
167
219
  }
168
220
 
169
- function readSourceOnce(cache, filePath) {
221
+ function readSourceOnce(cache, filePath, readSource = readSourceFromDisk) {
170
222
  if (!filePath) {
171
223
  return null;
172
224
  }
173
225
  if (!cache.has(filePath)) {
174
226
  try {
175
- cache.set(filePath, fs.readFileSync(filePath, 'utf8'));
227
+ cache.set(filePath, readSource(filePath));
176
228
  } catch {
177
229
  cache.set(filePath, null);
178
230
  }
@@ -281,6 +333,7 @@ module.exports = {
281
333
  findDeclarationProperty,
282
334
  getCssFindingType,
283
335
  getCssFindingValue,
336
+ isParseErrorWarning,
284
337
  positionToOffset,
285
338
  readSourceOnce,
286
339
  resolveScssSyntax,
@@ -20,7 +20,7 @@ function collectTailwindFindings(templateFiles, options) {
20
20
  for (const filePath of templateFiles) {
21
21
  let source;
22
22
  try {
23
- source = fs.readFileSync(filePath, 'utf8');
23
+ source = options.readSource ? options.readSource(filePath) : fs.readFileSync(filePath, 'utf8');
24
24
  } catch {
25
25
  continue;
26
26
  }
@@ -66,7 +66,7 @@ function collectTailwindMotionFindings(templateFiles, options) {
66
66
  for (const filePath of templateFiles) {
67
67
  let source;
68
68
  try {
69
- source = fs.readFileSync(filePath, 'utf8');
69
+ source = options.readSource ? options.readSource(filePath) : fs.readFileSync(filePath, 'utf8');
70
70
  } catch {
71
71
  continue;
72
72
  }
@@ -122,15 +122,28 @@ function buildTailwindMotionFindingText(token, analysis) {
122
122
  * bundle that took hours. This walks the source once: an escape never closes a
123
123
  * literal, a ' or " literal that reaches a line break is not a literal (the
124
124
  * language agrees), and a backtick literal may span lines.
125
+ *
126
+ * Class strings also sit inside other literals: a ternary in a template
127
+ * literal's ${}, a Vue :class="{ 'p-2': on }" object, a Svelte {expression}
128
+ * inside class="...". After a literal closes, its value is scanned again for
129
+ * literals in the other quote kinds. A literal never holds its own quote
130
+ * unescaped, so each level drops one quote kind: at most three levels, and
131
+ * every character is read at most three times.
125
132
  */
133
+ const QUOTES = ['"', "'", '`'];
134
+
126
135
  function findStringLiterals(source) {
127
136
  const literals = [];
128
- const { length } = source;
129
- let index = 0;
137
+ scanStringLiterals(source, 0, source.length, QUOTES, literals);
138
+ return literals;
139
+ }
140
+
141
+ function scanStringLiterals(source, start, length, quotes, literals) {
142
+ let index = start;
130
143
 
131
144
  while (index < length) {
132
145
  const quote = source[index];
133
- if (quote !== '"' && quote !== "'" && quote !== '`') {
146
+ if (!quotes.includes(quote)) {
134
147
  index += 1;
135
148
  continue;
136
149
  }
@@ -156,13 +169,15 @@ function findStringLiterals(source) {
156
169
 
157
170
  if (closed) {
158
171
  literals.push({ quote, value: source.slice(valueStart, cursor), valueStart });
172
+ const innerQuotes = quotes.filter((candidate) => candidate !== quote);
173
+ if (innerQuotes.length > 0) {
174
+ scanStringLiterals(source, valueStart, cursor, innerQuotes, literals);
175
+ }
159
176
  index = cursor + 1;
160
177
  } else {
161
178
  index = valueStart;
162
179
  }
163
180
  }
164
-
165
- return literals;
166
181
  }
167
182
 
168
183
  function getLineStarts(source) {