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 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
- # Warning: 3 red color variants used for text: text-red-500 (4), text-rose-500 (1), text-red-600 (2)
129
- # Warning: px inconsistency: px-4 (8 files) vs px-3 (2 files)
130
- # Warning: z inconsistency: z-[100] (3 files) vs z-[200] (1 file) vs z-[50] (2 files)
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
- ```js
186
- // tailwind-canonical.config.js
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.js` at the root:
272
+ Create `tailwind-canonical.config.ts` at the root:
258
273
 
259
- ```js
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
@@ -19,6 +19,9 @@ type Flags = {
19
19
  reporter: Reporter;
20
20
  targets: string[];
21
21
  error?: string;
22
+ hasExplicitMode: boolean;
23
+ hasExplicitReporter: boolean;
24
+ hasExplicitWatch: boolean;
22
25
  };
23
26
  export declare function parseArgs(argv: string[]): Flags;
24
27
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/cli/cli.ts"],"names":[],"mappings":"AAuBA,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;CAChB,CAAC;AAsFF,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,KAAK,CA0C/C;AASD;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,KAAK,GAAG,MAAM,EAAE,CAwBnD;AAyLD,wBAAsB,GAAG,CACvB,IAAI,EAAE,MAAM,EAAE,EACd,GAAG,EAAE,MAAM,EACX,IAAI,GAAE,IAAkB,GACvB,OAAO,CAAC,SAAS,CAAC,CAiMpB"}
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
- for (const group of report.colorVariants) {
173
- const tokens = group.variants
174
- .map((v) => `${group.property}-${v.token} (${v.count})`)
175
- .join(', ');
176
- sink.log(` Warning: ${group.variants.length} ${group.family} color variants used for ${group.property}: ${tokens}`);
177
- }
178
- for (const scale of report.scaleInconsistencies) {
179
- const values = scale.values
180
- .map((v) => `${scale.property}-${v.value} (${pluralize(v.files.length, 'file')})`)
181
- .join(' vs ');
182
- sink.log(` Warning: ${scale.property} inconsistency: ${values}`);
183
- }
184
- for (const combo of report.combinations) {
185
- sink.log(` Pattern: "${combo.classes.join(' ')}" repeated in ${pluralize(combo.files.length, 'file')}`);
186
- }
187
- if (issueCount === 0) {
188
- sink.log('✓ No cross-file inconsistencies found');
189
- }
190
- else {
191
- sink.log(`\n✖ Found ${pluralize(issueCount, 'consistency issue')} across ${pluralize(report.filesAnalyzed, 'file')}`);
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.on('SIGINT', () => {
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
- const flags = parseArgs(argv);
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 files = await resolveTargets(flags.targets);
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
- $schema: SARIF_SCHEMA,
370
- version: '2.1.0',
371
- runs: [
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
- tool: {
374
- driver: {
375
- name: 'tailwind-canonical',
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
  }