tailwind-canonical 0.3.0 → 0.4.1

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.
Files changed (102) hide show
  1. package/README.md +85 -9
  2. package/dist/cli/cli.d.ts +9 -1
  3. package/dist/cli/cli.d.ts.map +1 -1
  4. package/dist/cli/cli.js +409 -78
  5. package/dist/cli/cli.js.map +1 -1
  6. package/dist/core/analyzer.d.ts +2 -1
  7. package/dist/core/analyzer.d.ts.map +1 -1
  8. package/dist/core/analyzer.js +5 -15
  9. package/dist/core/analyzer.js.map +1 -1
  10. package/dist/core/class-strings.d.ts +5 -1
  11. package/dist/core/class-strings.d.ts.map +1 -1
  12. package/dist/core/class-strings.js +74 -55
  13. package/dist/core/class-strings.js.map +1 -1
  14. package/dist/core/config.d.ts +3 -2
  15. package/dist/core/config.d.ts.map +1 -1
  16. package/dist/core/config.js +91 -33
  17. package/dist/core/config.js.map +1 -1
  18. package/dist/core/consistency.d.ts +18 -1
  19. package/dist/core/consistency.d.ts.map +1 -1
  20. package/dist/core/consistency.js +76 -110
  21. package/dist/core/consistency.js.map +1 -1
  22. package/dist/core/deduplicator.d.ts +5 -1
  23. package/dist/core/deduplicator.d.ts.map +1 -1
  24. package/dist/core/deduplicator.js +22 -17
  25. package/dist/core/deduplicator.js.map +1 -1
  26. package/dist/core/fixer.d.ts +5 -1
  27. package/dist/core/fixer.d.ts.map +1 -1
  28. package/dist/core/fixer.js +6 -10
  29. package/dist/core/fixer.js.map +1 -1
  30. package/dist/core/lexicon.d.ts +16 -0
  31. package/dist/core/lexicon.d.ts.map +1 -0
  32. package/dist/core/lexicon.js +139 -0
  33. package/dist/core/lexicon.js.map +1 -0
  34. package/dist/core/merger.d.ts +5 -1
  35. package/dist/core/merger.d.ts.map +1 -1
  36. package/dist/core/merger.js +3 -8
  37. package/dist/core/merger.js.map +1 -1
  38. package/dist/core/rules.d.ts +17 -0
  39. package/dist/core/rules.d.ts.map +1 -1
  40. package/dist/core/rules.js +18 -9
  41. package/dist/core/rules.js.map +1 -1
  42. package/dist/core/scanner.d.ts +5 -2
  43. package/dist/core/scanner.d.ts.map +1 -1
  44. package/dist/core/scanner.js +12 -80
  45. package/dist/core/scanner.js.map +1 -1
  46. package/dist/core/sorter.d.ts +5 -1
  47. package/dist/core/sorter.d.ts.map +1 -1
  48. package/dist/core/sorter.js +9 -10
  49. package/dist/core/sorter.js.map +1 -1
  50. package/dist/core/suppressions.d.ts +4 -0
  51. package/dist/core/suppressions.d.ts.map +1 -1
  52. package/dist/core/suppressions.js +10 -4
  53. package/dist/core/suppressions.js.map +1 -1
  54. package/dist/core/typos.d.ts +14 -0
  55. package/dist/core/typos.d.ts.map +1 -0
  56. package/dist/core/typos.js +71 -0
  57. package/dist/core/typos.js.map +1 -0
  58. package/dist/eslint/plugin.d.ts +3 -95
  59. package/dist/eslint/plugin.d.ts.map +1 -1
  60. package/dist/eslint/plugin.js +68 -31
  61. package/dist/eslint/plugin.js.map +1 -1
  62. package/dist/index.d.ts +5 -5
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/index.js +11 -4
  65. package/dist/index.js.map +1 -1
  66. package/dist/io/analyzer.d.ts +4 -0
  67. package/dist/io/analyzer.d.ts.map +1 -0
  68. package/dist/io/analyzer.js +7 -0
  69. package/dist/io/analyzer.js.map +1 -0
  70. package/dist/io/config.d.ts +3 -0
  71. package/dist/io/config.d.ts.map +1 -0
  72. package/dist/io/config.js +13 -0
  73. package/dist/io/config.js.map +1 -0
  74. package/dist/io/consistency.d.ts +4 -0
  75. package/dist/io/consistency.d.ts.map +1 -0
  76. package/dist/io/consistency.js +21 -0
  77. package/dist/io/consistency.js.map +1 -0
  78. package/dist/io/deduplicator.d.ts +3 -0
  79. package/dist/io/deduplicator.d.ts.map +1 -0
  80. package/dist/io/deduplicator.js +10 -0
  81. package/dist/io/deduplicator.js.map +1 -0
  82. package/dist/io/fixer.d.ts +3 -0
  83. package/dist/io/fixer.d.ts.map +1 -0
  84. package/dist/io/fixer.js +10 -0
  85. package/dist/io/fixer.js.map +1 -0
  86. package/dist/io/merger.d.ts +3 -0
  87. package/dist/io/merger.d.ts.map +1 -0
  88. package/dist/io/merger.js +17 -0
  89. package/dist/io/merger.js.map +1 -0
  90. package/dist/io/scanner.d.ts +4 -0
  91. package/dist/io/scanner.d.ts.map +1 -0
  92. package/dist/io/scanner.js +84 -0
  93. package/dist/io/scanner.js.map +1 -0
  94. package/dist/io/sorter.d.ts +4 -0
  95. package/dist/io/sorter.d.ts.map +1 -0
  96. package/dist/io/sorter.js +10 -0
  97. package/dist/io/sorter.js.map +1 -0
  98. package/dist/io/typos.d.ts +4 -0
  99. package/dist/io/typos.d.ts.map +1 -0
  100. package/dist/io/typos.js +7 -0
  101. package/dist/io/typos.js.map +1 -0
  102. package/package.json +22 -13
package/dist/cli/cli.js CHANGED
@@ -1,20 +1,55 @@
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
+ import { toClassStringOpts } from '../core/class-strings.js';
4
5
  import { loadConfig } from '../core/config.js';
5
- import { analyzeConsistencyFiles } from '../core/consistency.js';
6
+ import { analyzeConsistencyFiles, toConsistencyOptions, } from '../core/consistency.js';
6
7
  import { dedupeFile } from '../core/deduplicator.js';
7
8
  import { fixFile } from '../core/fixer.js';
8
9
  import { mergeFile } from '../core/merger.js';
9
10
  import { resolveTargets } from '../core/scanner.js';
10
11
  import { sortFile } from '../core/sorter.js';
12
+ import { analyzeTyposFile } from '../core/typos.js';
11
13
  const SARIF_SCHEMA = 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json';
12
- const USAGE = 'Usage: tailwind-canonical [--fix] [--merge] [--dedup] [--sort] [--analyze] [--watch] [--reporter json|sarif] <dir|file> [dir|file...]';
13
- export const defaultSink = {
14
+ const USAGE = 'Usage: tailwind-canonical [--fix] [--merge] [--dedup] [--sort] [--analyze] [--typos] [--watch] [--reporter json|sarif] <dir|file> [dir|file...]';
15
+ const defaultSink = {
14
16
  log: (s) => console.log(s),
15
17
  error: (s) => console.error(s),
16
18
  write: (s) => process.stdout.write(s),
17
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
+ }
18
53
  function errMsg(e) {
19
54
  return e instanceof Error ? e.message : String(e);
20
55
  }
@@ -26,17 +61,55 @@ function timestamp() {
26
61
  const pad = (n) => n.toString().padStart(2, '0');
27
62
  return `[${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}]`;
28
63
  }
64
+ const REPORTERS = ['text', 'json', 'sarif'];
65
+ const KNOWN_FLAGS = new Set([
66
+ '--fix',
67
+ '--merge',
68
+ '--dedup',
69
+ '--sort',
70
+ '--watch',
71
+ '--analyze',
72
+ '--typos',
73
+ '--reporter',
74
+ ]);
75
+ function isReporter(value) {
76
+ return REPORTERS.includes(value);
77
+ }
29
78
  export function parseArgs(argv) {
30
- const reporterIdx = argv.indexOf('--reporter');
31
- const reporterValue = argv[reporterIdx + 1];
32
- const reporter = reporterIdx !== -1 &&
33
- (reporterValue === 'json' || reporterValue === 'sarif')
34
- ? reporterValue
35
- : 'text';
36
- const targets = argv.filter((a, i) => !a.startsWith('--') &&
37
- argv[i - 1] !== '--reporter' &&
38
- a !== 'json' &&
39
- a !== 'sarif');
79
+ let reporter = 'text';
80
+ let reporterRaw;
81
+ const consumed = new Set();
82
+ for (let i = 0; i < argv.length; i += 1) {
83
+ const arg = argv[i];
84
+ if (arg === '--reporter') {
85
+ consumed.add(i);
86
+ reporterRaw = argv[i + 1];
87
+ if (i + 1 < argv.length)
88
+ consumed.add(i + 1);
89
+ }
90
+ else if (arg.startsWith('--reporter=')) {
91
+ consumed.add(i);
92
+ reporterRaw = arg.slice('--reporter='.length);
93
+ }
94
+ }
95
+ let error;
96
+ if (reporterRaw !== undefined) {
97
+ if (isReporter(reporterRaw)) {
98
+ reporter = reporterRaw;
99
+ }
100
+ else {
101
+ error = `Unknown reporter: ${reporterRaw} (expected ${REPORTERS.join('|')})`;
102
+ }
103
+ }
104
+ if (!error) {
105
+ const unknown = argv.find((a, i) => a.startsWith('--') &&
106
+ !consumed.has(i) &&
107
+ !KNOWN_FLAGS.has(a) &&
108
+ !a.startsWith('--reporter='));
109
+ if (unknown)
110
+ error = `Unknown flag: ${unknown}`;
111
+ }
112
+ const targets = argv.filter((a, i) => !(a.startsWith('--') || consumed.has(i)));
40
113
  return {
41
114
  fix: argv.includes('--fix'),
42
115
  merge: argv.includes('--merge'),
@@ -44,15 +117,102 @@ export function parseArgs(argv) {
44
117
  sort: argv.includes('--sort'),
45
118
  watch: argv.includes('--watch'),
46
119
  analyze: argv.includes('--analyze'),
120
+ typos: argv.includes('--typos'),
47
121
  reporter,
48
122
  targets,
123
+ error,
49
124
  };
50
125
  }
51
- function toClassStringOpts(config) {
52
- return {
53
- functionNames: config.functionNames,
54
- attributeNames: config.attributeNames,
55
- };
126
+ const TRANSFORM_FLAGS = [
127
+ { key: 'fix', flag: '--fix' },
128
+ { key: 'dedup', flag: '--dedup' },
129
+ { key: 'merge', flag: '--merge' },
130
+ { key: 'sort', flag: '--sort' },
131
+ ];
132
+ /**
133
+ * Modes are mutually exclusive with a fixed precedence: analyze > typos >
134
+ * transform/check. `--watch` is only honored in transform/check mode. This
135
+ * computes warnings for every flag that the active mode will ignore. Pure and
136
+ * order-stable so it can be unit-tested via the injectable sink.
137
+ */
138
+ export function flagWarnings(flags) {
139
+ const warnings = [];
140
+ if (flags.analyze) {
141
+ for (const { key, flag } of TRANSFORM_FLAGS) {
142
+ if (flags[key])
143
+ warnings.push(`${flag} ignored: --analyze takes priority`);
144
+ }
145
+ if (flags.typos)
146
+ warnings.push('--typos ignored: --analyze takes priority');
147
+ if (flags.watch)
148
+ warnings.push('--watch ignored: not supported with --analyze');
149
+ return warnings;
150
+ }
151
+ if (flags.typos) {
152
+ for (const { key, flag } of TRANSFORM_FLAGS) {
153
+ if (flags[key])
154
+ warnings.push(`${flag} ignored: --typos takes priority`);
155
+ }
156
+ if (flags.watch)
157
+ warnings.push('--watch ignored: not supported with --typos');
158
+ return warnings;
159
+ }
160
+ return warnings;
161
+ }
162
+ function runTypos(files, config, reporter, sink) {
163
+ let hadError = false;
164
+ const findings = [];
165
+ for (const file of files) {
166
+ try {
167
+ findings.push(...analyzeTyposFile(file, config));
168
+ }
169
+ catch (err) {
170
+ hadError = true;
171
+ sink.error(`${file}: ${errMsg(err)}`);
172
+ }
173
+ }
174
+ if (reporter === 'json') {
175
+ const report = {
176
+ total: findings.length,
177
+ typos: findings,
178
+ };
179
+ sink.write(`${JSON.stringify(report, null, 2)}\n`);
180
+ return { exitCode: findings.length > 0 || hadError ? 1 : 0 };
181
+ }
182
+ if (reporter === 'sarif') {
183
+ const sarifOutput = sarifDocument([
184
+ {
185
+ id: 'color-typo',
186
+ name: 'ColorTypo',
187
+ shortDescription: {
188
+ text: 'Class color name is a likely typo of a Tailwind color',
189
+ },
190
+ },
191
+ ], findings.map((f) => ({
192
+ ruleId: 'color-typo',
193
+ message: { text: `${f.original} → ${f.suggestion}` },
194
+ locations: [
195
+ {
196
+ physicalLocation: {
197
+ artifactLocation: { uri: f.file },
198
+ region: { startLine: f.line, startColumn: f.col },
199
+ },
200
+ },
201
+ ],
202
+ })));
203
+ sink.write(`${JSON.stringify(sarifOutput, null, 2)}\n`);
204
+ return { exitCode: findings.length > 0 || hadError ? 1 : 0 };
205
+ }
206
+ for (const f of findings) {
207
+ sink.log(` ${f.file}:${f.line}:${f.col} ${f.original} → ${f.suggestion} [typo]`);
208
+ }
209
+ if (findings.length === 0) {
210
+ sink.log('✓ No likely typos found');
211
+ }
212
+ else {
213
+ sink.log(`\n✖ Found ${pluralize(findings.length, 'likely typo')}`);
214
+ }
215
+ return { exitCode: findings.length > 0 || hadError ? 1 : 0 };
56
216
  }
57
217
  async function processFile(file, flags, config) {
58
218
  const opts = toClassStringOpts(config);
@@ -70,40 +230,215 @@ async function processFile(file, flags, config) {
70
230
  function totalOf(c) {
71
231
  return c.fixed + c.deduped + c.merged + c.sorted;
72
232
  }
233
+ function scaleClass(property, value) {
234
+ return value.startsWith('-')
235
+ ? `-${property}-${value.slice(1)}`
236
+ : `${property}-${value}`;
237
+ }
238
+ function findUnconfiguredClassFunctions(files, config) {
239
+ const configured = new Set(config.functionNames ?? []);
240
+ const missing = KNOWN_CLASS_FUNCTIONS.filter((name) => !configured.has(name));
241
+ if (missing.length === 0)
242
+ return [];
243
+ const found = new Set();
244
+ const re = new RegExp(`\\b(${missing.join('|')})\\s*\\(`);
245
+ for (const file of files) {
246
+ let content;
247
+ try {
248
+ content = readFileSync(file, 'utf8');
249
+ }
250
+ catch {
251
+ continue;
252
+ }
253
+ const match = content.match(re);
254
+ if (match?.[1])
255
+ found.add(match[1]);
256
+ }
257
+ return [...found].sort();
258
+ }
259
+ function compactPath(file) {
260
+ const normalized = file.replace(/\\/g, '/');
261
+ const parts = normalized.split('/');
262
+ if (parts.length <= 4)
263
+ return normalized;
264
+ return `.../${parts.slice(-4).join('/')}`;
265
+ }
266
+ function withMore(values, limit, format) {
267
+ const shown = values.slice(0, limit).map(format);
268
+ const remaining = values.length - shown.length;
269
+ if (remaining > 0)
270
+ shown.push(`+${remaining} more`);
271
+ return shown.join(', ');
272
+ }
273
+ function scaleValueSummary(property, value) {
274
+ return `${scaleClass(property, value.value)} (${pluralize(value.count, 'use')}, ${pluralize(value.files.length, 'file')})`;
275
+ }
276
+ function scaleTotalUses(scale) {
277
+ return scale.values.reduce((sum, value) => sum + value.count, 0);
278
+ }
279
+ function logAnalyzeText(report, issueCount, config, sink) {
280
+ const textOptions = {
281
+ maxScaleGroups: config.analyze?.maxScaleGroups ??
282
+ DEFAULT_ANALYZE_TEXT_OPTIONS.maxScaleGroups,
283
+ maxScaleValues: config.analyze?.maxScaleValues ??
284
+ DEFAULT_ANALYZE_TEXT_OPTIONS.maxScaleValues,
285
+ maxRareValues: config.analyze?.maxRareValues ??
286
+ DEFAULT_ANALYZE_TEXT_OPTIONS.maxRareValues,
287
+ maxPatterns: config.analyze?.maxPatterns ?? DEFAULT_ANALYZE_TEXT_OPTIONS.maxPatterns,
288
+ };
289
+ sink.log('tailwind-canonical analyze');
290
+ sink.log(`Files analyzed: ${report.filesAnalyzed}`);
291
+ sink.log(`Issue groups: ${issueCount} (${pluralize(report.colorVariants.length, 'color')}, ${pluralize(report.scaleInconsistencies.length, 'scale')}, ${pluralize(report.combinations.length, 'pattern')})`);
292
+ if (report.rareScaleValues.length > 0) {
293
+ sink.log(`Rare values: ${report.rareScaleValues.length}`);
294
+ }
295
+ if (issueCount === 0) {
296
+ sink.log('\nNo cross-file inconsistencies found');
297
+ return;
298
+ }
299
+ if (report.colorVariants.length > 0) {
300
+ sink.log('\nColor variants');
301
+ for (const group of report.colorVariants) {
302
+ const tokens = group.variants
303
+ .map((v) => `${group.property}-${v.token} x${v.count}`)
304
+ .join(', ');
305
+ sink.log(` - ${group.property}/${group.family}: ${tokens}`);
306
+ }
307
+ }
308
+ if (report.scaleInconsistencies.length > 0) {
309
+ sink.log('\nScale inconsistency groups');
310
+ const scales = [...report.scaleInconsistencies].sort((a, b) => scaleTotalUses(b) - scaleTotalUses(a) ||
311
+ a.property.localeCompare(b.property));
312
+ for (const scale of scales.slice(0, textOptions.maxScaleGroups)) {
313
+ const totalUses = scaleTotalUses(scale);
314
+ const files = new Set(scale.values.flatMap((value) => value.files));
315
+ sink.log(` - ${scale.property}: ${scale.values.length} values, ${totalUses} uses, ${pluralize(files.size, 'file')}`);
316
+ sink.log(` Top: ${withMore(scale.values, textOptions.maxScaleValues, (value) => scaleValueSummary(scale.property, value))}`);
317
+ }
318
+ const remaining = scales.length - textOptions.maxScaleGroups;
319
+ if (remaining > 0)
320
+ sink.log(` - +${remaining} more scale groups`);
321
+ }
322
+ if (report.rareScaleValues.length > 0) {
323
+ sink.log('\nRare scale values');
324
+ for (const rare of report.rareScaleValues.slice(0, textOptions.maxRareValues)) {
325
+ const example = rare.files[0]
326
+ ? `; e.g. ${compactPath(rare.files[0])}`
327
+ : '';
328
+ sink.log(` - ${rare.className}: ${pluralize(rare.count, 'use')} in ${pluralize(rare.files.length, 'file')} (${rare.propertyCount} ${rare.property} uses total)${example}`);
329
+ }
330
+ const remaining = report.rareScaleValues.length - textOptions.maxRareValues;
331
+ if (remaining > 0)
332
+ sink.log(` - +${remaining} more rare values`);
333
+ }
334
+ if (report.combinations.length > 0) {
335
+ sink.log('\nRepeated patterns');
336
+ for (const combo of report.combinations.slice(0, textOptions.maxPatterns)) {
337
+ sink.log(` - Pattern: "${combo.classes.join(' ')}" repeated in ${pluralize(combo.files.length, 'file')}`);
338
+ }
339
+ const remaining = report.combinations.length - textOptions.maxPatterns;
340
+ if (remaining > 0)
341
+ sink.log(` - +${remaining} more repeated patterns`);
342
+ }
343
+ sink.log(`\nFound ${pluralize(issueCount, 'consistency issue')} across ${pluralize(report.filesAnalyzed, 'file')}`);
344
+ }
73
345
  function runAnalyze(files, config, reporter, sink) {
74
346
  let hadError = false;
75
- const report = analyzeConsistencyFiles(files, config, {}, (file, err) => {
347
+ const options = toConsistencyOptions(config);
348
+ const report = analyzeConsistencyFiles(files, config, options, (file, err) => {
76
349
  hadError = true;
77
350
  sink.error(`${file}: ${errMsg(err)}`);
78
351
  });
79
352
  const issueCount = report.colorVariants.length +
80
353
  report.scaleInconsistencies.length +
81
354
  report.combinations.length;
355
+ const unconfiguredFunctions = findUnconfiguredClassFunctions(files, config);
356
+ if (unconfiguredFunctions.length > 0) {
357
+ const calls = unconfiguredFunctions
358
+ .map((name) => `${name}(...)`)
359
+ .join(', ');
360
+ sink.error(`Warning: detected ${calls} calls but functionNames does not include them; --analyze may miss class strings.`);
361
+ }
82
362
  if (reporter === 'json') {
83
363
  sink.write(`${JSON.stringify(report, null, 2)}\n`);
84
364
  return { exitCode: issueCount > 0 || hadError ? 1 : 0 };
85
365
  }
86
- for (const group of report.colorVariants) {
87
- const tokens = group.variants
88
- .map((v) => `${group.property}-${v.token} (${v.count})`)
89
- .join(', ');
90
- sink.log(` Warning: ${group.variants.length} ${group.family} color variants used for ${group.property}: ${tokens}`);
91
- }
92
- for (const scale of report.scaleInconsistencies) {
93
- const values = scale.values
94
- .map((v) => `${scale.property}-${v.value} (${pluralize(v.files.length, 'file')})`)
95
- .join(' vs ');
96
- sink.log(` Warning: ${scale.property} inconsistency: ${values}`);
97
- }
98
- for (const combo of report.combinations) {
99
- sink.log(` Pattern: "${combo.classes.join(' ')}" repeated in ${pluralize(combo.files.length, 'file')}`);
100
- }
101
- if (issueCount === 0) {
102
- sink.log('✓ No cross-file inconsistencies found');
103
- }
104
- else {
105
- sink.log(`\n✖ Found ${pluralize(issueCount, 'consistency issue')} across ${pluralize(report.filesAnalyzed, 'file')}`);
366
+ if (reporter === 'sarif') {
367
+ const results = [];
368
+ for (const group of report.colorVariants) {
369
+ const tokens = group.variants.map((v) => v.token).join(', ');
370
+ const files = [...new Set(group.variants.flatMap((v) => v.files))];
371
+ results.push({
372
+ ruleId: 'color-variant-inconsistency',
373
+ message: {
374
+ text: `${group.variants.length} ${group.family} color variants for ${group.property}: ${tokens}`,
375
+ },
376
+ locations: fileLocations(files),
377
+ });
378
+ }
379
+ for (const scale of report.scaleInconsistencies) {
380
+ const values = scale.values
381
+ .map((v) => scaleClass(scale.property, v.value))
382
+ .join(' vs ');
383
+ const files = [...new Set(scale.values.flatMap((v) => v.files))];
384
+ results.push({
385
+ ruleId: 'scale-inconsistency',
386
+ message: { text: `${scale.property} inconsistency: ${values}` },
387
+ locations: fileLocations(files),
388
+ });
389
+ }
390
+ for (const rare of report.rareScaleValues) {
391
+ results.push({
392
+ ruleId: 'rare-scale-value',
393
+ message: {
394
+ text: `${rare.className} is rare for ${rare.property}: ${rare.count} occurrence(s) in ${rare.files.length} file(s), within ${rare.propertyCount} ${rare.property} uses`,
395
+ },
396
+ locations: fileLocations(rare.files),
397
+ });
398
+ }
399
+ for (const combo of report.combinations) {
400
+ results.push({
401
+ ruleId: 'repeated-combination',
402
+ message: {
403
+ text: `Repeated class combination: ${combo.classes.join(' ')}`,
404
+ },
405
+ locations: fileLocations(combo.files),
406
+ });
407
+ }
408
+ const sarifOutput = sarifDocument([
409
+ {
410
+ id: 'color-variant-inconsistency',
411
+ name: 'ColorVariantInconsistency',
412
+ shortDescription: {
413
+ text: 'Multiple color variants of the same family used for one property',
414
+ },
415
+ },
416
+ {
417
+ id: 'scale-inconsistency',
418
+ name: 'ScaleInconsistency',
419
+ shortDescription: {
420
+ text: 'Inconsistent scale values used for the same property',
421
+ },
422
+ },
423
+ {
424
+ id: 'rare-scale-value',
425
+ name: 'RareScaleValue',
426
+ shortDescription: {
427
+ text: 'A scale value appears rarely within an otherwise common property',
428
+ },
429
+ },
430
+ {
431
+ id: 'repeated-combination',
432
+ name: 'RepeatedCombination',
433
+ shortDescription: {
434
+ text: 'Identical class combination repeated across files',
435
+ },
436
+ },
437
+ ], results);
438
+ sink.write(`${JSON.stringify(sarifOutput, null, 2)}\n`);
439
+ return { exitCode: issueCount > 0 || hadError ? 1 : 0 };
106
440
  }
441
+ logAnalyzeText(report, issueCount, config, sink);
107
442
  return { exitCode: issueCount > 0 || hadError ? 1 : 0 };
108
443
  }
109
444
  function startWatch(files, flags, config, sink) {
@@ -149,7 +484,7 @@ function startWatch(files, flags, config, sink) {
149
484
  }, 50));
150
485
  });
151
486
  }
152
- process.on('SIGINT', () => {
487
+ process.once('SIGINT', () => {
153
488
  sink.log('\nWatcher stopped.');
154
489
  process.exit(0);
155
490
  });
@@ -157,6 +492,10 @@ function startWatch(files, flags, config, sink) {
157
492
  }
158
493
  export async function run(argv, cwd, sink = defaultSink) {
159
494
  const flags = parseArgs(argv);
495
+ if (flags.error) {
496
+ sink.error(flags.error);
497
+ return { exitCode: 1 };
498
+ }
160
499
  if (flags.targets.length === 0) {
161
500
  sink.error(USAGE);
162
501
  return { exitCode: 1 };
@@ -178,9 +517,14 @@ export async function run(argv, cwd, sink = defaultSink) {
178
517
  sink.error(`tailwind-canonical: ${err instanceof Error ? err.message : String(err)}`);
179
518
  return { exitCode: 1 };
180
519
  }
520
+ for (const warning of flagWarnings(flags)) {
521
+ sink.error(`Warning: ${warning}`);
522
+ }
181
523
  const files = await resolveTargets(flags.targets);
182
524
  if (flags.analyze)
183
525
  return runAnalyze(files, config, flags.reporter, sink);
526
+ if (flags.typos)
527
+ return runTypos(files, config, flags.reporter, sink);
184
528
  const transforming = flags.fix || flags.dedup || flags.merge || flags.sort;
185
529
  const totals = { fixed: 0, deduped: 0, merged: 0, sorted: 0 };
186
530
  const allFindings = [];
@@ -229,14 +573,15 @@ export async function run(argv, cwd, sink = defaultSink) {
229
573
  return startWatch(files, flags, config, sink);
230
574
  if (transforming) {
231
575
  if (flags.reporter === 'json') {
232
- sink.write(`${JSON.stringify({
576
+ const report = {
233
577
  files: files.length,
234
578
  changedFiles,
235
579
  fixed: totals.fixed,
236
580
  deduped: totals.deduped,
237
581
  merged: totals.merged,
238
582
  sorted: totals.sorted,
239
- }, null, 2)}\n`);
583
+ };
584
+ sink.write(`${JSON.stringify(report, null, 2)}\n`);
240
585
  return { exitCode: hadError ? 1 : 0 };
241
586
  }
242
587
  const parts = [];
@@ -253,7 +598,7 @@ export async function run(argv, cwd, sink = defaultSink) {
253
598
  }
254
599
  const totalFindings = allFindings.length;
255
600
  if (flags.reporter === 'json') {
256
- sink.write(`${JSON.stringify({
601
+ const report = {
257
602
  files: files.length,
258
603
  total: totalFindings,
259
604
  findings: allFindings.map((f) => ({
@@ -264,47 +609,33 @@ export async function run(argv, cwd, sink = defaultSink) {
264
609
  canonical: f.suggestion.canonical,
265
610
  isCustomToken: f.suggestion.isCustomToken,
266
611
  })),
267
- }, null, 2)}\n`);
612
+ };
613
+ sink.write(`${JSON.stringify(report, null, 2)}\n`);
268
614
  return { exitCode: totalFindings > 0 || hadError ? 1 : 0 };
269
615
  }
270
616
  if (flags.reporter === 'sarif') {
271
- const sarifOutput = {
272
- $schema: SARIF_SCHEMA,
273
- version: '2.1.0',
274
- runs: [
617
+ const sarifOutput = sarifDocument([
618
+ {
619
+ id: 'no-arbitrary-canonical',
620
+ name: 'NoArbitraryCanonical',
621
+ shortDescription: {
622
+ text: 'Arbitrary value has a canonical Tailwind equivalent',
623
+ },
624
+ },
625
+ ], allFindings.map((f) => ({
626
+ ruleId: 'no-arbitrary-canonical',
627
+ message: {
628
+ text: `${f.suggestion.original} → ${f.suggestion.canonical}`,
629
+ },
630
+ locations: [
275
631
  {
276
- tool: {
277
- driver: {
278
- name: 'tailwind-canonical',
279
- informationUri: 'https://github.com/peak-lab/tailwind-canonical',
280
- rules: [
281
- {
282
- id: 'no-arbitrary-canonical',
283
- name: 'NoArbitraryCanonical',
284
- shortDescription: {
285
- text: 'Arbitrary value has a canonical Tailwind equivalent',
286
- },
287
- },
288
- ],
289
- },
632
+ physicalLocation: {
633
+ artifactLocation: { uri: f.file },
634
+ region: { startLine: f.line, startColumn: f.col },
290
635
  },
291
- results: allFindings.map((f) => ({
292
- ruleId: 'no-arbitrary-canonical',
293
- message: {
294
- text: `${f.suggestion.original} → ${f.suggestion.canonical}`,
295
- },
296
- locations: [
297
- {
298
- physicalLocation: {
299
- artifactLocation: { uri: f.file },
300
- region: { startLine: f.line, startColumn: f.col },
301
- },
302
- },
303
- ],
304
- })),
305
636
  },
306
637
  ],
307
- };
638
+ })));
308
639
  sink.write(`${JSON.stringify(sarifOutput, null, 2)}\n`);
309
640
  return { exitCode: totalFindings > 0 || hadError ? 1 : 0 };
310
641
  }