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.
- package/CHANGELOG.md +77 -0
- package/README.md +5 -1
- package/package.json +1 -1
- package/src/audit/args.js +22 -2
- package/src/audit/baseline.js +184 -27
- package/src/audit/codemod.js +39 -14
- package/src/audit/config.js +25 -31
- package/src/audit/contract.js +96 -10
- package/src/audit/render-github.js +4 -0
- package/src/audit/render-html.js +7 -0
- package/src/audit/render-markdown.js +27 -0
- package/src/audit/render-text.js +23 -1
- package/src/audit/report.js +60 -17
- package/src/audit/scan/files.js +154 -27
- package/src/audit/scan/stylesheets.js +87 -34
- package/src/audit/scan/templates.js +22 -7
- package/src/audit/token-chains.js +18 -7
- package/src/cli/audit.js +7 -3
- package/src/cli/doctor.js +48 -7
- package/src/cli/fix.js +56 -15
- package/src/cli/init.js +136 -23
- package/src/cli/quickstart.js +34 -8
- package/src/core/fs-cache.js +6 -0
- package/src/core/length.js +15 -0
- package/src/core/options.js +8 -1
- package/src/core/scale-inference.js +99 -68
- package/src/core/tailwind-class-analysis.js +26 -16
- package/src/core/tailwind-motion-analysis.js +12 -3
- package/src/core/token-index.js +76 -24
- package/src/core/token-map.js +23 -10
- package/src/core/token-source-entries.js +64 -0
- package/src/core/token-sources.js +361 -69
- package/src/core/value-nodes.js +280 -54
- package/src/eslint/literal-spans.js +319 -0
- package/src/eslint/rules/tailwind-class-use-motion-scale.js +9 -32
- package/src/eslint/rules/tailwind-class-use-scale.js +45 -40
- package/src/rules/no-offscale-transform/index.js +35 -14
- package/src/rules/prefer-token/index.js +72 -48
- package/src/rules/report.js +3 -1
- package/src/rules/use-motion-scale/index.js +68 -5
- package/src/rules/use-scale/index.js +54 -39
- package/types/__checks__/eslint-config.ts +37 -0
- package/types/audit.d.ts +10 -2
- package/types/shared.d.ts +21 -3
package/src/audit/report.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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 =
|
|
224
|
+
text = readSource(filePath);
|
|
182
225
|
} catch {
|
|
183
226
|
continue;
|
|
184
227
|
}
|
package/src/audit/scan/files.js
CHANGED
|
@@ -77,39 +77,39 @@ function getScanFiles(rootDir, ignorePatterns, parsed) {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
function getGitChangedScanFiles(rootDir, ignorePatterns, parsed) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
134
|
+
const result = {
|
|
130
135
|
cssFiles,
|
|
131
136
|
scanScope: {
|
|
132
|
-
changedFiles:
|
|
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
|
|
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
|
-
|
|
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
|
|
95
|
+
let skippedScssFiles = [];
|
|
98
96
|
if (scssFiles.length > 0) {
|
|
99
97
|
const scssSyntax = resolveScssSyntax();
|
|
100
98
|
if (scssSyntax) {
|
|
101
|
-
|
|
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
|
-
|
|
101
|
+
skippedScssFiles = scssFiles;
|
|
113
102
|
}
|
|
114
103
|
}
|
|
115
104
|
|
|
116
|
-
results
|
|
117
|
-
|
|
105
|
+
assertNoInvalidOptions(results);
|
|
106
|
+
|
|
118
107
|
// Stylelint returns no result at all for a file its ignore file excludes, so
|
|
119
|
-
//
|
|
120
|
-
const
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
129
|
-
|
|
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
|
|
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) {
|