@weerachai06/tw-scanner 1.0.4 → 1.1.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.
package/README.md CHANGED
@@ -47,7 +47,10 @@ npx @weerachai06/tw-scanner --src ./src --css ./src/globals.css --json | jq '.in
47
47
  |---|---|---|
48
48
  | ❌ Invalid class | `bg-old-token-500` | **Error** — invalid after migration |
49
49
  | ❌ Invalid in cva variants | `danger: 'bg-red-danger'` | **Error** — detected via AST |
50
+ | ❌ Invalid `@apply` class | `@apply bg-fake-999;` in `.css` | **Error** — validated against your config |
51
+ | ❌ Missing CSS Module class | `styles.nonExistent` | **Error** — class not defined in `.module.css` |
50
52
  | ⚠️ Dynamic expression | `` `text-${color}` `` | **Warning** — cannot validate statically |
53
+ | ⚠️ Dynamic module access | `styles[variable]` | **Warning** — skipped, cannot resolve statically |
51
54
 
52
55
  ## How it works
53
56
 
@@ -60,13 +63,36 @@ Uses `@typescript-eslint/typescript-estree` to parse `.ts/.tsx/.js/.jsx` and tra
60
63
  - `ConditionalExpression` and `LogicalExpression` inside class utilities
61
64
  - `TemplateLiteral` — static parts extracted, dynamic parts flagged as warnings
62
65
 
63
- ### 2. Tailwind v4 Validation
66
+ ### 2. CSS file scanning
67
+
68
+ `.css` and `.module.css` files are also scanned. Classes inside `@apply` directives are extracted and validated against your Tailwind config.
69
+
70
+ ```css
71
+ .btn {
72
+ @apply bg-blue-500 text-white px-4; /* ✓ valid */
73
+ @apply bg-fake-999; /* ✗ error */
74
+ }
75
+ ```
76
+
77
+ ### 3. CSS Modules validation
78
+
79
+ When a JS/TS file imports a `.module.css` file, all `styles.xxx` and `styles['xxx']` usages are checked against the class names actually defined in that file.
80
+
81
+ ```tsx
82
+ import styles from './button.module.css'
83
+
84
+ <button className={styles.btn}>...</button> // ✓ defined
85
+ <button className={styles.nonExistent}>...</button> // ✗ error
86
+ <button className={styles[variant]}>...</button> // ⚠ skipped (dynamic)
87
+ ```
88
+
89
+ ### 4. Tailwind v4 Validation
64
90
 
65
91
  Uses `@tailwindcss/node`'s `compile()` API to load your actual CSS entry file (including all `@theme` tokens and plugins), then calls `build([candidate])` for each class. If the class generates no CSS rule in the output, it's invalid.
66
92
 
67
93
  Validation is **100% accurate against your real config** — custom tokens, plugins, and all.
68
94
 
69
- ### 3. Batch validation
95
+ ### 5. Batch validation
70
96
 
71
97
  All unique class values are validated in a single `build()` call per batch for performance.
72
98
 
package/dist/cli.js CHANGED
@@ -46,8 +46,7 @@ async function main() {
46
46
  if (outputArg) {
47
47
  writeJsonReport(result, path.resolve(outputArg));
48
48
  }
49
- // Exit with error code if invalid classes found
50
- process.exit(result.invalid.length > 0 ? 1 : 0);
49
+ process.exit(result.invalid.length > 0 || result.cssModuleViolations.length > 0 ? 1 : 0);
51
50
  }
52
51
  catch (err) {
53
52
  console.error('Fatal error:', err.message);
package/dist/extractor.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { parse } from '@typescript-eslint/typescript-estree';
2
2
  import * as fs from 'fs';
3
+ import * as path from 'path';
3
4
  // ─── Class utility function names to detect ──────────────────────────────────
4
5
  const CLASS_UTIL_NAMES = new Set(['clsx', 'cn', 'cx', 'classnames', 'cva', 'tv']);
5
6
  // JSX props that contain class strings
@@ -210,6 +211,93 @@ function walk(node, file, source, results, visited = new WeakSet()) {
210
211
  }
211
212
  }
212
213
  }
214
+ // ─── CSS Modules extractor ───────────────────────────────────────────────────
215
+ export function extractCssModuleUsages(file) {
216
+ if (!fs.existsSync(file))
217
+ return [];
218
+ const source = fs.readFileSync(file, 'utf8');
219
+ const isJSX = /\.(tsx|jsx)$/.test(file);
220
+ let ast;
221
+ try {
222
+ ast = parse(source, { jsx: isJSX, loc: true, range: true, tolerant: true });
223
+ }
224
+ catch {
225
+ return [];
226
+ }
227
+ // Pass 1: collect `import styles from './foo.module.css'` → binding → resolved path
228
+ const imports = new Map();
229
+ for (const node of ast.body) {
230
+ if (node.type === 'ImportDeclaration' &&
231
+ typeof node.source.value === 'string' &&
232
+ node.source.value.endsWith('.module.css')) {
233
+ const resolvedPath = path.resolve(path.dirname(file), node.source.value);
234
+ for (const specifier of node.specifiers) {
235
+ if (specifier.type === 'ImportDefaultSpecifier') {
236
+ imports.set(specifier.local.name, resolvedPath);
237
+ }
238
+ }
239
+ }
240
+ }
241
+ if (imports.size === 0)
242
+ return [];
243
+ // Pass 2: collect `styles.btn` / `styles['btn']` / `styles[expr]` usages
244
+ const results = [];
245
+ function walkForUsages(node) {
246
+ if (!node)
247
+ return;
248
+ if (node.type === 'MemberExpression') {
249
+ const obj = node.object;
250
+ if (obj.type === 'Identifier' && imports.has(obj.name)) {
251
+ const modulePath = imports.get(obj.name);
252
+ const prop = node.property;
253
+ const snippet = source.slice(Math.max(0, (node.range?.[0] ?? 0) - 20), Math.min(source.length, (node.range?.[1] ?? 0) + 20)).replace(/\n/g, ' ').trim();
254
+ if (!node.computed && prop.type === 'Identifier') {
255
+ results.push({ file, line: node.loc.start.line, col: node.loc.start.column, className: prop.name, modulePath, context: snippet, isDynamic: false });
256
+ }
257
+ else if (node.computed && prop.type === 'Literal' && typeof prop.value === 'string') {
258
+ results.push({ file, line: node.loc.start.line, col: node.loc.start.column, className: prop.value, modulePath, context: snippet, isDynamic: false });
259
+ }
260
+ else if (node.computed) {
261
+ results.push({ file, line: node.loc.start.line, col: node.loc.start.column, className: source.slice(node.range?.[0] ?? 0, node.range?.[1] ?? 0), modulePath, context: snippet, isDynamic: true });
262
+ }
263
+ }
264
+ }
265
+ for (const key of Object.keys(node)) {
266
+ if (key === 'parent' || key === 'tokens' || key === 'comments')
267
+ continue;
268
+ const child = node[key];
269
+ if (Array.isArray(child)) {
270
+ for (const c of child) {
271
+ if (c && typeof c === 'object' && 'type' in c)
272
+ walkForUsages(c);
273
+ }
274
+ }
275
+ else if (child && typeof child === 'object' && 'type' in child) {
276
+ walkForUsages(child);
277
+ }
278
+ }
279
+ }
280
+ walkForUsages(ast);
281
+ return results;
282
+ }
283
+ export function extractDefinedCssModuleClasses(cssFile) {
284
+ if (!fs.existsSync(cssFile))
285
+ return new Set();
286
+ const source = fs.readFileSync(cssFile, 'utf8');
287
+ const classes = new Set();
288
+ const lines = source.split('\n');
289
+ for (const line of lines) {
290
+ // Skip @apply lines — those dots are Tailwind classes, not module class names
291
+ if (line.trimStart().startsWith('@apply'))
292
+ continue;
293
+ const regex = /\.([a-zA-Z_][a-zA-Z0-9_-]*)\b/g;
294
+ let m;
295
+ while ((m = regex.exec(line)) !== null) {
296
+ classes.add(m[1]);
297
+ }
298
+ }
299
+ return classes;
300
+ }
213
301
  // ─── CSS @apply extractor ─────────────────────────────────────────────────────
214
302
  export function extractClassesFromCss(file) {
215
303
  if (!fs.existsSync(file))
package/dist/reporter.js CHANGED
@@ -22,7 +22,7 @@ export function printReport(result, opts = {}) {
22
22
  console.log(JSON.stringify(result, null, 2));
23
23
  return;
24
24
  }
25
- const { invalid, dynamic, totalClasses, totalFiles, durationMs } = result;
25
+ const { invalid, dynamic, cssModuleViolations, totalClasses, totalFiles, durationMs } = result;
26
26
  console.log('');
27
27
  console.log(pc.bold('─── Tailwind v4 Class Scanner ───────────────────────────────'));
28
28
  console.log(` Scanned ${pc.cyan(pluralize(totalFiles, 'file'))} · ` +
@@ -67,11 +67,32 @@ export function printReport(result, opts = {}) {
67
67
  console.log('');
68
68
  }
69
69
  }
70
+ // ── CSS Module violations ──
71
+ if (cssModuleViolations.length > 0) {
72
+ console.log(pc.red(pc.bold(` ✗ ${pluralize(cssModuleViolations.length, 'CSS Module class')} not found`)));
73
+ console.log('');
74
+ const byFile = groupByFile(cssModuleViolations);
75
+ for (const [file, items] of byFile) {
76
+ console.log(` ${pc.underline(relativePath(file))}`);
77
+ for (const item of items) {
78
+ const loc = pc.dim(`${item.line}:${item.col}`);
79
+ const cls = pc.red(pc.bold(item.className));
80
+ const mod = pc.dim(`(${relativePath(item.modulePath)})`);
81
+ console.log(` ${loc} ${cls} ${mod}`);
82
+ if (opts.verbose) {
83
+ console.log(` ${pc.dim(`…${item.context}…`)}`);
84
+ }
85
+ }
86
+ console.log('');
87
+ }
88
+ }
70
89
  // ── Summary ──
71
90
  console.log('─────────────────────────────────────────────────────────────');
72
- const status = invalid.length === 0 ? pc.green('PASS') : pc.red('FAIL');
91
+ const hasErrors = invalid.length > 0 || cssModuleViolations.length > 0;
92
+ const status = hasErrors ? pc.red('FAIL') : pc.green('PASS');
73
93
  console.log(` Status: ${status} · ` +
74
94
  `${pc.red(String(invalid.length))} invalid · ` +
95
+ `${pc.red(String(cssModuleViolations.length))} CSS module · ` +
75
96
  `${pc.yellow(String(dynamicOnly.length))} dynamic warnings`);
76
97
  console.log('');
77
98
  }
package/dist/scanner.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { globSync } from 'glob';
2
2
  import * as path from 'path';
3
- import { extractClassesFromFile, extractClassesFromCss } from './extractor.js';
3
+ import { extractClassesFromFile, extractClassesFromCss, extractCssModuleUsages, extractDefinedCssModuleClasses } from './extractor.js';
4
4
  import { loadTailwindContext, validateBatch } from './validator.js';
5
5
  const DEFAULT_INCLUDE = ['**/*.{ts,tsx,js,jsx,css}'];
6
6
  const DEFAULT_EXCLUDE = [
@@ -50,9 +50,34 @@ export async function scan(opts) {
50
50
  invalid.push({ cls, valid });
51
51
  }
52
52
  }
53
+ // ── 7. CSS Module cross-reference ────────────────────────────────────────────
54
+ const cssModuleViolations = [];
55
+ const jsFiles = files.filter((f) => !f.endsWith('.css'));
56
+ // Collect all usages across JS/TS files
57
+ const allUsages = jsFiles.flatMap((f) => extractCssModuleUsages(f));
58
+ const staticUsages = allUsages.filter((u) => !u.isDynamic);
59
+ // Load defined classes per unique module path (cached)
60
+ const moduleClassesCache = new Map();
61
+ for (const usage of staticUsages) {
62
+ if (!moduleClassesCache.has(usage.modulePath)) {
63
+ moduleClassesCache.set(usage.modulePath, extractDefinedCssModuleClasses(usage.modulePath));
64
+ }
65
+ const defined = moduleClassesCache.get(usage.modulePath);
66
+ if (!defined.has(usage.className)) {
67
+ cssModuleViolations.push({
68
+ file: usage.file,
69
+ line: usage.line,
70
+ col: usage.col,
71
+ className: usage.className,
72
+ modulePath: usage.modulePath,
73
+ context: usage.context,
74
+ });
75
+ }
76
+ }
53
77
  return {
54
78
  invalid,
55
79
  dynamic: dynamicClasses,
80
+ cssModuleViolations,
56
81
  totalClasses: allExtracted.length,
57
82
  totalFiles: files.length,
58
83
  durationMs: Date.now() - start,
package/dist/validator.js CHANGED
@@ -11,10 +11,23 @@ export async function loadTailwindContext(cssFile) {
11
11
  throw new Error(`Tailwind CSS file not found: ${abs}`);
12
12
  }
13
13
  const css = fs.readFileSync(abs, 'utf8');
14
- const result = await compile(css, {
15
- base: path.dirname(abs),
16
- onDependency: () => { },
17
- });
14
+ let result;
15
+ try {
16
+ result = await compile(css, {
17
+ base: path.dirname(abs),
18
+ onDependency: () => { },
19
+ });
20
+ }
21
+ catch (err) {
22
+ const msg = err.message ?? '';
23
+ const match = msg.match(/Cannot apply unknown utility class [`']?([^\s`']+)[`']?/);
24
+ if (match) {
25
+ throw new Error(`Tailwind could not compile "${path.relative(process.cwd(), abs)}" — ` +
26
+ `unknown utility used in @apply: "${match[1]}".\n` +
27
+ ` Fix: add "@utility ${match[1]} {}" to your CSS entry file to register it as a custom utility.`);
28
+ }
29
+ throw err;
30
+ }
18
31
  compileCache.set(abs, result);
19
32
  return result;
20
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weerachai06/tw-scanner",
3
- "version": "1.0.4",
3
+ "version": "1.1.1",
4
4
  "description": "AST-based Tailwind v4 class validator for React projects",
5
5
  "type": "module",
6
6
  "bin": {