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