tailwind-canonical 0.4.0 → 0.4.2
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/README.md +60 -7
- package/dist/cli/cli.d.ts +3 -0
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +346 -65
- package/dist/cli/cli.js.map +1 -1
- package/dist/core/class-strings.d.ts.map +1 -1
- package/dist/core/class-strings.js +66 -54
- package/dist/core/class-strings.js.map +1 -1
- package/dist/core/config.d.ts +2 -2
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +125 -29
- package/dist/core/config.js.map +1 -1
- package/dist/core/consistency.d.ts +12 -0
- package/dist/core/consistency.d.ts.map +1 -1
- package/dist/core/consistency.js +39 -1
- package/dist/core/consistency.js.map +1 -1
- package/dist/core/lexicon.d.ts.map +1 -1
- package/dist/core/lexicon.js +10 -4
- package/dist/core/lexicon.js.map +1 -1
- package/dist/core/rules.d.ts +26 -0
- package/dist/core/rules.d.ts.map +1 -1
- package/dist/core/rules.js +13 -8
- package/dist/core/rules.js.map +1 -1
- package/dist/core/sorter.js +3 -3
- package/dist/core/sorter.js.map +1 -1
- package/dist/eslint/plugin.d.ts.map +1 -1
- package/dist/eslint/plugin.js +45 -18
- package/dist/eslint/plugin.js.map +1 -1
- package/dist/io/config.d.ts.map +1 -1
- package/dist/io/config.js +5 -4
- package/dist/io/config.js.map +1 -1
- package/dist/io/merger.d.ts.map +1 -1
- package/dist/io/merger.js +7 -1
- package/dist/io/merger.js.map +1 -1
- package/dist/io/scanner.d.ts.map +1 -1
- package/dist/io/scanner.js +7 -1
- package/dist/io/scanner.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -125,9 +125,19 @@ Detects semantic inconsistencies visible only at project scale — it never modi
|
|
|
125
125
|
|
|
126
126
|
```bash
|
|
127
127
|
npx tailwind-canonical --analyze ./src
|
|
128
|
-
#
|
|
129
|
-
#
|
|
130
|
-
#
|
|
128
|
+
# tailwind-canonical analyze
|
|
129
|
+
# Files analyzed: 42
|
|
130
|
+
# Issue groups: 3 (1 color, 2 scales, 0 patterns)
|
|
131
|
+
#
|
|
132
|
+
# Color variants
|
|
133
|
+
# - text/red: text-red-500 x4, text-rose-500 x1
|
|
134
|
+
#
|
|
135
|
+
# Scale inconsistency groups
|
|
136
|
+
# - px: 2 values, 10 uses, 10 files
|
|
137
|
+
# Top: px-4 (8 uses, 8 files), px-3 (2 uses, 2 files)
|
|
138
|
+
#
|
|
139
|
+
# Rare scale values
|
|
140
|
+
# - px-11: 3 uses in 1 file (608 px uses total); e.g. src/Dialog.tsx
|
|
131
141
|
```
|
|
132
142
|
|
|
133
143
|
Three detectors:
|
|
@@ -136,6 +146,7 @@ Three detectors:
|
|
|
136
146
|
|---|---|
|
|
137
147
|
| Color variants | Multiple shades/colors of the same hue family used for one property (`text`, `bg`, `border`, …) |
|
|
138
148
|
| Scale inconsistency | Competing values for the same spacing / `gap` / `z` property across files |
|
|
149
|
+
| Rare scale values | Low-frequency scale values inside otherwise common properties (`ml-13`, `gap-24`, `px-11`, …) |
|
|
139
150
|
| Repeated patterns | Identical class combinations recurring across 3+ files |
|
|
140
151
|
|
|
141
152
|
Pairs with `--reporter json` for machine-readable output. Exits `1` when any inconsistency is found.
|
|
@@ -160,6 +171,10 @@ npx tailwind-canonical --analyze --reporter json ./src
|
|
|
160
171
|
{ "value": "3", "count": 2, "files": ["src/IconButton.tsx"] }
|
|
161
172
|
] }
|
|
162
173
|
],
|
|
174
|
+
"rareScaleValues": [
|
|
175
|
+
{ "property": "px", "value": "11", "className": "px-11",
|
|
176
|
+
"count": 3, "files": ["src/Dialog.tsx"], "propertyCount": 608 }
|
|
177
|
+
],
|
|
163
178
|
"combinations": []
|
|
164
179
|
}
|
|
165
180
|
```
|
|
@@ -182,8 +197,8 @@ Variants (`hover:`, `sm:`, `focus:`) are sorted after base classes, with respons
|
|
|
182
197
|
|
|
183
198
|
Override the category order via config. Categories you omit (and unknown classes) sort to the end, preserving their original relative order. Variants are always sorted after base classes regardless of order.
|
|
184
199
|
|
|
185
|
-
```
|
|
186
|
-
// tailwind-canonical.config.
|
|
200
|
+
```ts
|
|
201
|
+
// tailwind-canonical.config.ts
|
|
187
202
|
export default {
|
|
188
203
|
sortOrder: [
|
|
189
204
|
'display',
|
|
@@ -254,9 +269,9 @@ Non-divisible values (`h-[22px]`, `px-[7px]`) are left untouched.
|
|
|
254
269
|
|
|
255
270
|
## Config
|
|
256
271
|
|
|
257
|
-
Create `tailwind-canonical.config.
|
|
272
|
+
Create `tailwind-canonical.config.ts` at the root:
|
|
258
273
|
|
|
259
|
-
```
|
|
274
|
+
```ts
|
|
260
275
|
export default {
|
|
261
276
|
customTextTokens: {
|
|
262
277
|
10: '3xs',
|
|
@@ -269,6 +284,24 @@ export default {
|
|
|
269
284
|
attributeNames: ['className', 'class', ':class', 'tw'],
|
|
270
285
|
// Support utility function wrappers
|
|
271
286
|
functionNames: ['cn', 'clsx', 'cva'],
|
|
287
|
+
// Optional CLI defaults, so `tailwind-canonical` is enough locally/CI
|
|
288
|
+
defaultCommand: {
|
|
289
|
+
fix: true,
|
|
290
|
+
dedup: true,
|
|
291
|
+
merge: true,
|
|
292
|
+
sort: true,
|
|
293
|
+
targets: ['./src'],
|
|
294
|
+
},
|
|
295
|
+
// Tune --analyze reporting
|
|
296
|
+
analyze: {
|
|
297
|
+
maxScaleGroups: 8,
|
|
298
|
+
maxScaleValues: 5,
|
|
299
|
+
maxRareValues: 12,
|
|
300
|
+
maxPatterns: 10,
|
|
301
|
+
minRareScalePropertyOccurrences: 10,
|
|
302
|
+
rareScaleMaxFiles: 2,
|
|
303
|
+
rareScaleMaxCount: 3,
|
|
304
|
+
},
|
|
272
305
|
// Never suggest replacements for classes matching these patterns
|
|
273
306
|
ignorePatterns: [/^font-/, /-\[var\(/],
|
|
274
307
|
}
|
|
@@ -280,6 +313,26 @@ export default {
|
|
|
280
313
|
|
|
281
314
|
`functionNames` enables scanning inside utility function calls like `cn(...)` and `clsx(...)`.
|
|
282
315
|
|
|
316
|
+
`defaultCommand` lets the config provide the CLI defaults. With the example
|
|
317
|
+
above, running `tailwind-canonical` is equivalent to
|
|
318
|
+
`tailwind-canonical --fix --dedup --merge --sort ./src`. Explicit CLI flags and
|
|
319
|
+
targets still override the config, so `tailwind-canonical --typos ./app` runs
|
|
320
|
+
only typo detection on `./app`.
|
|
321
|
+
|
|
322
|
+
`analyze` controls project-scale consistency reporting. `maxScaleGroups`,
|
|
323
|
+
`maxScaleValues`, `maxRareValues`, and `maxPatterns` keep terminal output
|
|
324
|
+
compact. `minRareScalePropertyOccurrences`, `rareScaleMaxFiles`, and
|
|
325
|
+
`rareScaleMaxCount` control which low-frequency values are surfaced as rare
|
|
326
|
+
scale values. Defaults are conservative: only properties with 10+ total
|
|
327
|
+
occurrences are considered, and a value is rare when it appears in at most 2
|
|
328
|
+
files and at most 3 times.
|
|
329
|
+
|
|
330
|
+
The older top-level rare-value keys are still accepted for compatibility, but
|
|
331
|
+
new configs should prefer `analyze`.
|
|
332
|
+
|
|
333
|
+
`tailwind-canonical.config.js` is still loaded as a fallback when no TypeScript
|
|
334
|
+
config exists.
|
|
335
|
+
|
|
283
336
|
## ESLint plugin
|
|
284
337
|
|
|
285
338
|
```js
|
package/dist/cli/cli.d.ts
CHANGED
package/dist/cli/cli.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/cli/cli.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/cli/cli.ts"],"names":[],"mappings":"AAwBA,MAAM,MAAM,IAAI,GAAG;IACjB,GAAG,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;IACzB,KAAK,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;IAC3B,KAAK,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;CAC5B,CAAC;AAQF,MAAM,MAAM,SAAS,GAAG;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAEjE,KAAK,QAAQ,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;AAE1C,KAAK,KAAK,GAAG;IACX,GAAG,EAAE,OAAO,CAAC;IACb,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,EAAE,OAAO,CAAC;IACf,IAAI,EAAE,OAAO,CAAC;IACd,KAAK,EAAE,OAAO,CAAC;IACf,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;IACf,QAAQ,EAAE,QAAQ,CAAC;IACnB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,eAAe,EAAE,OAAO,CAAC;IACzB,mBAAmB,EAAE,OAAO,CAAC;IAC7B,gBAAgB,EAAE,OAAO,CAAC;CAC3B,CAAC;AAmJF,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,KAAK,CAwD/C;AA6CD;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,KAAK,GAAG,MAAM,EAAE,CAwBnD;AAgbD,wBAAsB,GAAG,CACvB,IAAI,EAAE,MAAM,EAAE,EACd,GAAG,EAAE,MAAM,EACX,IAAI,GAAE,IAAkB,GACvB,OAAO,CAAC,SAAS,CAAC,CAwLpB"}
|
package/dist/cli/cli.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { watch as fsWatch } from 'node:fs';
|
|
1
|
+
import { watch as fsWatch, readFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, resolve } from 'node:path';
|
|
3
3
|
import { analyzeFile } from '../core/analyzer.js';
|
|
4
4
|
import { toClassStringOpts } from '../core/class-strings.js';
|
|
@@ -17,6 +17,39 @@ const defaultSink = {
|
|
|
17
17
|
error: (s) => console.error(s),
|
|
18
18
|
write: (s) => process.stdout.write(s),
|
|
19
19
|
};
|
|
20
|
+
const KNOWN_CLASS_FUNCTIONS = ['cn', 'clsx', 'cva'];
|
|
21
|
+
const DEFAULT_ANALYZE_TEXT_OPTIONS = {
|
|
22
|
+
maxScaleGroups: 8,
|
|
23
|
+
maxScaleValues: 5,
|
|
24
|
+
maxRareValues: 12,
|
|
25
|
+
maxPatterns: 10,
|
|
26
|
+
};
|
|
27
|
+
function sarifDocument(rules, results) {
|
|
28
|
+
return {
|
|
29
|
+
$schema: SARIF_SCHEMA,
|
|
30
|
+
version: '2.1.0',
|
|
31
|
+
runs: [
|
|
32
|
+
{
|
|
33
|
+
tool: {
|
|
34
|
+
driver: {
|
|
35
|
+
name: 'tailwind-canonical',
|
|
36
|
+
informationUri: 'https://github.com/peak-lab/tailwind-canonical',
|
|
37
|
+
rules,
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
results,
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function fileLocations(files) {
|
|
46
|
+
return files.map((uri) => ({
|
|
47
|
+
physicalLocation: {
|
|
48
|
+
artifactLocation: { uri },
|
|
49
|
+
region: { startLine: 1, startColumn: 1 },
|
|
50
|
+
},
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
20
53
|
function errMsg(e) {
|
|
21
54
|
return e instanceof Error ? e.message : String(e);
|
|
22
55
|
}
|
|
@@ -29,6 +62,24 @@ function timestamp() {
|
|
|
29
62
|
return `[${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}]`;
|
|
30
63
|
}
|
|
31
64
|
const REPORTERS = ['text', 'json', 'sarif'];
|
|
65
|
+
const MODE_FLAGS = [
|
|
66
|
+
'--fix',
|
|
67
|
+
'--merge',
|
|
68
|
+
'--dedup',
|
|
69
|
+
'--sort',
|
|
70
|
+
'--analyze',
|
|
71
|
+
'--typos',
|
|
72
|
+
];
|
|
73
|
+
const KNOWN_FLAGS = new Set([
|
|
74
|
+
'--fix',
|
|
75
|
+
'--merge',
|
|
76
|
+
'--dedup',
|
|
77
|
+
'--sort',
|
|
78
|
+
'--watch',
|
|
79
|
+
'--analyze',
|
|
80
|
+
'--typos',
|
|
81
|
+
'--reporter',
|
|
82
|
+
]);
|
|
32
83
|
function isReporter(value) {
|
|
33
84
|
return REPORTERS.includes(value);
|
|
34
85
|
}
|
|
@@ -58,6 +109,14 @@ export function parseArgs(argv) {
|
|
|
58
109
|
error = `Unknown reporter: ${reporterRaw} (expected ${REPORTERS.join('|')})`;
|
|
59
110
|
}
|
|
60
111
|
}
|
|
112
|
+
if (!error) {
|
|
113
|
+
const unknown = argv.find((a, i) => a.startsWith('--') &&
|
|
114
|
+
!consumed.has(i) &&
|
|
115
|
+
!KNOWN_FLAGS.has(a) &&
|
|
116
|
+
!a.startsWith('--reporter='));
|
|
117
|
+
if (unknown)
|
|
118
|
+
error = `Unknown flag: ${unknown}`;
|
|
119
|
+
}
|
|
61
120
|
const targets = argv.filter((a, i) => !(a.startsWith('--') || consumed.has(i)));
|
|
62
121
|
return {
|
|
63
122
|
fix: argv.includes('--fix'),
|
|
@@ -70,8 +129,45 @@ export function parseArgs(argv) {
|
|
|
70
129
|
reporter,
|
|
71
130
|
targets,
|
|
72
131
|
error,
|
|
132
|
+
hasExplicitMode: MODE_FLAGS.some((flag) => argv.includes(flag)),
|
|
133
|
+
hasExplicitReporter: reporterRaw !== undefined,
|
|
134
|
+
hasExplicitWatch: argv.includes('--watch'),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function applyDefaultCommand(flags, config) {
|
|
138
|
+
const defaults = config.defaultCommand;
|
|
139
|
+
if (!defaults)
|
|
140
|
+
return flags;
|
|
141
|
+
return {
|
|
142
|
+
...flags,
|
|
143
|
+
fix: flags.hasExplicitMode ? flags.fix : (defaults.fix ?? flags.fix),
|
|
144
|
+
merge: flags.hasExplicitMode
|
|
145
|
+
? flags.merge
|
|
146
|
+
: (defaults.merge ?? flags.merge),
|
|
147
|
+
dedup: flags.hasExplicitMode
|
|
148
|
+
? flags.dedup
|
|
149
|
+
: (defaults.dedup ?? flags.dedup),
|
|
150
|
+
sort: flags.hasExplicitMode ? flags.sort : (defaults.sort ?? flags.sort),
|
|
151
|
+
analyze: flags.hasExplicitMode
|
|
152
|
+
? flags.analyze
|
|
153
|
+
: (defaults.analyze ?? flags.analyze),
|
|
154
|
+
typos: flags.hasExplicitMode
|
|
155
|
+
? flags.typos
|
|
156
|
+
: (defaults.typos ?? flags.typos),
|
|
157
|
+
watch: flags.hasExplicitWatch
|
|
158
|
+
? flags.watch
|
|
159
|
+
: (defaults.watch ?? flags.watch),
|
|
160
|
+
reporter: flags.hasExplicitReporter
|
|
161
|
+
? flags.reporter
|
|
162
|
+
: (defaults.reporter ?? flags.reporter),
|
|
163
|
+
targets: flags.targets.length > 0 ? flags.targets : (defaults.targets ?? []),
|
|
73
164
|
};
|
|
74
165
|
}
|
|
166
|
+
function resolveTarget(cwd, target) {
|
|
167
|
+
if (target.startsWith('!'))
|
|
168
|
+
return `!${resolve(cwd, target.slice(1))}`;
|
|
169
|
+
return resolve(cwd, target);
|
|
170
|
+
}
|
|
75
171
|
const TRANSFORM_FLAGS = [
|
|
76
172
|
{ key: 'fix', flag: '--fix' },
|
|
77
173
|
{ key: 'dedup', flag: '--dedup' },
|
|
@@ -128,6 +224,30 @@ function runTypos(files, config, reporter, sink) {
|
|
|
128
224
|
sink.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
129
225
|
return { exitCode: findings.length > 0 || hadError ? 1 : 0 };
|
|
130
226
|
}
|
|
227
|
+
if (reporter === 'sarif') {
|
|
228
|
+
const sarifOutput = sarifDocument([
|
|
229
|
+
{
|
|
230
|
+
id: 'color-typo',
|
|
231
|
+
name: 'ColorTypo',
|
|
232
|
+
shortDescription: {
|
|
233
|
+
text: 'Class color name is a likely typo of a Tailwind color',
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
], findings.map((f) => ({
|
|
237
|
+
ruleId: 'color-typo',
|
|
238
|
+
message: { text: `${f.original} → ${f.suggestion}` },
|
|
239
|
+
locations: [
|
|
240
|
+
{
|
|
241
|
+
physicalLocation: {
|
|
242
|
+
artifactLocation: { uri: f.file },
|
|
243
|
+
region: { startLine: f.line, startColumn: f.col },
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
],
|
|
247
|
+
})));
|
|
248
|
+
sink.write(`${JSON.stringify(sarifOutput, null, 2)}\n`);
|
|
249
|
+
return { exitCode: findings.length > 0 || hadError ? 1 : 0 };
|
|
250
|
+
}
|
|
131
251
|
for (const f of findings) {
|
|
132
252
|
sink.log(` ${f.file}:${f.line}:${f.col} ${f.original} → ${f.suggestion} [typo]`);
|
|
133
253
|
}
|
|
@@ -155,6 +275,118 @@ async function processFile(file, flags, config) {
|
|
|
155
275
|
function totalOf(c) {
|
|
156
276
|
return c.fixed + c.deduped + c.merged + c.sorted;
|
|
157
277
|
}
|
|
278
|
+
function scaleClass(property, value) {
|
|
279
|
+
return value.startsWith('-')
|
|
280
|
+
? `-${property}-${value.slice(1)}`
|
|
281
|
+
: `${property}-${value}`;
|
|
282
|
+
}
|
|
283
|
+
function findUnconfiguredClassFunctions(files, config) {
|
|
284
|
+
const configured = new Set(config.functionNames ?? []);
|
|
285
|
+
const missing = KNOWN_CLASS_FUNCTIONS.filter((name) => !configured.has(name));
|
|
286
|
+
if (missing.length === 0)
|
|
287
|
+
return [];
|
|
288
|
+
const found = new Set();
|
|
289
|
+
const re = new RegExp(`\\b(${missing.join('|')})\\s*\\(`);
|
|
290
|
+
for (const file of files) {
|
|
291
|
+
let content;
|
|
292
|
+
try {
|
|
293
|
+
content = readFileSync(file, 'utf8');
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
const match = content.match(re);
|
|
299
|
+
if (match?.[1])
|
|
300
|
+
found.add(match[1]);
|
|
301
|
+
}
|
|
302
|
+
return [...found].sort();
|
|
303
|
+
}
|
|
304
|
+
function compactPath(file) {
|
|
305
|
+
const normalized = file.replace(/\\/g, '/');
|
|
306
|
+
const parts = normalized.split('/');
|
|
307
|
+
if (parts.length <= 4)
|
|
308
|
+
return normalized;
|
|
309
|
+
return `.../${parts.slice(-4).join('/')}`;
|
|
310
|
+
}
|
|
311
|
+
function withMore(values, limit, format) {
|
|
312
|
+
const shown = values.slice(0, limit).map(format);
|
|
313
|
+
const remaining = values.length - shown.length;
|
|
314
|
+
if (remaining > 0)
|
|
315
|
+
shown.push(`+${remaining} more`);
|
|
316
|
+
return shown.join(', ');
|
|
317
|
+
}
|
|
318
|
+
function scaleValueSummary(property, value) {
|
|
319
|
+
return `${scaleClass(property, value.value)} (${pluralize(value.count, 'use')}, ${pluralize(value.files.length, 'file')})`;
|
|
320
|
+
}
|
|
321
|
+
function scaleTotalUses(scale) {
|
|
322
|
+
return scale.values.reduce((sum, value) => sum + value.count, 0);
|
|
323
|
+
}
|
|
324
|
+
function logAnalyzeText(report, issueCount, config, sink) {
|
|
325
|
+
const textOptions = {
|
|
326
|
+
maxScaleGroups: config.analyze?.maxScaleGroups ??
|
|
327
|
+
DEFAULT_ANALYZE_TEXT_OPTIONS.maxScaleGroups,
|
|
328
|
+
maxScaleValues: config.analyze?.maxScaleValues ??
|
|
329
|
+
DEFAULT_ANALYZE_TEXT_OPTIONS.maxScaleValues,
|
|
330
|
+
maxRareValues: config.analyze?.maxRareValues ??
|
|
331
|
+
DEFAULT_ANALYZE_TEXT_OPTIONS.maxRareValues,
|
|
332
|
+
maxPatterns: config.analyze?.maxPatterns ?? DEFAULT_ANALYZE_TEXT_OPTIONS.maxPatterns,
|
|
333
|
+
};
|
|
334
|
+
sink.log('tailwind-canonical analyze');
|
|
335
|
+
sink.log(`Files analyzed: ${report.filesAnalyzed}`);
|
|
336
|
+
sink.log(`Issue groups: ${issueCount} (${pluralize(report.colorVariants.length, 'color')}, ${pluralize(report.scaleInconsistencies.length, 'scale')}, ${pluralize(report.combinations.length, 'pattern')})`);
|
|
337
|
+
if (report.rareScaleValues.length > 0) {
|
|
338
|
+
sink.log(`Rare values: ${report.rareScaleValues.length}`);
|
|
339
|
+
}
|
|
340
|
+
if (issueCount === 0) {
|
|
341
|
+
sink.log('\nNo cross-file inconsistencies found');
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (report.colorVariants.length > 0) {
|
|
345
|
+
sink.log('\nColor variants');
|
|
346
|
+
for (const group of report.colorVariants) {
|
|
347
|
+
const tokens = group.variants
|
|
348
|
+
.map((v) => `${group.property}-${v.token} x${v.count}`)
|
|
349
|
+
.join(', ');
|
|
350
|
+
sink.log(` - ${group.property}/${group.family}: ${tokens}`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (report.scaleInconsistencies.length > 0) {
|
|
354
|
+
sink.log('\nScale inconsistency groups');
|
|
355
|
+
const scales = [...report.scaleInconsistencies].sort((a, b) => scaleTotalUses(b) - scaleTotalUses(a) ||
|
|
356
|
+
a.property.localeCompare(b.property));
|
|
357
|
+
for (const scale of scales.slice(0, textOptions.maxScaleGroups)) {
|
|
358
|
+
const totalUses = scaleTotalUses(scale);
|
|
359
|
+
const files = new Set(scale.values.flatMap((value) => value.files));
|
|
360
|
+
sink.log(` - ${scale.property}: ${scale.values.length} values, ${totalUses} uses, ${pluralize(files.size, 'file')}`);
|
|
361
|
+
sink.log(` Top: ${withMore(scale.values, textOptions.maxScaleValues, (value) => scaleValueSummary(scale.property, value))}`);
|
|
362
|
+
}
|
|
363
|
+
const remaining = scales.length - textOptions.maxScaleGroups;
|
|
364
|
+
if (remaining > 0)
|
|
365
|
+
sink.log(` - +${remaining} more scale groups`);
|
|
366
|
+
}
|
|
367
|
+
if (report.rareScaleValues.length > 0) {
|
|
368
|
+
sink.log('\nRare scale values');
|
|
369
|
+
for (const rare of report.rareScaleValues.slice(0, textOptions.maxRareValues)) {
|
|
370
|
+
const example = rare.files[0]
|
|
371
|
+
? `; e.g. ${compactPath(rare.files[0])}`
|
|
372
|
+
: '';
|
|
373
|
+
sink.log(` - ${rare.className}: ${pluralize(rare.count, 'use')} in ${pluralize(rare.files.length, 'file')} (${rare.propertyCount} ${rare.property} uses total)${example}`);
|
|
374
|
+
}
|
|
375
|
+
const remaining = report.rareScaleValues.length - textOptions.maxRareValues;
|
|
376
|
+
if (remaining > 0)
|
|
377
|
+
sink.log(` - +${remaining} more rare values`);
|
|
378
|
+
}
|
|
379
|
+
if (report.combinations.length > 0) {
|
|
380
|
+
sink.log('\nRepeated patterns');
|
|
381
|
+
for (const combo of report.combinations.slice(0, textOptions.maxPatterns)) {
|
|
382
|
+
sink.log(` - Pattern: "${combo.classes.join(' ')}" repeated in ${pluralize(combo.files.length, 'file')}`);
|
|
383
|
+
}
|
|
384
|
+
const remaining = report.combinations.length - textOptions.maxPatterns;
|
|
385
|
+
if (remaining > 0)
|
|
386
|
+
sink.log(` - +${remaining} more repeated patterns`);
|
|
387
|
+
}
|
|
388
|
+
sink.log(`\nFound ${pluralize(issueCount, 'consistency issue')} across ${pluralize(report.filesAnalyzed, 'file')}`);
|
|
389
|
+
}
|
|
158
390
|
function runAnalyze(files, config, reporter, sink) {
|
|
159
391
|
let hadError = false;
|
|
160
392
|
const options = toConsistencyOptions(config);
|
|
@@ -165,31 +397,93 @@ function runAnalyze(files, config, reporter, sink) {
|
|
|
165
397
|
const issueCount = report.colorVariants.length +
|
|
166
398
|
report.scaleInconsistencies.length +
|
|
167
399
|
report.combinations.length;
|
|
400
|
+
const unconfiguredFunctions = findUnconfiguredClassFunctions(files, config);
|
|
401
|
+
if (unconfiguredFunctions.length > 0) {
|
|
402
|
+
const calls = unconfiguredFunctions
|
|
403
|
+
.map((name) => `${name}(...)`)
|
|
404
|
+
.join(', ');
|
|
405
|
+
sink.error(`Warning: detected ${calls} calls but functionNames does not include them; --analyze may miss class strings.`);
|
|
406
|
+
}
|
|
168
407
|
if (reporter === 'json') {
|
|
169
408
|
sink.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
170
409
|
return { exitCode: issueCount > 0 || hadError ? 1 : 0 };
|
|
171
410
|
}
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
.join(', ');
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
411
|
+
if (reporter === 'sarif') {
|
|
412
|
+
const results = [];
|
|
413
|
+
for (const group of report.colorVariants) {
|
|
414
|
+
const tokens = group.variants.map((v) => v.token).join(', ');
|
|
415
|
+
const files = [...new Set(group.variants.flatMap((v) => v.files))];
|
|
416
|
+
results.push({
|
|
417
|
+
ruleId: 'color-variant-inconsistency',
|
|
418
|
+
message: {
|
|
419
|
+
text: `${group.variants.length} ${group.family} color variants for ${group.property}: ${tokens}`,
|
|
420
|
+
},
|
|
421
|
+
locations: fileLocations(files),
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
for (const scale of report.scaleInconsistencies) {
|
|
425
|
+
const values = scale.values
|
|
426
|
+
.map((v) => scaleClass(scale.property, v.value))
|
|
427
|
+
.join(' vs ');
|
|
428
|
+
const files = [...new Set(scale.values.flatMap((v) => v.files))];
|
|
429
|
+
results.push({
|
|
430
|
+
ruleId: 'scale-inconsistency',
|
|
431
|
+
message: { text: `${scale.property} inconsistency: ${values}` },
|
|
432
|
+
locations: fileLocations(files),
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
for (const rare of report.rareScaleValues) {
|
|
436
|
+
results.push({
|
|
437
|
+
ruleId: 'rare-scale-value',
|
|
438
|
+
message: {
|
|
439
|
+
text: `${rare.className} is rare for ${rare.property}: ${rare.count} occurrence(s) in ${rare.files.length} file(s), within ${rare.propertyCount} ${rare.property} uses`,
|
|
440
|
+
},
|
|
441
|
+
locations: fileLocations(rare.files),
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
for (const combo of report.combinations) {
|
|
445
|
+
results.push({
|
|
446
|
+
ruleId: 'repeated-combination',
|
|
447
|
+
message: {
|
|
448
|
+
text: `Repeated class combination: ${combo.classes.join(' ')}`,
|
|
449
|
+
},
|
|
450
|
+
locations: fileLocations(combo.files),
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
const sarifOutput = sarifDocument([
|
|
454
|
+
{
|
|
455
|
+
id: 'color-variant-inconsistency',
|
|
456
|
+
name: 'ColorVariantInconsistency',
|
|
457
|
+
shortDescription: {
|
|
458
|
+
text: 'Multiple color variants of the same family used for one property',
|
|
459
|
+
},
|
|
460
|
+
},
|
|
461
|
+
{
|
|
462
|
+
id: 'scale-inconsistency',
|
|
463
|
+
name: 'ScaleInconsistency',
|
|
464
|
+
shortDescription: {
|
|
465
|
+
text: 'Inconsistent scale values used for the same property',
|
|
466
|
+
},
|
|
467
|
+
},
|
|
468
|
+
{
|
|
469
|
+
id: 'rare-scale-value',
|
|
470
|
+
name: 'RareScaleValue',
|
|
471
|
+
shortDescription: {
|
|
472
|
+
text: 'A scale value appears rarely within an otherwise common property',
|
|
473
|
+
},
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
id: 'repeated-combination',
|
|
477
|
+
name: 'RepeatedCombination',
|
|
478
|
+
shortDescription: {
|
|
479
|
+
text: 'Identical class combination repeated across files',
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
], results);
|
|
483
|
+
sink.write(`${JSON.stringify(sarifOutput, null, 2)}\n`);
|
|
484
|
+
return { exitCode: issueCount > 0 || hadError ? 1 : 0 };
|
|
192
485
|
}
|
|
486
|
+
logAnalyzeText(report, issueCount, config, sink);
|
|
193
487
|
return { exitCode: issueCount > 0 || hadError ? 1 : 0 };
|
|
194
488
|
}
|
|
195
489
|
function startWatch(files, flags, config, sink) {
|
|
@@ -235,18 +529,27 @@ function startWatch(files, flags, config, sink) {
|
|
|
235
529
|
}, 50));
|
|
236
530
|
});
|
|
237
531
|
}
|
|
238
|
-
process.
|
|
532
|
+
process.once('SIGINT', () => {
|
|
239
533
|
sink.log('\nWatcher stopped.');
|
|
240
534
|
process.exit(0);
|
|
241
535
|
});
|
|
242
536
|
return { exitCode: 0, watching: true };
|
|
243
537
|
}
|
|
244
538
|
export async function run(argv, cwd, sink = defaultSink) {
|
|
245
|
-
|
|
539
|
+
let flags = parseArgs(argv);
|
|
246
540
|
if (flags.error) {
|
|
247
541
|
sink.error(flags.error);
|
|
248
542
|
return { exitCode: 1 };
|
|
249
543
|
}
|
|
544
|
+
let config;
|
|
545
|
+
try {
|
|
546
|
+
config = await loadConfig(cwd);
|
|
547
|
+
}
|
|
548
|
+
catch (err) {
|
|
549
|
+
sink.error(`tailwind-canonical: ${err instanceof Error ? err.message : String(err)}`);
|
|
550
|
+
return { exitCode: 1 };
|
|
551
|
+
}
|
|
552
|
+
flags = applyDefaultCommand(flags, config);
|
|
250
553
|
if (flags.targets.length === 0) {
|
|
251
554
|
sink.error(USAGE);
|
|
252
555
|
return { exitCode: 1 };
|
|
@@ -260,18 +563,11 @@ export async function run(argv, cwd, sink = defaultSink) {
|
|
|
260
563
|
return { exitCode: 1 };
|
|
261
564
|
}
|
|
262
565
|
}
|
|
263
|
-
let config;
|
|
264
|
-
try {
|
|
265
|
-
config = await loadConfig(cwd);
|
|
266
|
-
}
|
|
267
|
-
catch (err) {
|
|
268
|
-
sink.error(`tailwind-canonical: ${err instanceof Error ? err.message : String(err)}`);
|
|
269
|
-
return { exitCode: 1 };
|
|
270
|
-
}
|
|
271
566
|
for (const warning of flagWarnings(flags)) {
|
|
272
567
|
sink.error(`Warning: ${warning}`);
|
|
273
568
|
}
|
|
274
|
-
const
|
|
569
|
+
const targetPaths = flags.targets.map((target) => resolveTarget(cwd, target));
|
|
570
|
+
const files = await resolveTargets(targetPaths);
|
|
275
571
|
if (flags.analyze)
|
|
276
572
|
return runAnalyze(files, config, flags.reporter, sink);
|
|
277
573
|
if (flags.typos)
|
|
@@ -365,43 +661,28 @@ export async function run(argv, cwd, sink = defaultSink) {
|
|
|
365
661
|
return { exitCode: totalFindings > 0 || hadError ? 1 : 0 };
|
|
366
662
|
}
|
|
367
663
|
if (flags.reporter === 'sarif') {
|
|
368
|
-
const sarifOutput =
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
664
|
+
const sarifOutput = sarifDocument([
|
|
665
|
+
{
|
|
666
|
+
id: 'no-arbitrary-canonical',
|
|
667
|
+
name: 'NoArbitraryCanonical',
|
|
668
|
+
shortDescription: {
|
|
669
|
+
text: 'Arbitrary value has a canonical Tailwind equivalent',
|
|
670
|
+
},
|
|
671
|
+
},
|
|
672
|
+
], allFindings.map((f) => ({
|
|
673
|
+
ruleId: 'no-arbitrary-canonical',
|
|
674
|
+
message: {
|
|
675
|
+
text: `${f.suggestion.original} → ${f.suggestion.canonical}`,
|
|
676
|
+
},
|
|
677
|
+
locations: [
|
|
372
678
|
{
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
informationUri: 'https://github.com/peak-lab/tailwind-canonical',
|
|
377
|
-
rules: [
|
|
378
|
-
{
|
|
379
|
-
id: 'no-arbitrary-canonical',
|
|
380
|
-
name: 'NoArbitraryCanonical',
|
|
381
|
-
shortDescription: {
|
|
382
|
-
text: 'Arbitrary value has a canonical Tailwind equivalent',
|
|
383
|
-
},
|
|
384
|
-
},
|
|
385
|
-
],
|
|
386
|
-
},
|
|
679
|
+
physicalLocation: {
|
|
680
|
+
artifactLocation: { uri: f.file },
|
|
681
|
+
region: { startLine: f.line, startColumn: f.col },
|
|
387
682
|
},
|
|
388
|
-
results: allFindings.map((f) => ({
|
|
389
|
-
ruleId: 'no-arbitrary-canonical',
|
|
390
|
-
message: {
|
|
391
|
-
text: `${f.suggestion.original} → ${f.suggestion.canonical}`,
|
|
392
|
-
},
|
|
393
|
-
locations: [
|
|
394
|
-
{
|
|
395
|
-
physicalLocation: {
|
|
396
|
-
artifactLocation: { uri: f.file },
|
|
397
|
-
region: { startLine: f.line, startColumn: f.col },
|
|
398
|
-
},
|
|
399
|
-
},
|
|
400
|
-
],
|
|
401
|
-
})),
|
|
402
683
|
},
|
|
403
684
|
],
|
|
404
|
-
};
|
|
685
|
+
})));
|
|
405
686
|
sink.write(`${JSON.stringify(sarifOutput, null, 2)}\n`);
|
|
406
687
|
return { exitCode: totalFindings > 0 || hadError ? 1 : 0 };
|
|
407
688
|
}
|