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