tailwind-canonical 0.6.0 → 0.7.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 (114) hide show
  1. package/README.md +103 -21
  2. package/dist/cli/cli.d.ts +2 -12
  3. package/dist/cli/cli.js +241 -512
  4. package/dist/cli/format.d.ts +3 -0
  5. package/dist/cli/format.js +11 -0
  6. package/dist/cli/index.d.ts +0 -1
  7. package/dist/cli/index.js +0 -1
  8. package/dist/cli/reporters.d.ts +69 -0
  9. package/dist/cli/reporters.js +333 -0
  10. package/dist/cli/types.d.ts +16 -0
  11. package/dist/cli/types.js +1 -0
  12. package/dist/cli/watch.d.ts +11 -0
  13. package/dist/cli/watch.js +33 -0
  14. package/dist/core/analyzer.d.ts +0 -1
  15. package/dist/core/analyzer.js +3 -3
  16. package/dist/core/class-strings.d.ts +0 -1
  17. package/dist/core/class-strings.js +13 -8
  18. package/dist/core/config.d.ts +2 -3
  19. package/dist/core/config.js +34 -44
  20. package/dist/core/consistency.d.ts +1 -2
  21. package/dist/core/consistency.js +1 -6
  22. package/dist/core/deduplicator.d.ts +0 -1
  23. package/dist/core/deduplicator.js +119 -175
  24. package/dist/core/fixer.d.ts +0 -1
  25. package/dist/core/fixer.js +0 -1
  26. package/dist/core/lexicon.d.ts +1 -1
  27. package/dist/core/lexicon.js +1 -2
  28. package/dist/core/merger.d.ts +0 -1
  29. package/dist/core/merger.js +41 -29
  30. package/dist/core/rules.d.ts +2 -1
  31. package/dist/core/rules.js +90 -57
  32. package/dist/core/scanner.d.ts +0 -1
  33. package/dist/core/scanner.js +23 -21
  34. package/dist/core/sorter.d.ts +0 -1
  35. package/dist/core/sorter.js +85 -10
  36. package/dist/core/suppressions.d.ts +1 -3
  37. package/dist/core/suppressions.js +17 -14
  38. package/dist/core/typos.d.ts +0 -1
  39. package/dist/core/typos.js +3 -3
  40. package/dist/eslint/plugin.d.ts +0 -1
  41. package/dist/eslint/plugin.js +64 -30
  42. package/dist/index.d.ts +1 -2
  43. package/dist/index.js +1 -2
  44. package/dist/io/analyzer.d.ts +0 -1
  45. package/dist/io/analyzer.js +0 -1
  46. package/dist/io/config.d.ts +9 -1
  47. package/dist/io/config.js +66 -5
  48. package/dist/io/consistency.d.ts +0 -1
  49. package/dist/io/consistency.js +0 -1
  50. package/dist/io/deduplicator.d.ts +0 -1
  51. package/dist/io/deduplicator.js +0 -1
  52. package/dist/io/fixer.d.ts +0 -1
  53. package/dist/io/fixer.js +0 -1
  54. package/dist/io/merger.d.ts +0 -1
  55. package/dist/io/merger.js +0 -1
  56. package/dist/io/scanner.d.ts +0 -1
  57. package/dist/io/scanner.js +0 -1
  58. package/dist/io/sorter.d.ts +0 -1
  59. package/dist/io/sorter.js +0 -1
  60. package/dist/io/typos.d.ts +0 -1
  61. package/dist/io/typos.js +0 -1
  62. package/package.json +10 -9
  63. package/dist/cli/cli.d.ts.map +0 -1
  64. package/dist/cli/cli.js.map +0 -1
  65. package/dist/cli/index.d.ts.map +0 -1
  66. package/dist/cli/index.js.map +0 -1
  67. package/dist/core/analyzer.d.ts.map +0 -1
  68. package/dist/core/analyzer.js.map +0 -1
  69. package/dist/core/class-strings.d.ts.map +0 -1
  70. package/dist/core/class-strings.js.map +0 -1
  71. package/dist/core/config.d.ts.map +0 -1
  72. package/dist/core/config.js.map +0 -1
  73. package/dist/core/consistency.d.ts.map +0 -1
  74. package/dist/core/consistency.js.map +0 -1
  75. package/dist/core/deduplicator.d.ts.map +0 -1
  76. package/dist/core/deduplicator.js.map +0 -1
  77. package/dist/core/fixer.d.ts.map +0 -1
  78. package/dist/core/fixer.js.map +0 -1
  79. package/dist/core/lexicon.d.ts.map +0 -1
  80. package/dist/core/lexicon.js.map +0 -1
  81. package/dist/core/merger.d.ts.map +0 -1
  82. package/dist/core/merger.js.map +0 -1
  83. package/dist/core/rules.d.ts.map +0 -1
  84. package/dist/core/rules.js.map +0 -1
  85. package/dist/core/scanner.d.ts.map +0 -1
  86. package/dist/core/scanner.js.map +0 -1
  87. package/dist/core/sorter.d.ts.map +0 -1
  88. package/dist/core/sorter.js.map +0 -1
  89. package/dist/core/suppressions.d.ts.map +0 -1
  90. package/dist/core/suppressions.js.map +0 -1
  91. package/dist/core/typos.d.ts.map +0 -1
  92. package/dist/core/typos.js.map +0 -1
  93. package/dist/eslint/plugin.d.ts.map +0 -1
  94. package/dist/eslint/plugin.js.map +0 -1
  95. package/dist/index.d.ts.map +0 -1
  96. package/dist/index.js.map +0 -1
  97. package/dist/io/analyzer.d.ts.map +0 -1
  98. package/dist/io/analyzer.js.map +0 -1
  99. package/dist/io/config.d.ts.map +0 -1
  100. package/dist/io/config.js.map +0 -1
  101. package/dist/io/consistency.d.ts.map +0 -1
  102. package/dist/io/consistency.js.map +0 -1
  103. package/dist/io/deduplicator.d.ts.map +0 -1
  104. package/dist/io/deduplicator.js.map +0 -1
  105. package/dist/io/fixer.d.ts.map +0 -1
  106. package/dist/io/fixer.js.map +0 -1
  107. package/dist/io/merger.d.ts.map +0 -1
  108. package/dist/io/merger.js.map +0 -1
  109. package/dist/io/scanner.d.ts.map +0 -1
  110. package/dist/io/scanner.js.map +0 -1
  111. package/dist/io/sorter.d.ts.map +0 -1
  112. package/dist/io/sorter.js.map +0 -1
  113. package/dist/io/typos.d.ts.map +0 -1
  114. package/dist/io/typos.js.map +0 -1
package/dist/cli/cli.js CHANGED
@@ -1,21 +1,20 @@
1
- import { existsSync, watch as fsWatch, readFileSync, writeFileSync, } from 'node:fs';
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join, resolve } from 'node:path';
3
3
  import { analyzeFile } from '../core/analyzer.js';
4
4
  import { toClassStringOpts } from '../core/class-strings.js';
5
- import { CONFIG_FILENAMES, loadConfig } from '../core/config.js';
5
+ import { CONFIG_FILENAMES, loadConfig, resolveInitFilename, } from '../core/config.js';
6
6
  import { analyzeConsistencyFiles, toConsistencyOptions, } from '../core/consistency.js';
7
- import { dedupeContent, dedupeFile } from '../core/deduplicator.js';
8
- import { fixContent, fixFile } from '../core/fixer.js';
9
- import { mergeContent, mergeFile } from '../core/merger.js';
7
+ import { dedupeContent } from '../core/deduplicator.js';
8
+ import { fixContent } from '../core/fixer.js';
9
+ import { mergeContent } from '../core/merger.js';
10
10
  import { resolveTargets } from '../core/scanner.js';
11
- import { sortContent, sortFile } from '../core/sorter.js';
11
+ import { sortContent } from '../core/sorter.js';
12
12
  import { analyzeTyposFile } from '../core/typos.js';
13
- const SARIF_SCHEMA = 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json';
13
+ import { errMsg, pluralize, timestamp } from './format.js';
14
+ import { analyzeSarifDocument, findingsSarifDocument, logAnalyzeText, logFindings, logTransformCounts, logTyposText, logWatchFindings, sarifDocument, typoSarifDocument, writeJson, } from './reporters.js';
15
+ import { startWatch } from './watch.js';
14
16
  const USAGE = 'Usage: tailwind-canonical [--fix] [--merge] [--dedup] [--sort] [--check] [--analyze] [--typos] [--watch] [--reporter json|sarif] <dir|file> [dir|file...]\n tailwind-canonical init';
15
- const INIT_CONFIG_CONTENT = [
16
- "import type { Config } from 'tailwind-canonical'",
17
- '',
18
- 'export default {',
17
+ const INIT_CONFIG_BODY = [
19
18
  ' // Run `tailwind-canonical` with no flags using these defaults:',
20
19
  " // defaultCommand: { fix: true, dedup: true, sort: true, typos: true, targets: ['./src'] },",
21
20
  ' // px → token additions/overrides:',
@@ -23,13 +22,26 @@ const INIT_CONFIG_CONTENT = [
23
22
  " // customSpacingTokens: { 14: '3.5' },",
24
23
  ' // Never suggest replacements for classes matching:',
25
24
  ' // ignorePatterns: [/^font-/],',
25
+ ];
26
+ const INIT_CONFIG_TS = [
27
+ "import type { Config } from 'tailwind-canonical'",
28
+ '',
29
+ 'export default {',
30
+ ...INIT_CONFIG_BODY,
26
31
  '} satisfies Config',
27
32
  '',
28
33
  ].join('\n');
34
+ const INIT_CONFIG_JS = [
35
+ "/** @type {import('tailwind-canonical').Config} */",
36
+ 'export default {',
37
+ ...INIT_CONFIG_BODY,
38
+ '}',
39
+ '',
40
+ ].join('\n');
29
41
  const HELP_TEXT = [
30
42
  USAGE,
31
43
  '',
32
- ' init Create a tailwind-canonical.config.ts scaffold in the current directory',
44
+ ' init Create a tailwind-canonical config scaffold in the current directory',
33
45
  ' --fix Auto-replace arbitrary values with canonical Tailwind classes',
34
46
  ' --dedup Remove redundant classes and collapse shorthands',
35
47
  ' --merge Resolve conflicting classes via tailwind-merge',
@@ -51,49 +63,6 @@ const defaultSink = {
51
63
  write: (s) => process.stdout.write(s),
52
64
  };
53
65
  const KNOWN_CLASS_FUNCTIONS = ['cn', 'clsx', 'cva'];
54
- const DEFAULT_ANALYZE_TEXT_OPTIONS = {
55
- maxScaleGroups: 8,
56
- maxScaleValues: 5,
57
- maxRareValues: 12,
58
- maxPatterns: 10,
59
- };
60
- function sarifDocument(rules, results) {
61
- return {
62
- $schema: SARIF_SCHEMA,
63
- version: '2.1.0',
64
- runs: [
65
- {
66
- tool: {
67
- driver: {
68
- name: 'tailwind-canonical',
69
- informationUri: 'https://github.com/peak-lab/tailwind-canonical',
70
- rules,
71
- },
72
- },
73
- results,
74
- },
75
- ],
76
- };
77
- }
78
- function fileLocations(files) {
79
- return files.map((uri) => ({
80
- physicalLocation: {
81
- artifactLocation: { uri },
82
- region: { startLine: 1, startColumn: 1 },
83
- },
84
- }));
85
- }
86
- function errMsg(e) {
87
- return e instanceof Error ? e.message : String(e);
88
- }
89
- function pluralize(n, word) {
90
- return `${n} ${word}${n !== 1 ? 's' : ''}`;
91
- }
92
- function timestamp() {
93
- const d = new Date();
94
- const pad = (n) => n.toString().padStart(2, '0');
95
- return `[${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}]`;
96
- }
97
66
  const REPORTERS = ['text', 'json', 'sarif'];
98
67
  const MODE_FLAGS = [
99
68
  '--fix',
@@ -185,35 +154,26 @@ export function parseArgs(argv) {
185
154
  hasExplicitCheck: argv.includes('--check'),
186
155
  };
187
156
  }
157
+ /** Explicit CLI flags win; otherwise `defaultCommand` fills the gap. */
158
+ function fallback(explicit, flag, configured) {
159
+ return explicit ? flag : (configured ?? flag);
160
+ }
188
161
  function applyDefaultCommand(flags, config) {
189
162
  const defaults = config.defaultCommand;
190
163
  if (!defaults)
191
164
  return flags;
165
+ const mode = flags.hasExplicitMode;
192
166
  return {
193
167
  ...flags,
194
- fix: flags.hasExplicitMode ? flags.fix : (defaults.fix ?? flags.fix),
195
- merge: flags.hasExplicitMode
196
- ? flags.merge
197
- : (defaults.merge ?? flags.merge),
198
- dedup: flags.hasExplicitMode
199
- ? flags.dedup
200
- : (defaults.dedup ?? flags.dedup),
201
- sort: flags.hasExplicitMode ? flags.sort : (defaults.sort ?? flags.sort),
202
- analyze: flags.hasExplicitMode
203
- ? flags.analyze
204
- : (defaults.analyze ?? flags.analyze),
205
- typos: flags.hasExplicitMode
206
- ? flags.typos
207
- : (defaults.typos ?? flags.typos),
208
- watch: flags.hasExplicitWatch
209
- ? flags.watch
210
- : (defaults.watch ?? flags.watch),
211
- check: flags.hasExplicitCheck
212
- ? flags.check
213
- : (defaults.check ?? flags.check),
214
- reporter: flags.hasExplicitReporter
215
- ? flags.reporter
216
- : (defaults.reporter ?? flags.reporter),
168
+ fix: fallback(mode, flags.fix, defaults.fix),
169
+ merge: fallback(mode, flags.merge, defaults.merge),
170
+ dedup: fallback(mode, flags.dedup, defaults.dedup),
171
+ sort: fallback(mode, flags.sort, defaults.sort),
172
+ analyze: fallback(mode, flags.analyze, defaults.analyze),
173
+ typos: fallback(mode, flags.typos, defaults.typos),
174
+ watch: fallback(flags.hasExplicitWatch, flags.watch, defaults.watch),
175
+ check: fallback(flags.hasExplicitCheck, flags.check, defaults.check),
176
+ reporter: fallback(flags.hasExplicitReporter, flags.reporter, defaults.reporter),
217
177
  targets: flags.targets.length > 0 ? flags.targets : (defaults.targets ?? []),
218
178
  };
219
179
  }
@@ -274,102 +234,104 @@ function collectTypos(files, config, sink) {
274
234
  }
275
235
  return { findings, hadError };
276
236
  }
277
- function logTyposText(findings, sink) {
278
- for (const f of findings) {
279
- sink.log(` ${f.file}:${f.line}:${f.col} ${f.original} → ${f.suggestion} [typo]`);
237
+ function readVersion() {
238
+ const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
239
+ return pkg.version;
240
+ }
241
+ function findProjectRoot(cwd) {
242
+ let dir = resolve(cwd);
243
+ while (true) {
244
+ if (existsSync(join(dir, '.git')))
245
+ return dir;
246
+ const parent = dirname(dir);
247
+ if (parent === dir)
248
+ return resolve(cwd);
249
+ dir = parent;
280
250
  }
281
- if (findings.length === 0) {
282
- sink.log('✓ No likely typos found');
251
+ }
252
+ function sarifContext(cwd) {
253
+ let version;
254
+ try {
255
+ version = readVersion();
283
256
  }
284
- else {
285
- sink.log(`\n✖ Found ${pluralize(findings.length, 'likely typo')}`);
257
+ catch {
258
+ version = undefined;
286
259
  }
260
+ return { root: findProjectRoot(cwd), version };
287
261
  }
288
- function typoSarifDocument(findings) {
289
- return sarifDocument([
290
- {
291
- id: 'color-typo',
292
- name: 'ColorTypo',
293
- shortDescription: {
294
- text: 'Class color name is a likely typo of a Tailwind color',
295
- },
296
- },
297
- ], findings.map((f) => ({
298
- ruleId: 'color-typo',
299
- message: { text: `${f.original} → ${f.suggestion}` },
300
- locations: [
301
- {
302
- physicalLocation: {
303
- artifactLocation: { uri: f.file },
304
- region: { startLine: f.line, startColumn: f.col },
305
- },
306
- },
307
- ],
308
- })));
309
- }
310
- function runTypos(files, config, reporter, sink) {
262
+ function runTypos(files, config, reporter, sink, sarif) {
311
263
  const { findings, hadError } = collectTypos(files, config, sink);
312
264
  if (reporter === 'json') {
313
265
  const report = {
314
266
  total: findings.length,
315
267
  typos: findings,
316
268
  };
317
- sink.write(`${JSON.stringify(report, null, 2)}\n`);
318
- return { exitCode: findings.length > 0 || hadError ? 1 : 0 };
269
+ writeJson(sink, report);
319
270
  }
320
- if (reporter === 'sarif') {
321
- sink.write(`${JSON.stringify(typoSarifDocument(findings), null, 2)}\n`);
322
- return { exitCode: findings.length > 0 || hadError ? 1 : 0 };
271
+ else if (reporter === 'sarif') {
272
+ writeJson(sink, typoSarifDocument(findings, sarif));
323
273
  }
324
- logTyposText(findings, sink);
325
- return { exitCode: findings.length > 0 || hadError ? 1 : 0 };
326
- }
327
- async function processFile(file, flags, config) {
328
- const opts = toClassStringOpts(config);
329
- const counts = { fixed: 0, deduped: 0, merged: 0, sorted: 0 };
330
- if (flags.fix)
331
- counts.fixed = fixFile(file, config);
332
- if (flags.dedup)
333
- counts.deduped = dedupeFile(file, opts);
334
- if (flags.merge)
335
- counts.merged = await mergeFile(file, opts);
336
- if (flags.sort)
337
- counts.sorted = sortFile(file, opts, config.sortOrder);
338
- return counts;
274
+ else {
275
+ logTyposText(findings, sink);
276
+ }
277
+ return exitWith(findings.length > 0 || hadError);
339
278
  }
340
- async function checkFile(file, flags, config, twMerge) {
279
+ function applyTransforms(content, flags, config, twMerge) {
341
280
  const opts = toClassStringOpts(config);
342
- const counts = { fixed: 0, deduped: 0, merged: 0, sorted: 0 };
343
- let content = readFileSync(file, 'utf8');
281
+ const counts = emptyCounts();
282
+ let result = content;
344
283
  if (flags.fix) {
345
- const { result, count } = fixContent(content, config);
346
- counts.fixed = count;
347
- content = result;
284
+ const fixed = fixContent(result, config);
285
+ counts.fixed = fixed.count;
286
+ result = fixed.result;
348
287
  }
349
288
  if (flags.dedup) {
350
- const { result, count } = dedupeContent(content, opts);
351
- counts.deduped = count;
352
- content = result;
289
+ const deduped = dedupeContent(result, opts);
290
+ counts.deduped = deduped.count;
291
+ result = deduped.result;
353
292
  }
354
- if (flags.merge && twMerge) {
355
- const { result, count } = mergeContent(content, twMerge, opts);
356
- counts.merged = count;
357
- content = result;
293
+ if (flags.merge) {
294
+ if (!twMerge) {
295
+ throw new Error('--merge requires the optional peer dependency "tailwind-merge". Install it with: pnpm add -D tailwind-merge');
296
+ }
297
+ const merged = mergeContent(result, twMerge, opts);
298
+ counts.merged = merged.count;
299
+ result = merged.result;
358
300
  }
359
301
  if (flags.sort) {
360
- const { result, count } = sortContent(content, opts, config.sortOrder);
361
- counts.sorted = count;
362
- content = result;
302
+ const sorted = sortContent(result, opts, config.sortOrder);
303
+ counts.sorted = sorted.count;
304
+ result = sorted.result;
363
305
  }
306
+ return { counts, result };
307
+ }
308
+ function processFile(file, flags, config, twMerge) {
309
+ const content = readFileSync(file, 'utf8');
310
+ const { counts, result } = applyTransforms(content, flags, config, twMerge);
311
+ // Counts, not text equality: replaceClassStrings also normalizes attribute
312
+ // spacing, which no transform counts. Writing on any textual difference
313
+ // would rewrite files that --check truthfully reports as clean.
314
+ if (totalOf(counts) > 0)
315
+ writeFileSync(file, result, 'utf8');
364
316
  return counts;
365
317
  }
366
318
  function totalOf(c) {
367
319
  return c.fixed + c.deduped + c.merged + c.sorted;
368
320
  }
369
- function scaleClass(property, value) {
370
- return value.startsWith('-')
371
- ? `-${property}-${value.slice(1)}`
372
- : `${property}-${value}`;
321
+ function emptyCounts() {
322
+ return { fixed: 0, deduped: 0, merged: 0, sorted: 0 };
323
+ }
324
+ function addCounts(target, source) {
325
+ target.fixed += source.fixed;
326
+ target.deduped += source.deduped;
327
+ target.merged += source.merged;
328
+ target.sorted += source.sorted;
329
+ }
330
+ function isTransforming(flags) {
331
+ return flags.fix || flags.dedup || flags.merge || flags.sort;
332
+ }
333
+ function exitWith(failed) {
334
+ return { exitCode: failed ? 1 : 0 };
373
335
  }
374
336
  function findUnconfiguredClassFunctions(files, config) {
375
337
  const configured = new Set(config.functionNames ?? []);
@@ -392,93 +354,7 @@ function findUnconfiguredClassFunctions(files, config) {
392
354
  }
393
355
  return [...found].sort();
394
356
  }
395
- function compactPath(file) {
396
- const normalized = file.replace(/\\/g, '/');
397
- const parts = normalized.split('/');
398
- if (parts.length <= 4)
399
- return normalized;
400
- return `.../${parts.slice(-4).join('/')}`;
401
- }
402
- function withMore(values, limit, format) {
403
- const shown = values.slice(0, limit).map(format);
404
- const remaining = values.length - shown.length;
405
- if (remaining > 0)
406
- shown.push(`+${remaining} more`);
407
- return shown.join(', ');
408
- }
409
- function scaleValueSummary(property, value) {
410
- return `${scaleClass(property, value.value)} (${pluralize(value.count, 'use')}, ${pluralize(value.files.length, 'file')})`;
411
- }
412
- function scaleTotalUses(scale) {
413
- return scale.values.reduce((sum, value) => sum + value.count, 0);
414
- }
415
- function logAnalyzeText(report, issueCount, config, sink) {
416
- const textOptions = {
417
- maxScaleGroups: config.analyze?.maxScaleGroups ??
418
- DEFAULT_ANALYZE_TEXT_OPTIONS.maxScaleGroups,
419
- maxScaleValues: config.analyze?.maxScaleValues ??
420
- DEFAULT_ANALYZE_TEXT_OPTIONS.maxScaleValues,
421
- maxRareValues: config.analyze?.maxRareValues ??
422
- DEFAULT_ANALYZE_TEXT_OPTIONS.maxRareValues,
423
- maxPatterns: config.analyze?.maxPatterns ?? DEFAULT_ANALYZE_TEXT_OPTIONS.maxPatterns,
424
- };
425
- sink.log('tailwind-canonical analyze');
426
- sink.log(`Files analyzed: ${report.filesAnalyzed}`);
427
- sink.log(`Issue groups: ${issueCount} (${pluralize(report.colorVariants.length, 'color')}, ${pluralize(report.scaleInconsistencies.length, 'scale')}, ${pluralize(report.combinations.length, 'pattern')})`);
428
- if (report.rareScaleValues.length > 0) {
429
- sink.log(`Rare values: ${report.rareScaleValues.length}`);
430
- }
431
- if (issueCount === 0) {
432
- sink.log('\nNo cross-file inconsistencies found');
433
- return;
434
- }
435
- if (report.colorVariants.length > 0) {
436
- sink.log('\nColor variants');
437
- for (const group of report.colorVariants) {
438
- const tokens = group.variants
439
- .map((v) => `${group.property}-${v.token} x${v.count}`)
440
- .join(', ');
441
- sink.log(` - ${group.property}/${group.family}: ${tokens}`);
442
- }
443
- }
444
- if (report.scaleInconsistencies.length > 0) {
445
- sink.log('\nScale inconsistency groups');
446
- const scales = [...report.scaleInconsistencies].sort((a, b) => scaleTotalUses(b) - scaleTotalUses(a) ||
447
- a.property.localeCompare(b.property));
448
- for (const scale of scales.slice(0, textOptions.maxScaleGroups)) {
449
- const totalUses = scaleTotalUses(scale);
450
- const files = new Set(scale.values.flatMap((value) => value.files));
451
- sink.log(` - ${scale.property}: ${scale.values.length} values, ${totalUses} uses, ${pluralize(files.size, 'file')}`);
452
- sink.log(` Top: ${withMore(scale.values, textOptions.maxScaleValues, (value) => scaleValueSummary(scale.property, value))}`);
453
- }
454
- const remaining = scales.length - textOptions.maxScaleGroups;
455
- if (remaining > 0)
456
- sink.log(` - +${remaining} more scale groups`);
457
- }
458
- if (report.rareScaleValues.length > 0) {
459
- sink.log('\nRare scale values');
460
- for (const rare of report.rareScaleValues.slice(0, textOptions.maxRareValues)) {
461
- const example = rare.files[0]
462
- ? `; e.g. ${compactPath(rare.files[0])}`
463
- : '';
464
- sink.log(` - ${rare.className}: ${pluralize(rare.count, 'use')} in ${pluralize(rare.files.length, 'file')} (${rare.propertyCount} ${rare.property} uses total)${example}`);
465
- }
466
- const remaining = report.rareScaleValues.length - textOptions.maxRareValues;
467
- if (remaining > 0)
468
- sink.log(` - +${remaining} more rare values`);
469
- }
470
- if (report.combinations.length > 0) {
471
- sink.log('\nRepeated patterns');
472
- for (const combo of report.combinations.slice(0, textOptions.maxPatterns)) {
473
- sink.log(` - Pattern: "${combo.classes.join(' ')}" repeated in ${pluralize(combo.files.length, 'file')}`);
474
- }
475
- const remaining = report.combinations.length - textOptions.maxPatterns;
476
- if (remaining > 0)
477
- sink.log(` - +${remaining} more repeated patterns`);
478
- }
479
- sink.log(`\nFound ${pluralize(issueCount, 'consistency issue')} across ${pluralize(report.filesAnalyzed, 'file')}`);
480
- }
481
- function runAnalyze(files, config, reporter, sink) {
357
+ function runAnalyze(files, config, reporter, sink, sarif) {
482
358
  let hadError = false;
483
359
  const options = toConsistencyOptions(config);
484
360
  const report = analyzeConsistencyFiles(files, config, options, (file, err) => {
@@ -496,135 +372,15 @@ function runAnalyze(files, config, reporter, sink) {
496
372
  sink.error(`Warning: detected ${calls} calls but functionNames does not include them; --analyze may miss class strings.`);
497
373
  }
498
374
  if (reporter === 'json') {
499
- sink.write(`${JSON.stringify(report, null, 2)}\n`);
500
- return { exitCode: issueCount > 0 || hadError ? 1 : 0 };
375
+ writeJson(sink, report);
501
376
  }
502
- if (reporter === 'sarif') {
503
- const results = [];
504
- for (const group of report.colorVariants) {
505
- const tokens = group.variants.map((v) => v.token).join(', ');
506
- const files = [...new Set(group.variants.flatMap((v) => v.files))];
507
- results.push({
508
- ruleId: 'color-variant-inconsistency',
509
- message: {
510
- text: `${group.variants.length} ${group.family} color variants for ${group.property}: ${tokens}`,
511
- },
512
- locations: fileLocations(files),
513
- });
514
- }
515
- for (const scale of report.scaleInconsistencies) {
516
- const values = scale.values
517
- .map((v) => scaleClass(scale.property, v.value))
518
- .join(' vs ');
519
- const files = [...new Set(scale.values.flatMap((v) => v.files))];
520
- results.push({
521
- ruleId: 'scale-inconsistency',
522
- message: { text: `${scale.property} inconsistency: ${values}` },
523
- locations: fileLocations(files),
524
- });
525
- }
526
- for (const rare of report.rareScaleValues) {
527
- results.push({
528
- ruleId: 'rare-scale-value',
529
- message: {
530
- text: `${rare.className} is rare for ${rare.property}: ${rare.count} occurrence(s) in ${rare.files.length} file(s), within ${rare.propertyCount} ${rare.property} uses`,
531
- },
532
- locations: fileLocations(rare.files),
533
- });
534
- }
535
- for (const combo of report.combinations) {
536
- results.push({
537
- ruleId: 'repeated-combination',
538
- message: {
539
- text: `Repeated class combination: ${combo.classes.join(' ')}`,
540
- },
541
- locations: fileLocations(combo.files),
542
- });
543
- }
544
- const sarifOutput = sarifDocument([
545
- {
546
- id: 'color-variant-inconsistency',
547
- name: 'ColorVariantInconsistency',
548
- shortDescription: {
549
- text: 'Multiple color variants of the same family used for one property',
550
- },
551
- },
552
- {
553
- id: 'scale-inconsistency',
554
- name: 'ScaleInconsistency',
555
- shortDescription: {
556
- text: 'Inconsistent scale values used for the same property',
557
- },
558
- },
559
- {
560
- id: 'rare-scale-value',
561
- name: 'RareScaleValue',
562
- shortDescription: {
563
- text: 'A scale value appears rarely within an otherwise common property',
564
- },
565
- },
566
- {
567
- id: 'repeated-combination',
568
- name: 'RepeatedCombination',
569
- shortDescription: {
570
- text: 'Identical class combination repeated across files',
571
- },
572
- },
573
- ], results);
574
- sink.write(`${JSON.stringify(sarifOutput, null, 2)}\n`);
575
- return { exitCode: issueCount > 0 || hadError ? 1 : 0 };
576
- }
577
- logAnalyzeText(report, issueCount, config, sink);
578
- return { exitCode: issueCount > 0 || hadError ? 1 : 0 };
579
- }
580
- function startWatch(files, flags, config, sink) {
581
- const transforming = flags.fix || flags.dedup || flags.merge || flags.sort;
582
- const fileSet = new Set(files);
583
- const inFlight = new Set();
584
- const timers = new Map();
585
- const dirs = new Set(files.map((f) => dirname(f)));
586
- sink.log(`Watching ${pluralize(files.length, 'file')}... (Ctrl+C to stop)`);
587
- for (const dir of dirs) {
588
- fsWatch(dir, { recursive: true }, (_, filename) => {
589
- if (!filename)
590
- return;
591
- const full = resolve(dir, filename);
592
- if (!fileSet.has(full) || inFlight.has(full))
593
- return;
594
- clearTimeout(timers.get(full));
595
- timers.set(full, setTimeout(() => {
596
- if (transforming) {
597
- inFlight.add(full);
598
- processFile(full, flags, config)
599
- .then((counts) => {
600
- const total = totalOf(counts);
601
- if (total > 0) {
602
- sink.log(`${timestamp()} ${full} — ${pluralize(total, 'change')} applied`);
603
- }
604
- })
605
- .catch((err) => {
606
- sink.error(`${timestamp()} ${full} — error: ${err instanceof Error ? err.message : String(err)}`);
607
- })
608
- .finally(() => inFlight.delete(full));
609
- }
610
- else {
611
- const findings = analyzeFile(full, config);
612
- if (findings.length > 0) {
613
- sink.log(`${timestamp()} ${full} — ${pluralize(findings.length, 'finding')}`);
614
- for (const f of findings) {
615
- const tag = f.suggestion.isCustomToken ? ' [custom token]' : '';
616
- sink.log(` ${f.line}:${f.col} ${f.suggestion.original} → ${f.suggestion.canonical}${tag}`);
617
- }
618
- }
619
- }
620
- }, 50));
621
- });
377
+ else if (reporter === 'sarif') {
378
+ writeJson(sink, analyzeSarifDocument(report, sarif));
622
379
  }
623
- process.once('SIGINT', () => {
624
- sink.log('\nWatcher stopped.');
625
- process.exit(0);
626
- });
627
- return { exitCode: 0, watching: true };
380
+ else {
381
+ logAnalyzeText(report, issueCount, config, sink);
382
+ }
383
+ return exitWith(issueCount > 0 || hadError);
628
384
  }
629
385
  function runInit(cwd, sink) {
630
386
  const existing = CONFIG_FILENAMES.find((name) => existsSync(join(cwd, name)));
@@ -632,8 +388,9 @@ function runInit(cwd, sink) {
632
388
  sink.error(`${existing} already exists`);
633
389
  return { exitCode: 1 };
634
390
  }
635
- const filename = CONFIG_FILENAMES[0];
636
- writeFileSync(join(cwd, filename), INIT_CONFIG_CONTENT, 'utf8');
391
+ const filename = resolveInitFilename(cwd);
392
+ const content = /\.m?ts$/.test(filename) ? INIT_CONFIG_TS : INIT_CONFIG_JS;
393
+ writeFileSync(join(cwd, filename), content, 'utf8');
637
394
  sink.log(`Created ${filename}`);
638
395
  sink.log('Uncomment defaultCommand to set your CLI defaults.');
639
396
  sink.log('Then run `npx tailwind-canonical` to lint your project.');
@@ -647,8 +404,7 @@ export async function run(argv, cwd, sink = defaultSink) {
647
404
  }
648
405
  if (flags.version) {
649
406
  try {
650
- const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
651
- sink.log(pkg.version);
407
+ sink.log(readVersion());
652
408
  return { exitCode: 0 };
653
409
  }
654
410
  catch (err) {
@@ -680,7 +436,7 @@ export async function run(argv, cwd, sink = defaultSink) {
680
436
  config = await loadConfig(cwd);
681
437
  }
682
438
  catch (err) {
683
- sink.error(`tailwind-canonical: ${err instanceof Error ? err.message : String(err)}`);
439
+ sink.error(`tailwind-canonical: ${errMsg(err)}`);
684
440
  return { exitCode: 1 };
685
441
  }
686
442
  flags = applyDefaultCommand(flags, config);
@@ -688,12 +444,7 @@ export async function run(argv, cwd, sink = defaultSink) {
688
444
  sink.error(USAGE);
689
445
  return { exitCode: 1 };
690
446
  }
691
- if (flags.check &&
692
- !flags.analyze &&
693
- !flags.fix &&
694
- !flags.dedup &&
695
- !flags.merge &&
696
- !flags.sort) {
447
+ if (flags.check && !flags.analyze && !isTransforming(flags)) {
697
448
  sink.error('--check requires at least one of --fix, --dedup, --merge, --sort');
698
449
  return { exitCode: 1 };
699
450
  }
@@ -716,125 +467,126 @@ export async function run(argv, cwd, sink = defaultSink) {
716
467
  sink.error(`No files matched: ${flags.targets.join(' ')}`);
717
468
  return { exitCode: 1 };
718
469
  }
719
- if (flags.analyze)
720
- return runAnalyze(files, config, flags.reporter, sink);
721
- const transforming = flags.fix || flags.dedup || flags.merge || flags.sort;
470
+ const sarif = sarifContext(cwd);
471
+ if (flags.analyze) {
472
+ return runAnalyze(files, config, flags.reporter, sink, sarif);
473
+ }
474
+ const transforming = isTransforming(flags);
722
475
  if (flags.typos && !transforming) {
723
- return runTypos(files, config, flags.reporter, sink);
476
+ return runTypos(files, config, flags.reporter, sink, sarif);
477
+ }
478
+ if (flags.watch && !flags.typos && !flags.check) {
479
+ return startWatch({
480
+ files,
481
+ sink,
482
+ processFile: (file) => transforming
483
+ ? watchTransform(file, flags, config, sink, twMerge)
484
+ : logWatchFindings(file, analyzeFile(file, config), timestamp(), sink),
485
+ });
486
+ }
487
+ return transforming
488
+ ? runTransforms(files, flags, config, sink, sarif, twMerge)
489
+ : runLint(files, config, flags.reporter, sink, sarif);
490
+ }
491
+ function watchTransform(file, flags, config, sink, twMerge) {
492
+ const total = totalOf(processFile(file, flags, config, twMerge));
493
+ if (total > 0) {
494
+ sink.log(`${timestamp()} ${file} — ${pluralize(total, 'change')} applied`);
724
495
  }
725
- const watching = flags.watch && !flags.typos && !flags.check;
726
- const totals = { fixed: 0, deduped: 0, merged: 0, sorted: 0 };
727
- const allFindings = [];
496
+ }
497
+ function runTransforms(files, flags, config, sink, sarif, twMerge) {
498
+ const totals = emptyCounts();
728
499
  const changedFiles = [];
729
500
  let hadError = false;
730
- if (!watching) {
731
- for (const file of files) {
732
- try {
733
- if (transforming) {
734
- const counts = flags.check
735
- ? await checkFile(file, flags, config, twMerge)
736
- : await processFile(file, flags, config);
737
- totals.fixed += counts.fixed;
738
- totals.deduped += counts.deduped;
739
- totals.merged += counts.merged;
740
- totals.sorted += counts.sorted;
741
- if (totalOf(counts) > 0)
742
- changedFiles.push(file);
743
- if (flags.reporter === 'text') {
744
- if (counts.fixed > 0)
745
- sink.log(` ${flags.check ? 'would fix' : 'fixed '} ${file} (${pluralize(counts.fixed, 'replacement')})`);
746
- if (counts.deduped > 0)
747
- sink.log(` ${flags.check ? 'would dedup' : 'deduped'} ${file} (${pluralize(counts.deduped, 'class string')})`);
748
- if (counts.merged > 0)
749
- sink.log(` ${flags.check ? 'would merge' : 'merged '} ${file} (${pluralize(counts.merged, 'conflict')})`);
750
- if (counts.sorted > 0)
751
- sink.log(` ${flags.check ? 'would sort' : 'sorted '} ${file} (${pluralize(counts.sorted, 'class string')})`);
752
- }
753
- }
754
- else {
755
- const findings = analyzeFile(file, config);
756
- allFindings.push(...findings);
757
- if (flags.reporter === 'text') {
758
- for (const f of findings) {
759
- const tag = f.suggestion.isCustomToken ? ' [custom token]' : '';
760
- sink.log(` ${f.file}:${f.line}:${f.col} ${f.suggestion.original} → ${f.suggestion.canonical}${tag}`);
761
- }
762
- }
763
- }
764
- }
765
- catch (err) {
766
- hadError = true;
767
- sink.error(`${file}: ${errMsg(err)}`);
501
+ for (const file of files) {
502
+ try {
503
+ const counts = flags.check
504
+ ? applyTransforms(readFileSync(file, 'utf8'), flags, config, twMerge)
505
+ .counts
506
+ : processFile(file, flags, config, twMerge);
507
+ addCounts(totals, counts);
508
+ if (totalOf(counts) > 0)
509
+ changedFiles.push(file);
510
+ if (flags.reporter === 'text') {
511
+ logTransformCounts(counts, file, flags.check, sink);
768
512
  }
769
513
  }
770
- }
771
- if (watching)
772
- return startWatch(files, flags, config, sink);
773
- if (transforming) {
774
- const typoResult = flags.typos
775
- ? collectTypos(files, config, sink)
776
- : undefined;
777
- const totalChanges = totalOf(totals);
778
- const exitCode = hadError ||
779
- (flags.check && totalChanges > 0) ||
780
- (typoResult && (typoResult.findings.length > 0 || typoResult.hadError))
781
- ? 1
782
- : 0;
783
- if (flags.reporter === 'json') {
784
- const report = {
785
- files: files.length,
786
- changedFiles,
787
- fixed: totals.fixed,
788
- deduped: totals.deduped,
789
- merged: totals.merged,
790
- sorted: totals.sorted,
791
- };
792
- if (flags.check)
793
- report.check = true;
794
- if (typoResult) {
795
- report.typoTotal = typoResult.findings.length;
796
- report.typos = typoResult.findings;
797
- }
798
- sink.write(`${JSON.stringify(report, null, 2)}\n`);
799
- return { exitCode };
514
+ catch (err) {
515
+ hadError = true;
516
+ sink.error(`${file}: ${errMsg(err)}`);
800
517
  }
801
- if (flags.reporter === 'sarif') {
802
- const doc = typoResult
803
- ? typoSarifDocument(typoResult.findings)
804
- : sarifDocument([], []);
805
- sink.write(`${JSON.stringify(doc, null, 2)}\n`);
806
- return { exitCode };
518
+ }
519
+ const typoResult = flags.typos
520
+ ? collectTypos(files, config, sink)
521
+ : undefined;
522
+ const totalChanges = totalOf(totals);
523
+ const result = exitWith(hadError ||
524
+ (flags.check && totalChanges > 0) ||
525
+ (typoResult !== undefined &&
526
+ (typoResult.findings.length > 0 || typoResult.hadError)));
527
+ if (flags.reporter === 'json') {
528
+ const report = {
529
+ files: files.length,
530
+ changedFiles,
531
+ ...totals,
532
+ };
533
+ if (flags.check)
534
+ report.check = true;
535
+ if (typoResult) {
536
+ report.typoTotal = typoResult.findings.length;
537
+ report.typos = typoResult.findings;
807
538
  }
808
- if (flags.check) {
809
- if (totalChanges > 0) {
810
- sink.log(`\n✖ ${pluralize(totalChanges, 'pending change')} across ${pluralize(changedFiles.length, 'file')} (run without --check to apply)`);
811
- }
812
- else {
813
- sink.log('✓ No pending changes');
814
- }
539
+ writeJson(sink, report);
540
+ return result;
541
+ }
542
+ if (flags.reporter === 'sarif') {
543
+ const doc = typoResult
544
+ ? typoSarifDocument(typoResult.findings, sarif)
545
+ : sarifDocument([], [], sarif);
546
+ writeJson(sink, doc);
547
+ return result;
548
+ }
549
+ if (flags.check) {
550
+ sink.log(totalChanges > 0
551
+ ? `\n✖ ${pluralize(totalChanges, 'pending change')} across ${pluralize(changedFiles.length, 'file')} (run without --check to apply)`
552
+ : '✓ No pending changes');
553
+ }
554
+ else {
555
+ const parts = [];
556
+ if (flags.fix)
557
+ parts.push(pluralize(totals.fixed, 'replacement'));
558
+ if (flags.dedup)
559
+ parts.push(pluralize(totals.deduped, 'dedup'));
560
+ if (flags.merge)
561
+ parts.push(`${pluralize(totals.merged, 'conflict')} merged`);
562
+ if (flags.sort)
563
+ parts.push(`${totals.sorted} sorted`);
564
+ sink.log(`\n✓ Fixed ${parts.join(', ')} across ${pluralize(files.length, 'file')}`);
565
+ }
566
+ if (typoResult)
567
+ logTyposText(typoResult.findings, sink);
568
+ return result;
569
+ }
570
+ function runLint(files, config, reporter, sink, sarif) {
571
+ const findings = [];
572
+ let hadError = false;
573
+ for (const file of files) {
574
+ try {
575
+ const fileFindings = analyzeFile(file, config);
576
+ findings.push(...fileFindings);
577
+ if (reporter === 'text')
578
+ logFindings(fileFindings, sink);
815
579
  }
816
- else {
817
- const parts = [];
818
- if (flags.fix)
819
- parts.push(pluralize(totals.fixed, 'replacement'));
820
- if (flags.dedup)
821
- parts.push(pluralize(totals.deduped, 'dedup'));
822
- if (flags.merge)
823
- parts.push(`${pluralize(totals.merged, 'conflict')} merged`);
824
- if (flags.sort)
825
- parts.push(`${totals.sorted} sorted`);
826
- sink.log(`\n✓ Fixed ${parts.join(', ')} across ${pluralize(files.length, 'file')}`);
580
+ catch (err) {
581
+ hadError = true;
582
+ sink.error(`${file}: ${errMsg(err)}`);
827
583
  }
828
- if (typoResult)
829
- logTyposText(typoResult.findings, sink);
830
- return { exitCode };
831
584
  }
832
- const totalFindings = allFindings.length;
833
- if (flags.reporter === 'json') {
585
+ if (reporter === 'json') {
834
586
  const report = {
835
587
  files: files.length,
836
- total: totalFindings,
837
- findings: allFindings.map((f) => ({
588
+ total: findings.length,
589
+ findings: findings.map((f) => ({
838
590
  file: f.file,
839
591
  line: f.line,
840
592
  col: f.col,
@@ -843,41 +595,18 @@ export async function run(argv, cwd, sink = defaultSink) {
843
595
  isCustomToken: f.suggestion.isCustomToken,
844
596
  })),
845
597
  };
846
- sink.write(`${JSON.stringify(report, null, 2)}\n`);
847
- return { exitCode: totalFindings > 0 || hadError ? 1 : 0 };
598
+ writeJson(sink, report);
599
+ return exitWith(findings.length > 0 || hadError);
848
600
  }
849
- if (flags.reporter === 'sarif') {
850
- const sarifOutput = sarifDocument([
851
- {
852
- id: 'no-arbitrary-canonical',
853
- name: 'NoArbitraryCanonical',
854
- shortDescription: {
855
- text: 'Arbitrary value has a canonical Tailwind equivalent',
856
- },
857
- },
858
- ], allFindings.map((f) => ({
859
- ruleId: 'no-arbitrary-canonical',
860
- message: {
861
- text: `${f.suggestion.original} → ${f.suggestion.canonical}`,
862
- },
863
- locations: [
864
- {
865
- physicalLocation: {
866
- artifactLocation: { uri: f.file },
867
- region: { startLine: f.line, startColumn: f.col },
868
- },
869
- },
870
- ],
871
- })));
872
- sink.write(`${JSON.stringify(sarifOutput, null, 2)}\n`);
873
- return { exitCode: totalFindings > 0 || hadError ? 1 : 0 };
874
- }
875
- if (totalFindings > 0) {
876
- sink.log(`\n✖ Found ${totalFindings} non-canonical class${totalFindings !== 1 ? 'es' : ''}`);
601
+ if (reporter === 'sarif') {
602
+ writeJson(sink, findingsSarifDocument(findings, sarif));
603
+ return exitWith(findings.length > 0 || hadError);
604
+ }
605
+ if (findings.length > 0) {
606
+ sink.log(`\n✖ Found ${findings.length} non-canonical class${findings.length !== 1 ? 'es' : ''}`);
877
607
  sink.log(' Run with --fix to auto-replace\n');
878
- return { exitCode: 1 };
608
+ return exitWith(true);
879
609
  }
880
610
  sink.log('✓ No non-canonical classes found');
881
- return { exitCode: hadError ? 1 : 0 };
611
+ return exitWith(hadError);
882
612
  }
883
- //# sourceMappingURL=cli.js.map