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,78 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { toAuditContractReport } = require('./contract');
|
|
4
|
+
const { escapeHtml } = require('./render-utils');
|
|
5
|
+
|
|
6
|
+
function renderHtml(report) {
|
|
7
|
+
const contractReport = toAuditContractReport(report);
|
|
8
|
+
const lines = [
|
|
9
|
+
'<!doctype html>',
|
|
10
|
+
'<html lang="en">',
|
|
11
|
+
'<head>',
|
|
12
|
+
'<meta charset="utf-8">',
|
|
13
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
14
|
+
'<title>Rhythmguard Design-System Audit</title>',
|
|
15
|
+
'<style>',
|
|
16
|
+
'body{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;margin:0;color:#171717;background:#fafafa;}',
|
|
17
|
+
'main{max-width:1040px;margin:0 auto;padding:32px 20px;}',
|
|
18
|
+
'h1,h2{line-height:1.2;}',
|
|
19
|
+
'.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;}',
|
|
20
|
+
'.metric{border:1px solid #ddd;background:#fff;padding:14px;border-radius:6px;}',
|
|
21
|
+
'.metric strong{display:block;font-size:28px;}',
|
|
22
|
+
'table{width:100%;border-collapse:collapse;background:#fff;border:1px solid #ddd;}',
|
|
23
|
+
'th,td{padding:10px;border-bottom:1px solid #eee;text-align:left;}',
|
|
24
|
+
'code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;}',
|
|
25
|
+
'</style>',
|
|
26
|
+
'</head>',
|
|
27
|
+
'<body>',
|
|
28
|
+
'<main>',
|
|
29
|
+
'<h1>Rhythmguard Design-System Audit</h1>',
|
|
30
|
+
`<p>Directory: <code>${escapeHtml(contractReport.command.directory)}</code></p>`,
|
|
31
|
+
'<section class="grid">',
|
|
32
|
+
metricHtml('Total findings', report.totalWarnings),
|
|
33
|
+
metricHtml('Scale cleanliness', `${report.scaleCleanliness}%`),
|
|
34
|
+
metricHtml('CSS files', report.cssFilesScanned),
|
|
35
|
+
metricHtml('Template files', report.templateFilesScanned),
|
|
36
|
+
'</section>',
|
|
37
|
+
renderHtmlTable('Top Affected Files', ['File', 'Findings'], report.topAffectedFiles.map(({ file, count }) => [file, count])),
|
|
38
|
+
renderHtmlTable('Token Contract Sources', ['File', 'Format', 'Tokens'], report.tokenContract.sources.map((source) => [
|
|
39
|
+
source.file,
|
|
40
|
+
source.format,
|
|
41
|
+
source.tokenCount,
|
|
42
|
+
])),
|
|
43
|
+
renderHtmlTable('Motion Rhythm Drift', ['Value', 'Count'], Object.entries(report.motion.values)),
|
|
44
|
+
'<h2>Machine JSON</h2>',
|
|
45
|
+
`<pre><code>${escapeHtml(JSON.stringify(contractReport, null, 2))}</code></pre>`,
|
|
46
|
+
'</main>',
|
|
47
|
+
'</body>',
|
|
48
|
+
'</html>',
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
return `${lines.join('\n')}\n`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function metricHtml(label, value) {
|
|
55
|
+
return `<div class="metric"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function renderHtmlTable(title, headers, rows) {
|
|
59
|
+
if (rows.length === 0) {
|
|
60
|
+
return '';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return [
|
|
64
|
+
`<h2>${escapeHtml(title)}</h2>`,
|
|
65
|
+
'<table>',
|
|
66
|
+
`<thead><tr>${headers.map((header) => `<th>${escapeHtml(header)}</th>`).join('')}</tr></thead>`,
|
|
67
|
+
'<tbody>',
|
|
68
|
+
...rows.map((row) => `<tr>${row.map((cell) => `<td><code>${escapeHtml(cell)}</code></td>`).join('')}</tr>`),
|
|
69
|
+
'</tbody>',
|
|
70
|
+
'</table>',
|
|
71
|
+
].join('\n');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = {
|
|
75
|
+
metricHtml,
|
|
76
|
+
renderHtml,
|
|
77
|
+
renderHtmlTable,
|
|
78
|
+
};
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { sortCountMap } = require('./contract');
|
|
4
|
+
const { escapeMarkdown } = require('./render-utils');
|
|
5
|
+
|
|
6
|
+
function describeScaleSource(scale) {
|
|
7
|
+
return scale.files.length > 0
|
|
8
|
+
? `${scale.source} (${scale.files.join(', ')})`
|
|
9
|
+
: scale.source;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function renderMarkdown(report) {
|
|
13
|
+
const lines = [
|
|
14
|
+
'# Rhythmguard Design-System Audit',
|
|
15
|
+
'',
|
|
16
|
+
`Directory: \`${report.directory}\``,
|
|
17
|
+
'',
|
|
18
|
+
'## Summary',
|
|
19
|
+
'',
|
|
20
|
+
'| Metric | Value |',
|
|
21
|
+
'| --- | ---: |',
|
|
22
|
+
`| CSS files scanned | ${report.cssFilesScanned} |`,
|
|
23
|
+
`| Template files scanned | ${report.templateFilesScanned} |`,
|
|
24
|
+
`| Files with issues | ${report.filesWithIssues} |`,
|
|
25
|
+
`| Total findings | ${report.totalWarnings} |`,
|
|
26
|
+
`| Scale cleanliness | ${report.scaleCleanliness}% |`,
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
if (report.scale) {
|
|
30
|
+
lines.push(`| Scale | ${report.scale.values.join(', ')} |`);
|
|
31
|
+
lines.push(`| Scale source | ${describeScaleSource(report.scale)} |`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (report.baseline) {
|
|
35
|
+
lines.push(`| New findings | ${report.baseline.newFindingsCount} |`);
|
|
36
|
+
lines.push(`| Resolved findings | ${report.baseline.resolvedFindingsCount} |`);
|
|
37
|
+
}
|
|
38
|
+
lines.push('');
|
|
39
|
+
|
|
40
|
+
appendMarkdownCounts(lines, 'CSS Off-Scale Values', report.offScaleValues);
|
|
41
|
+
appendMarkdownCounts(lines, 'CSS Token Opportunities', report.tokenOpportunities);
|
|
42
|
+
appendMarkdownCounts(lines, 'Tailwind Class-String Drift', report.tailwindArbitraryValues);
|
|
43
|
+
appendMarkdownCounts(lines, 'Motion Rhythm Drift', report.motion.values);
|
|
44
|
+
appendTokenContractMarkdown(lines, report.tokenContract);
|
|
45
|
+
appendBaselineMarkdown(lines, report);
|
|
46
|
+
|
|
47
|
+
if (report.topAffectedFiles.length > 0) {
|
|
48
|
+
lines.push('## Top Affected Files');
|
|
49
|
+
lines.push('');
|
|
50
|
+
lines.push('| File | Findings |');
|
|
51
|
+
lines.push('| --- | ---: |');
|
|
52
|
+
for (const { file, count } of report.topAffectedFiles) {
|
|
53
|
+
lines.push(`| \`${escapeMarkdown(file)}\` | ${count} |`);
|
|
54
|
+
}
|
|
55
|
+
lines.push('');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (report.findings.tailwind.length > 0) {
|
|
59
|
+
lines.push('## Tailwind Examples');
|
|
60
|
+
lines.push('');
|
|
61
|
+
lines.push('| File | Class | Suggested class |');
|
|
62
|
+
lines.push('| --- | --- | --- |');
|
|
63
|
+
for (const finding of report.findings.tailwind.slice(0, 10)) {
|
|
64
|
+
lines.push(`| \`${escapeMarkdown(`${finding.file}:${finding.line}`)}\` | \`${escapeMarkdown(finding.token)}\` | \`${escapeMarkdown(finding.fixedToken || 'n/a')}\` |`);
|
|
65
|
+
}
|
|
66
|
+
lines.push('');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
lines.push('## Recommended Next Actions');
|
|
70
|
+
lines.push('');
|
|
71
|
+
if (report.totalWarnings === 0) {
|
|
72
|
+
lines.push('- Keep Rhythmguard in CI to prevent new drift.');
|
|
73
|
+
} else {
|
|
74
|
+
lines.push('- Run deterministic autofix for CSS declarations where appropriate.');
|
|
75
|
+
lines.push('- Review Tailwind arbitrary spacing values with UX/design-system owners.');
|
|
76
|
+
lines.push('- Convert repeated raw on-scale values into existing design tokens.');
|
|
77
|
+
}
|
|
78
|
+
lines.push('');
|
|
79
|
+
|
|
80
|
+
return `${lines.join('\n')}\n`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function appendTokenContractMarkdown(lines, tokenContract) {
|
|
84
|
+
const {
|
|
85
|
+
conflictingTokens,
|
|
86
|
+
missingTokens,
|
|
87
|
+
rawValueCandidates,
|
|
88
|
+
rawValueMatches,
|
|
89
|
+
sources,
|
|
90
|
+
unusedTokens,
|
|
91
|
+
} = tokenContract;
|
|
92
|
+
if (
|
|
93
|
+
missingTokens.length === 0 &&
|
|
94
|
+
rawValueCandidates.length === 0 &&
|
|
95
|
+
rawValueMatches.length === 0 &&
|
|
96
|
+
unusedTokens.length === 0 &&
|
|
97
|
+
conflictingTokens.length === 0 &&
|
|
98
|
+
sources.length === 0
|
|
99
|
+
) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
lines.push('## Token Contract');
|
|
104
|
+
lines.push('');
|
|
105
|
+
|
|
106
|
+
if (sources.length > 0) {
|
|
107
|
+
lines.push('### Token Sources');
|
|
108
|
+
lines.push('');
|
|
109
|
+
lines.push('| File | Format | Tokens | Warnings |');
|
|
110
|
+
lines.push('| --- | --- | ---: | --- |');
|
|
111
|
+
for (const source of sources.slice(0, 10)) {
|
|
112
|
+
lines.push(`| \`${escapeMarkdown(source.file)}\` | \`${source.format}\` | ${source.tokenCount} | \`${escapeMarkdown(source.warnings.join('; ') || 'none')}\` |`);
|
|
113
|
+
}
|
|
114
|
+
lines.push('');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (missingTokens.length > 0) {
|
|
118
|
+
lines.push('### Tokens Used But Missing');
|
|
119
|
+
lines.push('');
|
|
120
|
+
lines.push('| Token | Files |');
|
|
121
|
+
lines.push('| --- | --- |');
|
|
122
|
+
for (const entry of missingTokens.slice(0, 10)) {
|
|
123
|
+
lines.push(`| \`${escapeMarkdown(entry.token)}\` | \`${escapeMarkdown(entry.files.join(', '))}\` |`);
|
|
124
|
+
}
|
|
125
|
+
lines.push('');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (unusedTokens.length > 0) {
|
|
129
|
+
lines.push('### Tokens Defined But Unused');
|
|
130
|
+
lines.push('');
|
|
131
|
+
lines.push('| Token | Value |');
|
|
132
|
+
lines.push('| --- | --- |');
|
|
133
|
+
for (const entry of unusedTokens.slice(0, 10)) {
|
|
134
|
+
lines.push(`| \`${escapeMarkdown(entry.token)}\` | \`${escapeMarkdown((entry.values || []).join(', ') || 'n/a')}\` |`);
|
|
135
|
+
}
|
|
136
|
+
lines.push('');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (rawValueCandidates.length > 0) {
|
|
140
|
+
lines.push('### Repeated Raw Value Candidates');
|
|
141
|
+
lines.push('');
|
|
142
|
+
lines.push('| Value | Count | Files |');
|
|
143
|
+
lines.push('| --- | ---: | --- |');
|
|
144
|
+
for (const entry of rawValueCandidates.slice(0, 10)) {
|
|
145
|
+
lines.push(`| \`${escapeMarkdown(entry.value)}\` | ${entry.count} | \`${escapeMarkdown(entry.files.join(', '))}\` |`);
|
|
146
|
+
}
|
|
147
|
+
lines.push('');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (rawValueMatches.length > 0) {
|
|
151
|
+
lines.push('### Raw Values Matching Known Tokens');
|
|
152
|
+
lines.push('');
|
|
153
|
+
lines.push('| Value | Count | Tokens |');
|
|
154
|
+
lines.push('| --- | ---: | --- |');
|
|
155
|
+
for (const entry of rawValueMatches.slice(0, 10)) {
|
|
156
|
+
lines.push(`| \`${escapeMarkdown(entry.value)}\` | ${entry.count} | \`${escapeMarkdown(entry.tokens.join(', '))}\` |`);
|
|
157
|
+
}
|
|
158
|
+
lines.push('');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (conflictingTokens.length > 0) {
|
|
162
|
+
lines.push('### Conflicting Token Values');
|
|
163
|
+
lines.push('');
|
|
164
|
+
lines.push('| Value | Tokens |');
|
|
165
|
+
lines.push('| --- | --- |');
|
|
166
|
+
for (const entry of conflictingTokens.slice(0, 10)) {
|
|
167
|
+
lines.push(`| \`${escapeMarkdown(entry.value)}\` | \`${escapeMarkdown(entry.tokens.join(', '))}\` |`);
|
|
168
|
+
}
|
|
169
|
+
lines.push('');
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function appendBaselineMarkdown(lines, report) {
|
|
174
|
+
if (!report.baseline && !report.baselineWritten) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
lines.push('## Baseline');
|
|
179
|
+
lines.push('');
|
|
180
|
+
|
|
181
|
+
if (report.baseline) {
|
|
182
|
+
lines.push('| Metric | Value |');
|
|
183
|
+
lines.push('| --- | ---: |');
|
|
184
|
+
lines.push(`| Baseline findings | ${report.baseline.baselineFindings} |`);
|
|
185
|
+
lines.push(`| New findings | ${report.baseline.newFindingsCount} |`);
|
|
186
|
+
lines.push(`| Resolved findings | ${report.baseline.resolvedFindingsCount} |`);
|
|
187
|
+
lines.push('');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (report.baseline && report.baseline.newFindings.length > 0) {
|
|
191
|
+
lines.push('| New finding | Location |');
|
|
192
|
+
lines.push('| --- | --- |');
|
|
193
|
+
for (const finding of report.baseline.newFindings.slice(0, 10)) {
|
|
194
|
+
lines.push(`| \`${escapeMarkdown(finding.text)}\` | \`${escapeMarkdown(`${finding.file}:${finding.line}`)}\` |`);
|
|
195
|
+
}
|
|
196
|
+
lines.push('');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (report.baselineWritten) {
|
|
200
|
+
lines.push(`Baseline written: \`${escapeMarkdown(report.baselineWritten.file)}\``);
|
|
201
|
+
lines.push('');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function appendMarkdownCounts(lines, title, counts) {
|
|
206
|
+
const entries = sortCountMap(counts);
|
|
207
|
+
|
|
208
|
+
if (entries.length === 0) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
lines.push(`## ${title}`);
|
|
213
|
+
lines.push('');
|
|
214
|
+
lines.push('| Value | Count |');
|
|
215
|
+
lines.push('| --- | ---: |');
|
|
216
|
+
for (const [value, count] of entries) {
|
|
217
|
+
lines.push(`| \`${escapeMarkdown(value)}\` | ${count} |`);
|
|
218
|
+
}
|
|
219
|
+
lines.push('');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
module.exports = {
|
|
223
|
+
appendBaselineMarkdown,
|
|
224
|
+
appendMarkdownCounts,
|
|
225
|
+
appendTokenContractMarkdown,
|
|
226
|
+
renderMarkdown,
|
|
227
|
+
};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
sortCountMap,
|
|
5
|
+
sumCounts,
|
|
6
|
+
} = require('./contract');
|
|
7
|
+
const {
|
|
8
|
+
histBar,
|
|
9
|
+
scoreBar,
|
|
10
|
+
truncate,
|
|
11
|
+
} = require('./render-utils');
|
|
12
|
+
|
|
13
|
+
function renderText(report) {
|
|
14
|
+
const lines = [
|
|
15
|
+
'',
|
|
16
|
+
' ┌─ RHYTHMGUARD DESIGN-SYSTEM AUDIT ─────────────────────────┐',
|
|
17
|
+
` │ ${report.directory.padEnd(58)}│`,
|
|
18
|
+
' └─────────────────────────────────────────────────────────────┘',
|
|
19
|
+
'',
|
|
20
|
+
` CSS files scanned ${String(report.cssFilesScanned).padStart(4)}`,
|
|
21
|
+
` Template files scanned ${String(report.templateFilesScanned).padStart(4)}`,
|
|
22
|
+
` Files with issues ${String(report.filesWithIssues).padStart(4)}`,
|
|
23
|
+
` Scale cleanliness ${scoreBar(report.scaleCleanliness)} ${report.scaleCleanliness}%`,
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
if (report.scale) {
|
|
27
|
+
lines.push(` Scale ${report.scale.values.join(', ')}`);
|
|
28
|
+
const files = report.scale.files.length > 0 ? ` (${report.scale.files.join(', ')})` : '';
|
|
29
|
+
lines.push(` Scale source ${report.scale.source}${files}`);
|
|
30
|
+
}
|
|
31
|
+
lines.push('');
|
|
32
|
+
|
|
33
|
+
appendHistogram(lines, 'CSS OFF-SCALE VALUES', report.offScaleValues);
|
|
34
|
+
appendHistogram(lines, 'CSS TOKEN OPPORTUNITIES', report.tokenOpportunities);
|
|
35
|
+
appendHistogram(lines, 'TAILWIND CLASS-STRING DRIFT', report.tailwindArbitraryValues);
|
|
36
|
+
appendHistogram(lines, 'MOTION RHYTHM DRIFT', report.motion.values);
|
|
37
|
+
appendTokenContractText(lines, report.tokenContract);
|
|
38
|
+
appendBaselineText(lines, report);
|
|
39
|
+
|
|
40
|
+
if (report.topAffectedFiles.length > 0) {
|
|
41
|
+
lines.push(' ── TOP AFFECTED FILES ──');
|
|
42
|
+
lines.push('');
|
|
43
|
+
const maxCount = Math.max(...report.topAffectedFiles.map(({ count }) => count));
|
|
44
|
+
for (const { file, count } of report.topAffectedFiles) {
|
|
45
|
+
lines.push(` ${truncate(file, 34).padEnd(36)} ${histBar(count, maxCount)} ${count}`);
|
|
46
|
+
}
|
|
47
|
+
lines.push('');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (report.totalWarnings === 0) {
|
|
51
|
+
lines.push(' ✓ No issues found. Your design-system rhythm is clean.');
|
|
52
|
+
} else {
|
|
53
|
+
lines.push(' → Run "npx stylelint --fix" for CSS declaration fixes.');
|
|
54
|
+
lines.push(' → Use "npx rhythmguard audit ./src --format markdown" for PR/design review.');
|
|
55
|
+
}
|
|
56
|
+
lines.push('');
|
|
57
|
+
|
|
58
|
+
return `${lines.join('\n')}\n`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function appendTokenContractText(lines, tokenContract) {
|
|
62
|
+
const {
|
|
63
|
+
conflictingTokens,
|
|
64
|
+
missingTokens,
|
|
65
|
+
rawValueCandidates,
|
|
66
|
+
rawValueMatches,
|
|
67
|
+
sources,
|
|
68
|
+
unusedTokens,
|
|
69
|
+
} = tokenContract;
|
|
70
|
+
if (
|
|
71
|
+
missingTokens.length === 0 &&
|
|
72
|
+
rawValueCandidates.length === 0 &&
|
|
73
|
+
rawValueMatches.length === 0 &&
|
|
74
|
+
unusedTokens.length === 0 &&
|
|
75
|
+
conflictingTokens.length === 0 &&
|
|
76
|
+
sources.length === 0
|
|
77
|
+
) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
lines.push(' ── TOKEN CONTRACT ──');
|
|
82
|
+
lines.push('');
|
|
83
|
+
|
|
84
|
+
if (sources.length > 0) {
|
|
85
|
+
lines.push(` Token sources ${sources.length}`);
|
|
86
|
+
for (const source of sources.slice(0, 5)) {
|
|
87
|
+
const warningSuffix = source.warnings.length > 0 ? ' (warning)' : '';
|
|
88
|
+
lines.push(` ${truncate(source.file, 42)} ${source.tokenCount}${warningSuffix}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (missingTokens.length > 0) {
|
|
93
|
+
lines.push(` Missing tokens ${missingTokens.length}`);
|
|
94
|
+
for (const entry of missingTokens.slice(0, 5)) {
|
|
95
|
+
lines.push(` ${entry.token} (${truncate(entry.files.join(', '), 42)})`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (unusedTokens.length > 0) {
|
|
100
|
+
lines.push(` Defined but unused ${unusedTokens.length}`);
|
|
101
|
+
for (const entry of unusedTokens.slice(0, 5)) {
|
|
102
|
+
lines.push(` ${entry.token}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (rawValueCandidates.length > 0) {
|
|
107
|
+
lines.push(` Repeated raw values ${rawValueCandidates.length}`);
|
|
108
|
+
for (const entry of rawValueCandidates.slice(0, 5)) {
|
|
109
|
+
lines.push(` ${entry.value.padEnd(14)} ${entry.count}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (rawValueMatches.length > 0) {
|
|
114
|
+
lines.push(` Raw values matching tokens ${rawValueMatches.length}`);
|
|
115
|
+
for (const entry of rawValueMatches.slice(0, 5)) {
|
|
116
|
+
lines.push(` ${entry.value.padEnd(14)} ${truncate(entry.tokens.join(', '), 34)}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (conflictingTokens.length > 0) {
|
|
121
|
+
lines.push(` Conflicting tokens ${conflictingTokens.length}`);
|
|
122
|
+
for (const entry of conflictingTokens.slice(0, 5)) {
|
|
123
|
+
lines.push(` ${entry.value.padEnd(14)} ${truncate(entry.tokens.join(', '), 34)}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
lines.push('');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function appendBaselineText(lines, report) {
|
|
131
|
+
if (report.baseline) {
|
|
132
|
+
lines.push(' ── BASELINE COMPARISON ──');
|
|
133
|
+
lines.push('');
|
|
134
|
+
lines.push(` Baseline findings ${String(report.baseline.baselineFindings).padStart(4)}`);
|
|
135
|
+
lines.push(` New findings ${String(report.baseline.newFindingsCount).padStart(4)}`);
|
|
136
|
+
lines.push(` Resolved findings ${String(report.baseline.resolvedFindingsCount).padStart(4)}`);
|
|
137
|
+
lines.push('');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (report.baselineWritten) {
|
|
141
|
+
lines.push(` Baseline written ${report.baselineWritten.file}`);
|
|
142
|
+
lines.push('');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function appendHistogram(lines, title, counts) {
|
|
147
|
+
const entries = sortCountMap(counts);
|
|
148
|
+
const total = sumCounts(counts);
|
|
149
|
+
|
|
150
|
+
if (entries.length === 0) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
lines.push(` ── ${title} ── ${total} total`);
|
|
155
|
+
lines.push('');
|
|
156
|
+
const maxCount = Math.max(...entries.map(([, count]) => count));
|
|
157
|
+
for (const [value, count] of entries) {
|
|
158
|
+
lines.push(` ${value.padEnd(14)} ${histBar(count, maxCount)} ${count}`);
|
|
159
|
+
}
|
|
160
|
+
lines.push('');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = {
|
|
164
|
+
appendBaselineText,
|
|
165
|
+
appendHistogram,
|
|
166
|
+
appendTokenContractText,
|
|
167
|
+
renderText,
|
|
168
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function histBar(count, maxCount) {
|
|
4
|
+
const width = 30;
|
|
5
|
+
const filled = Math.round((count / maxCount) * width);
|
|
6
|
+
return '█'.repeat(filled) + '░'.repeat(width - filled);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function scoreBar(pct) {
|
|
10
|
+
const width = 20;
|
|
11
|
+
const filled = Math.round((pct / 100) * width);
|
|
12
|
+
return '█'.repeat(filled) + '░'.repeat(width - filled);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function truncate(value, maxLength) {
|
|
16
|
+
if (value.length <= maxLength) {
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return `…${value.slice(value.length - maxLength + 1)}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function escapeMarkdown(value) {
|
|
24
|
+
return String(value).replace(/\|/g, '\\|').replace(/`/g, '\\`');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function escapeHtml(value) {
|
|
28
|
+
return String(value)
|
|
29
|
+
.replace(/&/g, '&')
|
|
30
|
+
.replace(/</g, '<')
|
|
31
|
+
.replace(/>/g, '>')
|
|
32
|
+
.replace(/"/g, '"');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = {
|
|
36
|
+
escapeHtml,
|
|
37
|
+
escapeMarkdown,
|
|
38
|
+
histBar,
|
|
39
|
+
scoreBar,
|
|
40
|
+
truncate,
|
|
41
|
+
};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
|
|
5
|
+
const {
|
|
6
|
+
createTokenKindMatcher,
|
|
7
|
+
parseTokenSources,
|
|
8
|
+
} = require('../utils/token-sources');
|
|
9
|
+
const {
|
|
10
|
+
applyBaselineComparison,
|
|
11
|
+
writeBaseline,
|
|
12
|
+
} = require('./baseline');
|
|
13
|
+
const { createDefaultAuditOptions } = require('./shared');
|
|
14
|
+
const {
|
|
15
|
+
applyAuditConfig,
|
|
16
|
+
loadAuditConfig,
|
|
17
|
+
loadIgnorePatterns,
|
|
18
|
+
} = require('./config');
|
|
19
|
+
const { buildReport, collectTokenDefinitions } = require('./contract');
|
|
20
|
+
const { DEFAULT_SCALE, formatPath } = require('./shared');
|
|
21
|
+
const { scaleFromDefinitions } = require('../utils/scale-inference');
|
|
22
|
+
const {
|
|
23
|
+
assertDirectory,
|
|
24
|
+
collectCssFindings,
|
|
25
|
+
collectTailwindFindings,
|
|
26
|
+
collectTailwindMotionFindings,
|
|
27
|
+
getScanFiles,
|
|
28
|
+
runStylelintAudit,
|
|
29
|
+
} = require('./scan');
|
|
30
|
+
|
|
31
|
+
async function createAuditReport(options) {
|
|
32
|
+
const parsed = normalizeCreateAuditOptions(options);
|
|
33
|
+
const resolvedDir = assertDirectory(parsed.dir);
|
|
34
|
+
let ignorePatterns;
|
|
35
|
+
ignorePatterns = [
|
|
36
|
+
...loadIgnorePatterns(parsed.ignorePath),
|
|
37
|
+
...parsed.ignorePatterns,
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const scanFiles = getScanFiles(resolvedDir, ignorePatterns, parsed);
|
|
41
|
+
|
|
42
|
+
const { cssFiles, scanScope, templateFiles } = scanFiles;
|
|
43
|
+
|
|
44
|
+
const tokenSourceResult = parseTokenSources({
|
|
45
|
+
baseFontSize: parsed.baseFontSize,
|
|
46
|
+
sources: parsed.tokenSources,
|
|
47
|
+
tokenKind: parsed.tokenKind,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const scale = resolveAuditScale({
|
|
51
|
+
baseFontSize: parsed.baseFontSize,
|
|
52
|
+
cssFiles,
|
|
53
|
+
requested: parsed.scale,
|
|
54
|
+
tokenSourceResult,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const lintOptions = {
|
|
58
|
+
baseFontSize: parsed.baseFontSize,
|
|
59
|
+
includeMotion: parsed.includeMotion,
|
|
60
|
+
scale: scale.values,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const cssResults = await runStylelintAudit(cssFiles, lintOptions);
|
|
64
|
+
const stylelintFindings = collectCssFindings(cssResults);
|
|
65
|
+
const cssFindings = stylelintFindings.filter((finding) => !finding.type.startsWith('motion-'));
|
|
66
|
+
const motionFindings = [
|
|
67
|
+
...stylelintFindings.filter((finding) => finding.type.startsWith('motion-')),
|
|
68
|
+
...collectTailwindMotionFindings(templateFiles, lintOptions),
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
const report = buildReport({
|
|
72
|
+
baseFontSize: parsed.baseFontSize,
|
|
73
|
+
config: parsed.config,
|
|
74
|
+
cssFiles,
|
|
75
|
+
cssFindings,
|
|
76
|
+
dir: parsed.dir,
|
|
77
|
+
externalTokenDefinitions: tokenSourceResult.definitions,
|
|
78
|
+
includeMotion: parsed.includeMotion,
|
|
79
|
+
motionFindings,
|
|
80
|
+
scale,
|
|
81
|
+
scanScope,
|
|
82
|
+
tailwindFindings: collectTailwindFindings(templateFiles, lintOptions),
|
|
83
|
+
templateFiles,
|
|
84
|
+
tokenCandidateMinCount: parsed.tokenCandidateMinCount,
|
|
85
|
+
tokenKind: parsed.tokenKind,
|
|
86
|
+
tokenSourceReports: tokenSourceResult.sources,
|
|
87
|
+
tokenSourceWarnings: tokenSourceResult.warnings,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
if (parsed.sinceBaseline) {
|
|
91
|
+
applyBaselineComparison(report, parsed.baselinePath);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (parsed.writeBaseline) {
|
|
95
|
+
writeBaseline(report, parsed.baselinePath);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return report;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* One project-level scale for the whole audit, with provenance.
|
|
103
|
+
* "auto": external token sources, then spacing custom properties across the scanned
|
|
104
|
+
* CSS, then the default scale. First source that yields values wins.
|
|
105
|
+
*/
|
|
106
|
+
function resolveAuditScale({ baseFontSize, cssFiles, requested, tokenSourceResult }) {
|
|
107
|
+
if (Array.isArray(requested)) {
|
|
108
|
+
return {
|
|
109
|
+
files: [],
|
|
110
|
+
source: requested === DEFAULT_SCALE ? 'default' : 'explicit',
|
|
111
|
+
tokenCount: 0,
|
|
112
|
+
values: requested,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (tokenSourceResult.definitions.size > 0) {
|
|
117
|
+
const values = scaleFromDefinitions(tokenSourceResult.definitions, baseFontSize);
|
|
118
|
+
if (values) {
|
|
119
|
+
return {
|
|
120
|
+
files: tokenSourceResult.sources.map((source) => source.file),
|
|
121
|
+
source: 'token-sources',
|
|
122
|
+
tokenCount: tokenSourceResult.definitions.size,
|
|
123
|
+
values,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const definitions = new Map();
|
|
129
|
+
const matchesKind = createTokenKindMatcher('spacing');
|
|
130
|
+
for (const filePath of cssFiles) {
|
|
131
|
+
let text;
|
|
132
|
+
try {
|
|
133
|
+
text = fs.readFileSync(filePath, 'utf8');
|
|
134
|
+
} catch {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
collectTokenDefinitions(text, formatPath(filePath), definitions, matchesKind, baseFontSize);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (definitions.size > 0) {
|
|
141
|
+
const values = scaleFromDefinitions(definitions, baseFontSize);
|
|
142
|
+
if (values) {
|
|
143
|
+
const files = new Set();
|
|
144
|
+
for (const definition of definitions.values()) {
|
|
145
|
+
for (const file of definition.files) {
|
|
146
|
+
files.add(file);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
files: Array.from(files).sort(),
|
|
151
|
+
source: 'scanned-css',
|
|
152
|
+
tokenCount: definitions.size,
|
|
153
|
+
values,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return { files: [], source: 'fallback', tokenCount: 0, values: DEFAULT_SCALE };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function normalizeCreateAuditOptions(options = {}) {
|
|
162
|
+
const parsed = {
|
|
163
|
+
...createDefaultAuditOptions(),
|
|
164
|
+
...options,
|
|
165
|
+
cliOptions: options.cliOptions instanceof Set
|
|
166
|
+
? options.cliOptions
|
|
167
|
+
: new Set(Object.keys(options)),
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
if (!Array.isArray(parsed.ignorePatterns)) {
|
|
171
|
+
parsed.ignorePatterns = [];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!Array.isArray(parsed.tokenSources)) {
|
|
175
|
+
parsed.tokenSources = [];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (parsed.configApplied) {
|
|
179
|
+
delete parsed.cliOptions;
|
|
180
|
+
return parsed;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return applyAuditConfig(parsed, loadAuditConfig(parsed));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
module.exports = {
|
|
187
|
+
createAuditReport,
|
|
188
|
+
normalizeCreateAuditOptions,
|
|
189
|
+
};
|