stylelint-plugin-rhythmguard 3.3.0 → 3.5.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 (34) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/CONTRIBUTING.md +19 -3
  3. package/README.md +2 -1
  4. package/package.json +7 -7
  5. package/src/audit/args.js +103 -307
  6. package/src/audit/config.js +7 -1
  7. package/src/audit/contract.js +5 -28
  8. package/src/audit/index.js +4 -7
  9. package/src/audit/report.js +14 -14
  10. package/src/audit/scan/files.js +262 -0
  11. package/src/audit/scan/stylesheets.js +275 -0
  12. package/src/audit/scan/templates.js +175 -0
  13. package/src/cli/doctor.js +1 -1
  14. package/src/cli/quickstart.js +1 -1
  15. package/src/{utils → core}/length.js +21 -0
  16. package/src/{utils → core}/options.js +32 -108
  17. package/src/{utils → core}/scale-inference.js +151 -15
  18. package/src/{utils → core}/token-packages.json +36 -0
  19. package/src/{utils → core}/token-sources.js +121 -12
  20. package/src/{utils/value-utils.js → core/value-nodes.js} +1 -17
  21. package/src/eslint/rules/tailwind-class-use-motion-scale.js +2 -2
  22. package/src/eslint/rules/tailwind-class-use-scale.js +2 -2
  23. package/src/rules/no-offscale-transform/index.js +22 -91
  24. package/src/rules/prefer-token/index.js +15 -73
  25. package/src/rules/report.js +75 -0
  26. package/src/rules/use-motion-scale/index.js +25 -42
  27. package/src/rules/use-scale/index.js +21 -94
  28. package/src/rules/validate.js +132 -0
  29. package/src/audit/scan.js +0 -676
  30. /package/src/{utils/constants.js → core/css-vocabulary.js} +0 -0
  31. /package/src/{utils → core}/tailwind-class-analysis.js +0 -0
  32. /package/src/{utils → core}/tailwind-motion-analysis.js +0 -0
  33. /package/src/{utils → core}/time.js +0 -0
  34. /package/src/{utils → core}/token-map.js +0 -0
@@ -3,10 +3,10 @@
3
3
  const fs = require('node:fs');
4
4
  const {
5
5
  addDefinition,
6
- collectScssTokens,
6
+ collectCssTokens,
7
7
  createTokenKindMatcher,
8
8
  getNormalizedValueKeys,
9
- } = require('../utils/token-sources');
9
+ } = require('../core/token-sources');
10
10
  const { formatPath } = require('./shared');
11
11
 
12
12
  function collectTokenContract({
@@ -95,33 +95,10 @@ function collectTokenContract({
95
95
  };
96
96
  }
97
97
 
98
+ /** Custom properties and Sass tokens declared in one stylesheet, added to the definitions map. */
98
99
  function collectTokenDefinitions(source, file, definitions, matchesKind, baseFontSize) {
99
- const declarationPattern = /(--[\w-]+)\s*:\s*([^;{}]+)/g;
100
- let match;
101
-
102
- while ((match = declarationPattern.exec(source)) !== null) {
103
- const token = match[1];
104
- if (!matchesKind(token)) {
105
- continue;
106
- }
107
-
108
- addDefinition(definitions, {
109
- baseFontSize,
110
- file,
111
- source: file,
112
- token,
113
- value: match[2].trim(),
114
- });
115
- }
116
-
117
- for (const sassToken of collectScssTokens(source, matchesKind)) {
118
- addDefinition(definitions, {
119
- baseFontSize,
120
- file,
121
- source: file,
122
- token: sassToken.token,
123
- value: sassToken.value,
124
- });
100
+ for (const { scope, token, value } of collectCssTokens(source, matchesKind)) {
101
+ addDefinition(definitions, { baseFontSize, file, scope, source: file, token, value });
125
102
  }
126
103
  }
127
104
 
@@ -1,12 +1,9 @@
1
1
  'use strict';
2
2
 
3
- const {
4
- AUDIT_JSON_SCHEMA,
5
- createAuditReport,
6
- loadAuditConfig,
7
- toAuditContractReport,
8
- } = require('../cli/audit');
9
- const { parseTokenSources } = require('../utils/token-sources');
3
+ const { loadAuditConfig } = require('./config');
4
+ const { AUDIT_JSON_SCHEMA, toAuditContractReport } = require('./contract');
5
+ const { createAuditReport } = require('./report');
6
+ const { parseTokenSources } = require('../core/token-sources');
10
7
 
11
8
  module.exports = {
12
9
  AUDIT_JSON_SCHEMA,
@@ -5,7 +5,7 @@ const fs = require('node:fs');
5
5
  const {
6
6
  createTokenKindMatcher,
7
7
  parseTokenSources,
8
- } = require('../utils/token-sources');
8
+ } = require('../core/token-sources');
9
9
  const {
10
10
  applyBaselineComparison,
11
11
  writeBaseline,
@@ -19,15 +19,14 @@ const {
19
19
  const { buildReport, collectTokenDefinitions } = require('./contract');
20
20
  const { DEFAULT_SCALE, formatPath } = require('./shared');
21
21
  const {
22
- assessScale, discoverTokenPackages, scaleFromDefinitions } = require('../utils/scale-inference');
23
- const {
24
- assertDirectory,
25
- collectCssFindings,
26
- collectTailwindFindings,
27
- collectTailwindMotionFindings,
28
- getScanFiles,
29
- runStylelintAudit,
30
- } = require('./scan');
22
+ assessScale,
23
+ discoverTokenPackages,
24
+ inferScaleFromDefinitions,
25
+ scaleFromDefinitions,
26
+ } = require('../core/scale-inference');
27
+ const { assertDirectory, getScanFiles } = require('./scan/files');
28
+ const { collectCssFindings, runStylelintAudit } = require('./scan/stylesheets');
29
+ const { collectTailwindFindings, collectTailwindMotionFindings } = require('./scan/templates');
31
30
 
32
31
  async function createAuditReport(options) {
33
32
  const parsed = normalizeCreateAuditOptions(options);
@@ -149,10 +148,11 @@ function resolveAuditScale({ baseFontSize, cssFiles, requested, tokenSourceResul
149
148
  }
150
149
 
151
150
  if (definitions.size > 0) {
152
- const values = scaleFromDefinitions(definitions, baseFontSize);
153
- if (values) {
151
+ const inferred = inferScaleFromDefinitions(definitions, baseFontSize);
152
+ if (inferred) {
153
+ const { values } = inferred;
154
154
  const files = new Set();
155
- for (const definition of definitions.values()) {
155
+ for (const definition of inferred.definitions.values()) {
156
156
  for (const file of definition.files) {
157
157
  files.add(file);
158
158
  }
@@ -163,7 +163,7 @@ function resolveAuditScale({ baseFontSize, cssFiles, requested, tokenSourceResul
163
163
  return {
164
164
  files: sortedFiles,
165
165
  source: 'scanned-css',
166
- tokenCount: definitions.size,
166
+ tokenCount: inferred.definitions.size,
167
167
  values,
168
168
  };
169
169
  }
@@ -0,0 +1,262 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Which files the audit looks at: directory walking, ignore patterns (root-relative
5
+ * paths and globs), git-scoped selection (--since, --staged), and the file-type
6
+ * predicates. Nothing here parses CSS.
7
+ */
8
+ const { execFileSync } = require('node:child_process');
9
+ const fs = require('node:fs');
10
+ const path = require('node:path');
11
+ const {
12
+ SKIP_DIRS,
13
+ TEMPLATE_EXTENSIONS,
14
+ } = require('../shared');
15
+
16
+ function assertDirectory(dir) {
17
+ if (!dir) {
18
+ throw new Error('Missing audit directory.');
19
+ }
20
+
21
+ const resolvedDir = path.resolve(dir);
22
+ if (!fs.existsSync(resolvedDir)) {
23
+ throw new Error(`Directory not found: ${dir}`);
24
+ }
25
+
26
+ if (!fs.statSync(resolvedDir).isDirectory()) {
27
+ throw new Error(`Not a directory: ${dir}`);
28
+ }
29
+
30
+ return resolvedDir;
31
+ }
32
+
33
+ function walkFiles(rootDir, ignorePatterns = []) {
34
+ const cssFiles = [];
35
+ const templateFiles = [];
36
+ const ignoreMatchers = createIgnoreMatchers(ignorePatterns);
37
+
38
+ function walk(currentDir) {
39
+ for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
40
+ const fullPath = path.join(currentDir, entry.name);
41
+ const relativePath = toPosixRelativePath(rootDir, fullPath);
42
+
43
+ if (shouldIgnorePath(relativePath, entry, ignoreMatchers)) {
44
+ continue;
45
+ }
46
+
47
+ if (entry.isDirectory()) {
48
+ walk(fullPath);
49
+ continue;
50
+ }
51
+
52
+ if (entry.isFile()) {
53
+ if (isCssFile(fullPath)) {
54
+ cssFiles.push(fullPath);
55
+ } else if (isTemplateFile(fullPath)) {
56
+ templateFiles.push(fullPath);
57
+ }
58
+ }
59
+ }
60
+ }
61
+
62
+ walk(rootDir);
63
+ return { cssFiles, templateFiles };
64
+ }
65
+
66
+ function getScanFiles(rootDir, ignorePatterns, parsed) {
67
+ if (parsed.staged || parsed.since) {
68
+ return getGitChangedScanFiles(rootDir, ignorePatterns, parsed);
69
+ }
70
+
71
+ return {
72
+ ...walkFiles(rootDir, ignorePatterns),
73
+ scanScope: {
74
+ mode: 'full',
75
+ },
76
+ };
77
+ }
78
+
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}`);
94
+ }
95
+
96
+ const cssFiles = [];
97
+ const templateFiles = [];
98
+ const ignoreMatchers = createIgnoreMatchers(ignorePatterns);
99
+ 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);
106
+
107
+ if (!isPathInside(rootDir, fullPath) || seen.has(fullPath) || !fs.existsSync(fullPath)) {
108
+ continue;
109
+ }
110
+
111
+ const stat = fs.statSync(fullPath);
112
+ if (!stat.isFile()) {
113
+ continue;
114
+ }
115
+
116
+ const relativePath = toPosixRelativePath(rootDir, fullPath);
117
+ if (shouldIgnoreRelativeFile(relativePath, ignoreMatchers)) {
118
+ continue;
119
+ }
120
+
121
+ seen.add(fullPath);
122
+ if (isCssFile(fullPath)) {
123
+ cssFiles.push(fullPath);
124
+ } else if (isTemplateFile(fullPath)) {
125
+ templateFiles.push(fullPath);
126
+ }
127
+ }
128
+
129
+ return {
130
+ cssFiles,
131
+ scanScope: {
132
+ changedFiles: changedFiles.length,
133
+ mode: parsed.staged ? 'staged' : 'since',
134
+ since: parsed.since,
135
+ },
136
+ templateFiles,
137
+ };
138
+ }
139
+
140
+ function isPathInside(rootDir, filePath) {
141
+ const relativePath = path.relative(rootDir, filePath);
142
+ return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath));
143
+ }
144
+
145
+ function shouldIgnorePath(relativePath, entry, ignoreMatchers) {
146
+ return ignoreMatchers.some((matcher) => matcher.test(relativePath))
147
+ || (entry.isDirectory() && SKIP_DIRS.has(entry.name));
148
+ }
149
+
150
+ function shouldIgnoreRelativeFile(relativePath, ignoreMatchers) {
151
+ const segments = relativePath.split('/');
152
+ return segments.some((segment) => SKIP_DIRS.has(segment))
153
+ || ignoreMatchers.some((matcher) => matcher.test(relativePath));
154
+ }
155
+
156
+ function createIgnoreMatchers(patterns) {
157
+ const variants = new Set();
158
+
159
+ for (const pattern of patterns) {
160
+ addIgnorePatternVariants(variants, pattern);
161
+ }
162
+
163
+ return Array.from(variants, (pattern) => globToRegExp(pattern));
164
+ }
165
+
166
+ function addIgnorePatternVariants(variants, pattern) {
167
+ if (!pattern) {
168
+ return;
169
+ }
170
+
171
+ variants.add(pattern);
172
+
173
+ if (!pattern.includes('/')) {
174
+ variants.add(`${pattern}/**`);
175
+ variants.add(`**/${pattern}`);
176
+ variants.add(`**/${pattern}/**`);
177
+ return;
178
+ }
179
+
180
+ if (pattern.endsWith('/**')) {
181
+ variants.add(pattern.slice(0, -3));
182
+ return;
183
+ }
184
+
185
+ if (!hasGlob(pattern)) {
186
+ variants.add(`${pattern}/**`);
187
+ }
188
+ }
189
+
190
+ function hasGlob(pattern) {
191
+ return /[*?]/.test(pattern);
192
+ }
193
+
194
+ function globToRegExp(pattern) {
195
+ let source = '^';
196
+
197
+ for (let index = 0; index < pattern.length; index++) {
198
+ const char = pattern[index];
199
+ const nextChar = pattern[index + 1];
200
+
201
+ if (char === '*' && nextChar === '*') {
202
+ source += '.*';
203
+ index++;
204
+ continue;
205
+ }
206
+
207
+ if (char === '*') {
208
+ source += '[^/]*';
209
+ continue;
210
+ }
211
+
212
+ if (char === '?') {
213
+ source += '[^/]';
214
+ continue;
215
+ }
216
+
217
+ source += escapeRegExp(char);
218
+ }
219
+
220
+ return new RegExp(`${source}$`);
221
+ }
222
+
223
+ function escapeRegExp(value) {
224
+ return value.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&');
225
+ }
226
+
227
+ function toPosixRelativePath(rootDir, filePath) {
228
+ return path.relative(rootDir, filePath).split(path.sep).join('/');
229
+ }
230
+
231
+ function isScssFile(filePath) {
232
+ return filePath.endsWith('.scss');
233
+ }
234
+
235
+ // "CSS files" in the audit means authored stylesheets: .css always, .scss too;
236
+ // whether .scss can be parsed is decided when the run starts (stylesheets.js).
237
+ function isCssFile(filePath) {
238
+ return filePath.endsWith('.css') || isScssFile(filePath);
239
+ }
240
+
241
+ function isTemplateFile(filePath) {
242
+ return TEMPLATE_EXTENSIONS.has(path.extname(filePath));
243
+ }
244
+
245
+ module.exports = {
246
+ addIgnorePatternVariants,
247
+ assertDirectory,
248
+ createIgnoreMatchers,
249
+ escapeRegExp,
250
+ getGitChangedScanFiles,
251
+ getScanFiles,
252
+ globToRegExp,
253
+ hasGlob,
254
+ isCssFile,
255
+ isPathInside,
256
+ isScssFile,
257
+ isTemplateFile,
258
+ shouldIgnorePath,
259
+ shouldIgnoreRelativeFile,
260
+ toPosixRelativePath,
261
+ walkFiles,
262
+ };
@@ -0,0 +1,275 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Findings from stylesheets: run the rules through Stylelint's API, turn each
5
+ * warning into a finding, and recover the CSS property at the warning position
6
+ * from the source, since Stylelint reports positions rather than nodes.
7
+ */
8
+ const fs = require('node:fs');
9
+ const {
10
+ DEFAULT_AUDIT_TOKEN_PATTERN,
11
+ formatPath,
12
+ pluginPath,
13
+ } = require('../shared');
14
+ const { isScssFile } = require('./files');
15
+
16
+ let scssSyntaxCache;
17
+
18
+ /**
19
+ * postcss-scss is an optional peer. Resolve it from the audited project first,
20
+ * then from this package. Returns null when unavailable; SCSS files are then
21
+ * counted as skipped instead of failing the audit.
22
+ */
23
+ function resolveScssSyntax() {
24
+ if (scssSyntaxCache !== undefined) {
25
+ return scssSyntaxCache;
26
+ }
27
+ try {
28
+ scssSyntaxCache = require.resolve('postcss-scss', { paths: [process.cwd(), __dirname] });
29
+ } catch {
30
+ scssSyntaxCache = null;
31
+ }
32
+ return scssSyntaxCache;
33
+ }
34
+
35
+ async function runStylelintAudit(cssFiles, options) {
36
+ if (cssFiles.length === 0) {
37
+ const empty = [];
38
+ empty.scssFiles = 0;
39
+ empty.scssSkipped = 0;
40
+ return empty;
41
+ }
42
+
43
+ const { default: stylelint } = await import('stylelint');
44
+ const rules = {
45
+ 'rhythmguard/use-scale': [
46
+ true,
47
+ {
48
+ baseFontSize: options.baseFontSize,
49
+ scale: options.scale,
50
+ severity: 'warning',
51
+ },
52
+ ],
53
+ 'rhythmguard/prefer-token': [
54
+ true,
55
+ {
56
+ baseFontSize: options.baseFontSize,
57
+ scale: options.scale,
58
+ severity: 'warning',
59
+ tokenMapFromCssCustomProperties: true,
60
+ tokenPattern: DEFAULT_AUDIT_TOKEN_PATTERN,
61
+ },
62
+ ],
63
+ };
64
+
65
+ if (options.includeMotion) {
66
+ rules['rhythmguard/use-motion-scale'] = [
67
+ true,
68
+ {
69
+ severity: 'warning',
70
+ },
71
+ ];
72
+ }
73
+
74
+ const plainFiles = cssFiles.filter((file) => !isScssFile(file));
75
+ const scssFiles = cssFiles.filter(isScssFile);
76
+ const results = [];
77
+
78
+ if (plainFiles.length > 0) {
79
+ const result = await stylelint.lint({
80
+ files: plainFiles,
81
+ config: {
82
+ plugins: [pluginPath],
83
+ rules,
84
+ },
85
+ });
86
+ results.push(...(result.results || []));
87
+ }
88
+
89
+ let scssSkipped = 0;
90
+ if (scssFiles.length > 0) {
91
+ const scssSyntax = resolveScssSyntax();
92
+ if (scssSyntax) {
93
+ const result = await stylelint.lint({
94
+ files: scssFiles,
95
+ config: {
96
+ customSyntax: scssSyntax,
97
+ plugins: [pluginPath],
98
+ rules,
99
+ },
100
+ });
101
+ results.push(...(result.results || []));
102
+ } else {
103
+ scssSkipped = scssFiles.length;
104
+ }
105
+ }
106
+
107
+ results.scssFiles = scssFiles.length;
108
+ results.scssSkipped = scssSkipped;
109
+ return results;
110
+ }
111
+
112
+ function collectCssFindings(fileResults) {
113
+ const findings = [];
114
+ const sources = new Map();
115
+
116
+ for (const fileResult of fileResults) {
117
+ for (const warning of fileResult.warnings || []) {
118
+ const text = warning.text || '';
119
+ const source = readSourceOnce(sources, fileResult.source);
120
+ const offScaleMatch = text.match(
121
+ /Unexpected (?:off-scale value|transform translation value) "([^"]+)"/,
122
+ );
123
+ const tokenMatch = text.match(
124
+ /Unexpected raw scale value "([^"]+)"/,
125
+ );
126
+ const motionDurationMatch = text.match(
127
+ /Unexpected (?:motion duration|negative motion duration) "([^"]+)"/,
128
+ );
129
+ const motionEasingMatch = text.match(
130
+ /Unexpected raw motion easing "([^"]+)"/,
131
+ );
132
+
133
+ findings.push({
134
+ column: warning.column || 1,
135
+ file: formatPath(fileResult.source),
136
+ line: warning.line || 1,
137
+ property: source === null
138
+ ? null
139
+ : findDeclarationProperty(source, warning.line || 1, warning.column || 1),
140
+ rule: warning.rule || 'rhythmguard',
141
+ text,
142
+ type: getCssFindingType({ motionDurationMatch, motionEasingMatch, tokenMatch }),
143
+ value: getCssFindingValue({
144
+ motionDurationMatch,
145
+ motionEasingMatch,
146
+ offScaleMatch,
147
+ tokenMatch,
148
+ }),
149
+ });
150
+ }
151
+ }
152
+
153
+ return findings;
154
+ }
155
+
156
+ function readSourceOnce(cache, filePath) {
157
+ if (!filePath) {
158
+ return null;
159
+ }
160
+ if (!cache.has(filePath)) {
161
+ try {
162
+ cache.set(filePath, fs.readFileSync(filePath, 'utf8'));
163
+ } catch {
164
+ cache.set(filePath, null);
165
+ }
166
+ }
167
+ return cache.get(filePath);
168
+ }
169
+
170
+ const DECLARATION_BOUNDARY = new Set([';', '{', '}']);
171
+
172
+ const DECLARATION_HEAD_PATTERN = /^\s*(?:(?:\/\*[\s\S]*?\*\/|\/\/[^\n]*)\s*)*(--[\w-]+|[a-zA-Z][\w-]*)\s*:/;
173
+
174
+ /**
175
+ * Stylelint warnings carry a position but not the declaration node. Recover the
176
+ * property by walking from the warning position back to the previous declaration
177
+ * boundary and reading the `property:` head, skipping block and Sass line comments.
178
+ * Sass interpolation is blanked first so `#{...}` braces do not act as boundaries. Returns null when the position is not
179
+ * inside a declaration, for example inside an at-rule.
180
+ */
181
+
182
+ function findDeclarationProperty(source, line, column) {
183
+ const offset = positionToOffset(source, line, column);
184
+ if (offset === null) {
185
+ return null;
186
+ }
187
+
188
+ const text = source.replace(/#\{[^}]*\}/g, (match) => ' '.repeat(match.length));
189
+ let start = offset;
190
+ while (start > 0 && !DECLARATION_BOUNDARY.has(text[start - 1])) {
191
+ start -= 1;
192
+ }
193
+ const backward = text.slice(start, offset).match(DECLARATION_HEAD_PATTERN);
194
+ if (backward) {
195
+ return backward[1];
196
+ }
197
+
198
+ let end = offset;
199
+ while (end < text.length && !DECLARATION_BOUNDARY.has(text[end])) {
200
+ end += 1;
201
+ }
202
+ const forward = text.slice(offset, end).match(DECLARATION_HEAD_PATTERN);
203
+ return forward ? forward[1] : null;
204
+ }
205
+
206
+ function positionToOffset(source, line, column) {
207
+ if (!Number.isInteger(line) || line < 1) {
208
+ return null;
209
+ }
210
+ let offset = 0;
211
+ let currentLine = 1;
212
+ while (currentLine < line) {
213
+ const newline = source.indexOf('\n', offset);
214
+ if (newline === -1) {
215
+ return null;
216
+ }
217
+ offset = newline + 1;
218
+ currentLine += 1;
219
+ }
220
+ const lineEnd = source.indexOf('\n', offset);
221
+ const lineLength = (lineEnd === -1 ? source.length : lineEnd) - offset;
222
+ return offset + Math.min(Math.max((column || 1) - 1, 0), lineLength);
223
+ }
224
+
225
+ function getCssFindingValue({
226
+ motionDurationMatch,
227
+ motionEasingMatch,
228
+ offScaleMatch,
229
+ tokenMatch,
230
+ }) {
231
+ if (tokenMatch) {
232
+ return tokenMatch[1];
233
+ }
234
+
235
+ if (offScaleMatch) {
236
+ return offScaleMatch[1];
237
+ }
238
+
239
+ if (motionDurationMatch) {
240
+ return motionDurationMatch[1];
241
+ }
242
+
243
+ if (motionEasingMatch) {
244
+ return motionEasingMatch[1];
245
+ }
246
+
247
+ return null;
248
+ }
249
+
250
+ function getCssFindingType({ motionDurationMatch, motionEasingMatch, tokenMatch }) {
251
+ if (motionDurationMatch) {
252
+ return 'motion-duration';
253
+ }
254
+
255
+ if (motionEasingMatch) {
256
+ return 'motion-easing';
257
+ }
258
+
259
+ if (tokenMatch) {
260
+ return 'token-opportunity';
261
+ }
262
+
263
+ return 'off-scale';
264
+ }
265
+
266
+ module.exports = {
267
+ collectCssFindings,
268
+ findDeclarationProperty,
269
+ getCssFindingType,
270
+ getCssFindingValue,
271
+ positionToOffset,
272
+ readSourceOnce,
273
+ resolveScssSyntax,
274
+ runStylelintAudit,
275
+ };