stylelint-plugin-rhythmguard 2.0.1 → 2.2.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 +42 -0
- package/CONTRIBUTING.md +24 -5
- package/README.md +45 -768
- package/package.json +15 -6
- package/src/audit/args.js +449 -0
- package/src/audit/baseline.js +114 -0
- package/src/audit/config.js +217 -0
- package/src/audit/contract.js +422 -0
- package/src/audit/render-github.js +39 -0
- package/src/audit/render-html.js +78 -0
- package/src/audit/render-markdown.js +227 -0
- package/src/audit/render-text.js +168 -0
- package/src/audit/render-utils.js +41 -0
- package/src/audit/report.js +189 -0
- package/src/audit/scan.js +546 -0
- package/src/audit/shared.js +108 -0
- package/src/cli/audit.js +22 -2213
- package/src/cli/index.js +11 -5
- package/src/cli/init.js +5 -1
- package/src/cli/quickstart.js +203 -0
- package/src/configs/embed.js +24 -0
- package/src/configs/embed.mjs +4 -0
- package/src/eslint/rules/tailwind-class-use-motion-scale.js +1 -0
- package/src/eslint/rules/tailwind-class-use-scale.js +1 -0
- package/src/index.js +2 -0
- package/src/rules/no-offscale-transform/index.js +23 -1
- package/src/rules/prefer-token/index.js +29 -1
- package/src/rules/use-motion-scale/index.js +1 -1
- package/src/rules/use-scale/index.js +35 -5
- package/src/utils/length.js +16 -0
- package/src/utils/options.js +53 -3
- package/src/utils/scale-inference.js +255 -0
- package/src/utils/token-map.js +4 -2
- package/src/utils/token-sources.js +32 -2
- package/types/__checks__/consumer.ts +80 -0
- package/types/audit.d.ts +25 -4
- package/types/index.d.ts +5 -0
- package/types/shared.d.ts +22 -1
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const {
|
|
6
|
+
normalizeTokenKind,
|
|
7
|
+
normalizeTokenSourceFormat,
|
|
8
|
+
} = require('../utils/token-sources');
|
|
9
|
+
const {
|
|
10
|
+
parseBaseFontSize,
|
|
11
|
+
parseNonNegativeInteger,
|
|
12
|
+
parsePathOption,
|
|
13
|
+
parsePercentage,
|
|
14
|
+
parsePositiveInteger,
|
|
15
|
+
parseScale,
|
|
16
|
+
} = require('./args');
|
|
17
|
+
const {
|
|
18
|
+
DEFAULT_IGNORE_PATH,
|
|
19
|
+
createDefaultAuditOptions,
|
|
20
|
+
formatPath,
|
|
21
|
+
normalizeIgnorePattern,
|
|
22
|
+
} = require('./shared');
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
function loadAuditConfig(parsed) {
|
|
26
|
+
parsed = {
|
|
27
|
+
...createDefaultAuditOptions(),
|
|
28
|
+
...parsed,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
if (parsed.noConfig) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const resolvedPath = path.resolve(process.cwd(), parsed.configPath);
|
|
36
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
37
|
+
if (parsed.configExplicit) {
|
|
38
|
+
throw new Error(`Config file not found: ${parsed.configPath}`);
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let config;
|
|
44
|
+
try {
|
|
45
|
+
config = JSON.parse(fs.readFileSync(resolvedPath, 'utf8'));
|
|
46
|
+
} catch (err) {
|
|
47
|
+
throw new Error(`Invalid Rhythmguard config ${parsed.configPath}: ${err.message}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
51
|
+
throw new Error(`Invalid Rhythmguard config ${parsed.configPath}: expected an object.`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const audit = config.audit || {};
|
|
55
|
+
if (!audit || typeof audit !== 'object' || Array.isArray(audit)) {
|
|
56
|
+
throw new Error(`Invalid Rhythmguard config ${parsed.configPath}: "audit" must be an object.`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
audit,
|
|
61
|
+
file: formatPath(resolvedPath),
|
|
62
|
+
rootDir: path.dirname(resolvedPath),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function applyAuditConfig(parsed, configResult) {
|
|
67
|
+
const cliOptions = parsed.cliOptions;
|
|
68
|
+
const next = {
|
|
69
|
+
...parsed,
|
|
70
|
+
config: configResult
|
|
71
|
+
? {
|
|
72
|
+
file: configResult.file,
|
|
73
|
+
}
|
|
74
|
+
: null,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
if (!configResult) {
|
|
78
|
+
next.tokenSources = normalizeCliTokenSources(parsed.tokenSources, parsed.tokenSourceFormat);
|
|
79
|
+
delete next.cliOptions;
|
|
80
|
+
return next;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const audit = configResult.audit;
|
|
84
|
+
|
|
85
|
+
if (Array.isArray(audit.ignore)) {
|
|
86
|
+
next.ignorePatterns = [
|
|
87
|
+
...audit.ignore.map((pattern) => normalizeIgnorePattern(pattern)).filter(Boolean),
|
|
88
|
+
...parsed.ignorePatterns,
|
|
89
|
+
];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const configuredTokenSources = normalizeConfigTokenSources(
|
|
93
|
+
audit.tokenSources,
|
|
94
|
+
configResult.rootDir,
|
|
95
|
+
);
|
|
96
|
+
next.tokenSources = [
|
|
97
|
+
...configuredTokenSources,
|
|
98
|
+
...normalizeCliTokenSources(parsed.tokenSources, parsed.tokenSourceFormat),
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
applyConfigScalar(next, audit, cliOptions, 'ignorePath', 'ignorePath', parsePathOption);
|
|
102
|
+
applyConfigScalar(next, audit, cliOptions, 'baselinePath', 'baseline', parsePathOption);
|
|
103
|
+
applyConfigScalar(next, audit, cliOptions, 'maxFindings', 'maxFindings', parseNonNegativeInteger);
|
|
104
|
+
applyConfigScalar(next, audit, cliOptions, 'minCleanliness', 'minCleanliness', parsePercentage);
|
|
105
|
+
applyConfigScalar(next, audit, cliOptions, 'tokenCandidateMinCount', 'tokenCandidateMinCount', parsePositiveInteger);
|
|
106
|
+
applyConfigScalar(next, audit, cliOptions, 'tokenKind', 'tokenKind', normalizeTokenKind);
|
|
107
|
+
applyConfigScalar(next, audit, cliOptions, 'baseFontSize', 'baseFontSize', parseBaseFontSize);
|
|
108
|
+
applyConfigScalar(next, audit, cliOptions, 'includeMotion', 'includeMotion', parseBooleanOption);
|
|
109
|
+
applyConfigScalar(next, audit, cliOptions, 'scale', 'scale', (value) => {
|
|
110
|
+
if (Array.isArray(value)) {
|
|
111
|
+
return value;
|
|
112
|
+
}
|
|
113
|
+
return parseScale(String(value));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
delete next.cliOptions;
|
|
117
|
+
return next;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function applyConfigScalar(target, audit, cliOptions, targetKey, configKey, parser) {
|
|
121
|
+
if (cliOptions.has(targetKey) || audit[configKey] === undefined) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
target[targetKey] = parser(audit[configKey], configKey);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parseBooleanOption(value, optionName) {
|
|
129
|
+
if (typeof value !== 'boolean') {
|
|
130
|
+
throw new Error(`${optionName} must be a boolean.`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return value;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function normalizeCliTokenSources(sources, format) {
|
|
137
|
+
return sources.map((source) => {
|
|
138
|
+
// The programmatic API may pass typed { path | file, format?, baseDir? } entries.
|
|
139
|
+
if (source && typeof source === 'object') {
|
|
140
|
+
return {
|
|
141
|
+
baseDir: source.baseDir || process.cwd(),
|
|
142
|
+
format: source.format || format,
|
|
143
|
+
path: source.path || source.file,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
baseDir: process.cwd(),
|
|
148
|
+
format,
|
|
149
|
+
path: source,
|
|
150
|
+
};
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function normalizeConfigTokenSources(sources, baseDir) {
|
|
155
|
+
if (sources === undefined) {
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!Array.isArray(sources)) {
|
|
160
|
+
throw new Error('Invalid Rhythmguard config: audit.tokenSources must be an array.');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return sources.map((source) => {
|
|
164
|
+
if (typeof source === 'string') {
|
|
165
|
+
return {
|
|
166
|
+
baseDir,
|
|
167
|
+
format: 'auto',
|
|
168
|
+
path: source,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!source || typeof source !== 'object' || Array.isArray(source)) {
|
|
173
|
+
throw new Error('Invalid Rhythmguard config: audit.tokenSources entries must be strings or objects.');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (typeof source.path !== 'string' || source.path.trim().length === 0) {
|
|
177
|
+
throw new Error('Invalid Rhythmguard config: token source objects must include a path.');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
baseDir,
|
|
182
|
+
format: normalizeTokenSourceFormat(source.format || 'auto'),
|
|
183
|
+
path: source.path,
|
|
184
|
+
};
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function loadIgnorePatterns(ignorePath) {
|
|
189
|
+
if (!ignorePath) {
|
|
190
|
+
return [];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const resolvedPath = path.resolve(process.cwd(), ignorePath);
|
|
194
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
195
|
+
if (ignorePath === DEFAULT_IGNORE_PATH) {
|
|
196
|
+
return [];
|
|
197
|
+
}
|
|
198
|
+
throw new Error(`Ignore file not found: ${ignorePath}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return fs.readFileSync(resolvedPath, 'utf8')
|
|
202
|
+
.split(/\r?\n/)
|
|
203
|
+
.map((line) => line.trim())
|
|
204
|
+
.filter((line) => line && !line.startsWith('#'))
|
|
205
|
+
.map((line) => normalizeIgnorePattern(line))
|
|
206
|
+
.filter(Boolean);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
module.exports = {
|
|
210
|
+
applyAuditConfig,
|
|
211
|
+
applyConfigScalar,
|
|
212
|
+
loadAuditConfig,
|
|
213
|
+
loadIgnorePatterns,
|
|
214
|
+
normalizeCliTokenSources,
|
|
215
|
+
normalizeConfigTokenSources,
|
|
216
|
+
parseBooleanOption,
|
|
217
|
+
};
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const {
|
|
5
|
+
addDefinition,
|
|
6
|
+
createTokenKindMatcher,
|
|
7
|
+
getNormalizedValueKeys,
|
|
8
|
+
} = require('../utils/token-sources');
|
|
9
|
+
const { formatPath } = require('./shared');
|
|
10
|
+
|
|
11
|
+
function collectTokenContract({
|
|
12
|
+
baseFontSize,
|
|
13
|
+
cssFiles,
|
|
14
|
+
cssFindings,
|
|
15
|
+
externalTokenDefinitions,
|
|
16
|
+
minCandidateCount,
|
|
17
|
+
tailwindFindings,
|
|
18
|
+
tokenKind,
|
|
19
|
+
tokenSourceReports,
|
|
20
|
+
}) {
|
|
21
|
+
const definitions = new Map();
|
|
22
|
+
const uses = new Map();
|
|
23
|
+
const rawValues = new Map();
|
|
24
|
+
const rawValueLocations = new Set();
|
|
25
|
+
const matchesKind = createTokenKindMatcher(tokenKind);
|
|
26
|
+
|
|
27
|
+
for (const filePath of cssFiles) {
|
|
28
|
+
let source = '';
|
|
29
|
+
try {
|
|
30
|
+
source = fs.readFileSync(filePath, 'utf8');
|
|
31
|
+
} catch {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const file = formatPath(filePath);
|
|
36
|
+
collectTokenDefinitions(source, file, definitions, matchesKind, baseFontSize);
|
|
37
|
+
collectTokenUses(source, file, uses, matchesKind);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (const entry of externalTokenDefinitions.values()) {
|
|
41
|
+
for (const value of entry.values || []) {
|
|
42
|
+
addDefinition(definitions, {
|
|
43
|
+
baseFontSize,
|
|
44
|
+
file: Array.from(entry.files)[0] || 'external-token-source',
|
|
45
|
+
source: Array.from(entry.sources)[0] || Array.from(entry.files)[0] || 'external-token-source',
|
|
46
|
+
token: entry.token,
|
|
47
|
+
value,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const finding of cssFindings) {
|
|
53
|
+
addRawValue(rawValues, rawValueLocations, finding.value, finding);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const finding of tailwindFindings) {
|
|
57
|
+
addRawValue(rawValues, rawValueLocations, finding.rawValue, finding);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const definedTokens = mapTokenEntries(definitions);
|
|
61
|
+
const usedTokens = mapTokenEntries(uses);
|
|
62
|
+
const missingTokens = usedTokens.filter(({ token }) => !definitions.has(token));
|
|
63
|
+
const unusedTokens = definedTokens.filter(({ token }) => !uses.has(token));
|
|
64
|
+
const rawValueMatches = collectRawValueMatches(rawValues, definitions, baseFontSize);
|
|
65
|
+
const conflictingTokens = collectConflictingTokens(definitions);
|
|
66
|
+
const rawValueCandidates = Array.from(rawValues.entries())
|
|
67
|
+
.map(([value, entry]) => ({
|
|
68
|
+
count: entry.count,
|
|
69
|
+
files: Array.from(entry.files).sort(),
|
|
70
|
+
value,
|
|
71
|
+
}))
|
|
72
|
+
.filter(({ count }) => count >= minCandidateCount)
|
|
73
|
+
.sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
definedTokens,
|
|
77
|
+
conflictingTokens,
|
|
78
|
+
missingTokens,
|
|
79
|
+
rawValueMatches,
|
|
80
|
+
rawValueCandidates,
|
|
81
|
+
sources: tokenSourceReports,
|
|
82
|
+
summary: {
|
|
83
|
+
conflictingTokens: conflictingTokens.length,
|
|
84
|
+
definedTokens: definedTokens.length,
|
|
85
|
+
missingTokens: missingTokens.length,
|
|
86
|
+
rawValueMatches: rawValueMatches.length,
|
|
87
|
+
rawValueCandidates: rawValueCandidates.length,
|
|
88
|
+
tokenSources: tokenSourceReports.length,
|
|
89
|
+
unusedTokens: unusedTokens.length,
|
|
90
|
+
usedTokens: usedTokens.length,
|
|
91
|
+
},
|
|
92
|
+
unusedTokens,
|
|
93
|
+
usedTokens,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function collectTokenDefinitions(source, file, definitions, matchesKind, baseFontSize) {
|
|
98
|
+
const declarationPattern = /(--[\w-]+)\s*:\s*([^;{}]+)/g;
|
|
99
|
+
let match;
|
|
100
|
+
|
|
101
|
+
while ((match = declarationPattern.exec(source)) !== null) {
|
|
102
|
+
const token = match[1];
|
|
103
|
+
if (!matchesKind(token)) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
addDefinition(definitions, {
|
|
108
|
+
baseFontSize,
|
|
109
|
+
file,
|
|
110
|
+
source: file,
|
|
111
|
+
token,
|
|
112
|
+
value: match[2].trim(),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function collectTokenUses(source, file, uses, matchesKind) {
|
|
118
|
+
const varPattern = /var\(\s*(--[\w-]+)/g;
|
|
119
|
+
let match;
|
|
120
|
+
|
|
121
|
+
while ((match = varPattern.exec(source)) !== null) {
|
|
122
|
+
const token = match[1];
|
|
123
|
+
if (!matchesKind(token)) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const entry = uses.get(token) || {
|
|
128
|
+
files: new Set(),
|
|
129
|
+
token,
|
|
130
|
+
};
|
|
131
|
+
entry.files.add(file);
|
|
132
|
+
uses.set(token, entry);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function addRawValue(rawValues, rawValueLocations, value, finding) {
|
|
137
|
+
if (!value) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const locationKey = [
|
|
142
|
+
finding.file || '',
|
|
143
|
+
finding.line || '',
|
|
144
|
+
finding.column || '',
|
|
145
|
+
value,
|
|
146
|
+
].join('\u001f');
|
|
147
|
+
if (rawValueLocations.has(locationKey)) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
rawValueLocations.add(locationKey);
|
|
151
|
+
|
|
152
|
+
const entry = rawValues.get(value) || {
|
|
153
|
+
count: 0,
|
|
154
|
+
files: new Set(),
|
|
155
|
+
};
|
|
156
|
+
entry.count += 1;
|
|
157
|
+
if (finding.file) {
|
|
158
|
+
entry.files.add(finding.file);
|
|
159
|
+
}
|
|
160
|
+
rawValues.set(value, entry);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function mapTokenEntries(entries) {
|
|
164
|
+
return Array.from(entries.values())
|
|
165
|
+
.map((entry) => ({
|
|
166
|
+
files: Array.from(entry.files).sort(),
|
|
167
|
+
normalizedValues: entry.normalizedValues
|
|
168
|
+
? Array.from(entry.normalizedValues).sort()
|
|
169
|
+
: undefined,
|
|
170
|
+
sources: entry.sources ? Array.from(entry.sources).sort() : undefined,
|
|
171
|
+
token: entry.token,
|
|
172
|
+
values: entry.values ? Array.from(entry.values).sort() : undefined,
|
|
173
|
+
}))
|
|
174
|
+
.sort((a, b) => a.token.localeCompare(b.token));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function collectRawValueMatches(rawValues, definitions, baseFontSize) {
|
|
178
|
+
const valueToTokens = createValueToTokensMap(definitions);
|
|
179
|
+
const matches = [];
|
|
180
|
+
|
|
181
|
+
for (const [value, entry] of rawValues.entries()) {
|
|
182
|
+
const tokens = new Set();
|
|
183
|
+
for (const key of getNormalizedValueKeys(value, baseFontSize)) {
|
|
184
|
+
for (const token of valueToTokens.get(key) || []) {
|
|
185
|
+
tokens.add(token);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (tokens.size === 0) {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
matches.push({
|
|
194
|
+
count: entry.count,
|
|
195
|
+
files: Array.from(entry.files).sort(),
|
|
196
|
+
tokens: Array.from(tokens).sort(),
|
|
197
|
+
value,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return matches.sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function collectConflictingTokens(definitions) {
|
|
205
|
+
const valueToTokens = createValueToTokensMap(definitions);
|
|
206
|
+
const conflicts = [];
|
|
207
|
+
|
|
208
|
+
for (const [value, tokens] of valueToTokens.entries()) {
|
|
209
|
+
const uniqueTokens = Array.from(tokens).sort();
|
|
210
|
+
if (uniqueTokens.length < 2) {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
conflicts.push({
|
|
215
|
+
tokens: uniqueTokens,
|
|
216
|
+
value,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return conflicts.sort((a, b) => a.value.localeCompare(b.value));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function createValueToTokensMap(definitions) {
|
|
224
|
+
const valueToTokens = new Map();
|
|
225
|
+
|
|
226
|
+
for (const entry of definitions.values()) {
|
|
227
|
+
for (const value of entry.normalizedValues || []) {
|
|
228
|
+
const tokens = valueToTokens.get(value) || new Set();
|
|
229
|
+
tokens.add(entry.token);
|
|
230
|
+
valueToTokens.set(value, tokens);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return valueToTokens;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function buildReport({
|
|
238
|
+
baseFontSize,
|
|
239
|
+
config,
|
|
240
|
+
cssFiles,
|
|
241
|
+
cssFindings,
|
|
242
|
+
dir,
|
|
243
|
+
externalTokenDefinitions,
|
|
244
|
+
includeMotion,
|
|
245
|
+
motionFindings,
|
|
246
|
+
scale,
|
|
247
|
+
scanScope,
|
|
248
|
+
templateFiles,
|
|
249
|
+
tailwindFindings,
|
|
250
|
+
tokenCandidateMinCount,
|
|
251
|
+
tokenKind,
|
|
252
|
+
tokenSourceReports,
|
|
253
|
+
tokenSourceWarnings,
|
|
254
|
+
}) {
|
|
255
|
+
const offScaleValues = countByValue(cssFindings
|
|
256
|
+
.filter((finding) => finding.type === 'off-scale' && finding.value)
|
|
257
|
+
.map((finding) => finding.value));
|
|
258
|
+
const tokenOpportunities = countByValue(cssFindings
|
|
259
|
+
.filter((finding) => finding.type === 'token-opportunity' && finding.value)
|
|
260
|
+
.map((finding) => finding.value));
|
|
261
|
+
const tailwindArbitraryValues = countByValue(tailwindFindings
|
|
262
|
+
.map((finding) => finding.rawValue));
|
|
263
|
+
const motionValues = countByValue(motionFindings
|
|
264
|
+
.map((finding) => finding.value || finding.rawValue));
|
|
265
|
+
const issueFiles = new Set([
|
|
266
|
+
...cssFindings.map((finding) => finding.file),
|
|
267
|
+
...motionFindings.map((finding) => finding.file),
|
|
268
|
+
...tailwindFindings.map((finding) => finding.file),
|
|
269
|
+
]);
|
|
270
|
+
const topAffectedFiles = sortCountMap(countByValue([
|
|
271
|
+
...cssFindings.map((finding) => finding.file),
|
|
272
|
+
...motionFindings.map((finding) => finding.file),
|
|
273
|
+
...tailwindFindings.map((finding) => finding.file),
|
|
274
|
+
])).slice(0, 10);
|
|
275
|
+
|
|
276
|
+
const totalFiles = cssFiles.length + templateFiles.length;
|
|
277
|
+
const totalWarnings = cssFindings.length + motionFindings.length + tailwindFindings.length;
|
|
278
|
+
const filesWithIssues = issueFiles.size;
|
|
279
|
+
const scaleCleanliness = totalFiles > 0
|
|
280
|
+
? Math.max(0, Math.round(((totalFiles - filesWithIssues) / totalFiles) * 100))
|
|
281
|
+
: 100;
|
|
282
|
+
const tokenContract = collectTokenContract({
|
|
283
|
+
baseFontSize,
|
|
284
|
+
cssFiles,
|
|
285
|
+
cssFindings,
|
|
286
|
+
externalTokenDefinitions,
|
|
287
|
+
minCandidateCount: tokenCandidateMinCount,
|
|
288
|
+
tailwindFindings,
|
|
289
|
+
tokenKind,
|
|
290
|
+
tokenSourceReports,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
config,
|
|
295
|
+
cssFilesScanned: cssFiles.length,
|
|
296
|
+
directory: dir,
|
|
297
|
+
filesWithIssues,
|
|
298
|
+
findings: {
|
|
299
|
+
css: cssFindings,
|
|
300
|
+
motion: motionFindings,
|
|
301
|
+
tailwind: tailwindFindings,
|
|
302
|
+
},
|
|
303
|
+
formatVersion: 5,
|
|
304
|
+
motion: {
|
|
305
|
+
enabled: includeMotion,
|
|
306
|
+
findings: motionFindings.length,
|
|
307
|
+
values: Object.fromEntries(sortCountMap(motionValues).slice(0, 10)),
|
|
308
|
+
},
|
|
309
|
+
offScaleValues: Object.fromEntries(sortCountMap(offScaleValues).slice(0, 10)),
|
|
310
|
+
scale: scale || null,
|
|
311
|
+
scaleCleanliness,
|
|
312
|
+
scanScope,
|
|
313
|
+
scanned: {
|
|
314
|
+
cssFiles: cssFiles.length,
|
|
315
|
+
templateFiles: templateFiles.length,
|
|
316
|
+
totalFiles,
|
|
317
|
+
},
|
|
318
|
+
summary: {
|
|
319
|
+
cssWarnings: cssFindings.length,
|
|
320
|
+
filesWithIssues,
|
|
321
|
+
motionFindings: motionFindings.length,
|
|
322
|
+
rawValueMatches: tokenContract.summary.rawValueMatches,
|
|
323
|
+
missingTokens: tokenContract.summary.missingTokens,
|
|
324
|
+
rawValueCandidates: tokenContract.summary.rawValueCandidates,
|
|
325
|
+
scaleCleanliness,
|
|
326
|
+
tailwindArbitrarySpacing: tailwindFindings.length,
|
|
327
|
+
tokenOpportunities: sumCounts(tokenOpportunities),
|
|
328
|
+
tokenSources: tokenContract.summary.tokenSources,
|
|
329
|
+
totalFindings: totalWarnings,
|
|
330
|
+
unusedTokens: tokenContract.summary.unusedTokens,
|
|
331
|
+
},
|
|
332
|
+
tailwindArbitraryValues: Object.fromEntries(sortCountMap(tailwindArbitraryValues).slice(0, 10)),
|
|
333
|
+
templateFilesScanned: templateFiles.length,
|
|
334
|
+
tokenContract,
|
|
335
|
+
tokenSourceWarnings,
|
|
336
|
+
tokenOpportunities: Object.fromEntries(sortCountMap(tokenOpportunities).slice(0, 10)),
|
|
337
|
+
topAffectedFiles: topAffectedFiles.map(([file, count]) => ({ count, file })),
|
|
338
|
+
totalFiles,
|
|
339
|
+
totalWarnings,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function countByValue(values) {
|
|
344
|
+
const counts = {};
|
|
345
|
+
|
|
346
|
+
for (const value of values) {
|
|
347
|
+
if (value) {
|
|
348
|
+
counts[value] = (counts[value] || 0) + 1;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return counts;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function sortCountMap(counts) {
|
|
356
|
+
return Object.entries(counts).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function sumCounts(counts) {
|
|
360
|
+
return Object.values(counts).reduce((total, count) => total + count, 0);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function toAuditContractReport(report) {
|
|
364
|
+
return {
|
|
365
|
+
baseline: report.baseline || null,
|
|
366
|
+
command: {
|
|
367
|
+
config: report.config,
|
|
368
|
+
directory: report.directory,
|
|
369
|
+
scanScope: report.scanScope.mode,
|
|
370
|
+
},
|
|
371
|
+
contracts: {
|
|
372
|
+
motion: report.motion,
|
|
373
|
+
scale: {
|
|
374
|
+
cleanliness: report.scaleCleanliness,
|
|
375
|
+
files: report.scale ? report.scale.files : [],
|
|
376
|
+
offScaleValues: report.offScaleValues,
|
|
377
|
+
source: report.scale ? report.scale.source : 'default',
|
|
378
|
+
tokenOpportunities: report.tokenOpportunities,
|
|
379
|
+
values: report.scale ? report.scale.values : null,
|
|
380
|
+
},
|
|
381
|
+
tokens: report.tokenContract,
|
|
382
|
+
},
|
|
383
|
+
findings: report.findings,
|
|
384
|
+
scanned: report.scanned,
|
|
385
|
+
schemaVersion: '2.0',
|
|
386
|
+
summary: report.summary,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const AUDIT_JSON_SCHEMA = Object.freeze({
|
|
391
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
392
|
+
additionalProperties: true,
|
|
393
|
+
properties: {
|
|
394
|
+
baseline: { type: ['object', 'null'] },
|
|
395
|
+
command: { type: 'object' },
|
|
396
|
+
contracts: { type: 'object' },
|
|
397
|
+
findings: { type: 'object' },
|
|
398
|
+
scanned: { type: 'object' },
|
|
399
|
+
schemaVersion: { const: '2.0' },
|
|
400
|
+
summary: { type: 'object' },
|
|
401
|
+
},
|
|
402
|
+
required: ['schemaVersion', 'command', 'summary', 'scanned', 'contracts', 'findings'],
|
|
403
|
+
title: 'Rhythmguard Audit Report',
|
|
404
|
+
type: 'object',
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
module.exports = {
|
|
408
|
+
AUDIT_JSON_SCHEMA,
|
|
409
|
+
addRawValue,
|
|
410
|
+
buildReport,
|
|
411
|
+
collectConflictingTokens,
|
|
412
|
+
collectRawValueMatches,
|
|
413
|
+
collectTokenContract,
|
|
414
|
+
collectTokenDefinitions,
|
|
415
|
+
collectTokenUses,
|
|
416
|
+
countByValue,
|
|
417
|
+
createValueToTokensMap,
|
|
418
|
+
mapTokenEntries,
|
|
419
|
+
sortCountMap,
|
|
420
|
+
sumCounts,
|
|
421
|
+
toAuditContractReport,
|
|
422
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { getAllFindings } = require('./baseline');
|
|
4
|
+
|
|
5
|
+
function renderGithub(report) {
|
|
6
|
+
const findings = getAllFindings(report);
|
|
7
|
+
const lines = findings.map((finding) =>
|
|
8
|
+
`::warning file=${escapeGithubProperty(finding.file)},line=${finding.line || 1},col=${finding.column || 1},title=${escapeGithubProperty(finding.rule || 'rhythmguard')}::${escapeGithubData(finding.text || '')}`,
|
|
9
|
+
);
|
|
10
|
+
const findingWord = findings.length === 1 ? 'finding' : 'findings';
|
|
11
|
+
const fileWord = report.filesWithIssues === 1 ? 'file' : 'files';
|
|
12
|
+
lines.push(
|
|
13
|
+
`::notice title=Rhythmguard audit::${escapeGithubData(
|
|
14
|
+
`${findings.length} ${findingWord} across ${report.filesWithIssues} ${fileWord}, ${report.scaleCleanliness}% scale cleanliness`,
|
|
15
|
+
)}`,
|
|
16
|
+
);
|
|
17
|
+
return `${lines.join('\n')}\n`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Escaping rules from GitHub's actions/toolkit (packages/core/src/command.ts).
|
|
21
|
+
|
|
22
|
+
function escapeGithubData(value) {
|
|
23
|
+
return String(value)
|
|
24
|
+
.replace(/%/g, '%25')
|
|
25
|
+
.replace(/\r/g, '%0D')
|
|
26
|
+
.replace(/\n/g, '%0A');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function escapeGithubProperty(value) {
|
|
30
|
+
return escapeGithubData(value)
|
|
31
|
+
.replace(/:/g, '%3A')
|
|
32
|
+
.replace(/,/g, '%2C');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = {
|
|
36
|
+
escapeGithubData,
|
|
37
|
+
escapeGithubProperty,
|
|
38
|
+
renderGithub,
|
|
39
|
+
};
|