@rolandjlevy/css-ghost 1.1.3 → 1.2.0

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/CHANGELOG.md ADDED
@@ -0,0 +1,50 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@rolandjlevy/css-ghost` are documented here.
4
+
5
+ ---
6
+
7
+ ## [1.2.0] — 2026-10-17
8
+
9
+ ### Fixed
10
+
11
+ - **SASS indented syntax** (`src/parser.js`) — `.sass` files previously returned 0 selectors because the parser required `{` to open a rule block. A new `extractSassIndentedSelectors()` branch parses the indentation-based syntax correctly, detecting top-level class selectors and collecting their rule bodies from the indented lines that follow.
12
+
13
+ - **Vue and Svelte components not scanned** (`src/walker.js`) — `.vue` and `.svelte` files were excluded from the component walk, causing all selectors used only in Vue SFCs or Svelte components to appear as false positives. Both extensions are now included in `COMPONENT_EXTENSIONS`.
14
+
15
+ - **`hasDynamic` flag contaminating unrelated files** (`src/scanner.js`) — when any component anywhere in the scan used `styles[variable]` or a template literal, every style file in the entire scan was tagged `hasDynamic: true`. The flag and the `dynamicFiles` list are now scoped to style files that share a directory with the dynamic component.
16
+
17
+ - **Terminal output missing dynamic warning** (`src/reporter.js`) — the `⚠️ Dynamic class references detected` message appeared in the Markdown report but not in the terminal output. The warning now appears inline per file in the terminal, matching the Markdown behaviour.
18
+
19
+ ### Added
20
+
21
+ - Comprehensive CLI test fixtures under `test/` covering all documented use-cases: SCSS, LESS, SASS indented syntax, React Native `StyleSheet.create()`, dynamic class access, hyphenated selectors, `clsx` (strings/objects/arrays), Vue SFC `:class` bindings, and Svelte `class:name` directives.
22
+
23
+ ---
24
+
25
+ ## [1.1.3] — 2026-10-16
26
+
27
+ - Internal release; no public changelog entry.
28
+
29
+ ---
30
+
31
+ ## [1.1.0] — 2026-10-01
32
+
33
+ ### Added
34
+
35
+ - `--ghost-trail` flag: shows the last git commit that touched each orphaned selector
36
+ - `--ai` flag: appends an AI-generated summary via OpenRouter (run `--setup` once to save your key)
37
+ - CSS custom property orphan detection (`--my-token` defined but never consumed via `var(--my-token)`)
38
+ - `--install-hook` flag: writes a pre-commit hook that blocks commits when orphans are present
39
+
40
+ ---
41
+
42
+ ## [1.0.0] — 2026-09-01
43
+
44
+ ### Initial release
45
+
46
+ - CSS selector orphan detection across `.css`, `.module.css`, `.scss`, `.sass`, `.less`, and JS style files (`StyleSheet.create`, `makeStyles`, vanilla-extract)
47
+ - `--delete` / `--delete --yes` to remove orphaned selectors from source files
48
+ - `--strict` flag for CI (exit code 1 when any orphans found)
49
+ - `--output <file>` for custom Markdown report paths
50
+ - Interactive folder picker when run with no arguments
package/README.md CHANGED
@@ -8,9 +8,27 @@ Scans one or more directories for style files, extracts every class selector def
8
8
 
9
9
  **Supported style files:** `.css`, `.module.css`, `.scss`, `.sass`, `.less`, `.js`/`.ts` using `StyleSheet.create({})` / `makeStyles({})` (MUI), `.css.ts`/`.css.js` using `style()` or `styleVariants()` (vanilla-extract)
10
10
 
11
- **Detected usage patterns:** `className="foo"`, `className={styles.foo}`, `styles['foo']`, ternary patterns, `cx()` / `clsx()` / `classnames()` (strings, objects and arrays), `classes.foo` (MUI), vanilla-extract named imports
11
+ **Detected usage patterns:** `className="foo"`, `className={styles.foo}`, `styles['foo']`, ternary patterns, `cx()` / `clsx()` / `classnames()` (strings, objects and arrays), `classes.foo` (MUI), vanilla-extract named imports, Vue `:class` bindings, Svelte `class:name` directives
12
12
 
13
- **Dynamic class detection:** Patterns where the class name can't be resolved statically — `styles[variable]` or template literals with `${}` — are flagged with a ⚠️ warning instead of being reported as orphans.
13
+ **Dynamic class detection:** Patterns where the class name can't be resolved statically — `styles[variable]` or template literals with `${}` — are flagged with a ⚠️ warning per file in both the terminal output and the Markdown report.
14
+
15
+ ## Example output
16
+
17
+ ![css-ghost demo](./docs/demo.gif)
18
+
19
+ When a component uses dynamic class access (`styles[variant]`), the file is flagged rather than reporting false positives:
20
+
21
+ ```
22
+ ✅ src/components/alert.module.css
23
+
24
+ ⚠️ Dynamic class references detected — results may be incomplete.
25
+ Dynamic references in: src/components/Alert.jsx
26
+
27
+ 👻 .hidden (line 30)
28
+ .hidden { display: none; }
29
+ ```
30
+
31
+ ---
14
32
 
15
33
  ## How is this different from PurgeCSS?
16
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rolandjlevy/css-ghost",
3
- "version": "1.1.3",
3
+ "version": "1.2.0",
4
4
  "description": "Find and remove unused CSS selectors and orphaned CSS custom properties — a CLI linter and auditor for React, Vue, Svelte, SCSS, and CSS Modules",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,6 +18,7 @@
18
18
  "bin",
19
19
  "src",
20
20
  "README.md",
21
+ "CHANGELOG.md",
21
22
  "LICENSE"
22
23
  ],
23
24
  "keywords": [
package/src/deleter.js CHANGED
@@ -36,10 +36,42 @@ function findBlockEnd(lines, startIdx) {
36
36
 
37
37
  // ─── CSS / SCSS / LESS ───────────────────────────────────────────────────────
38
38
 
39
- function deleteFromCssFile(filePath, orphans) {
39
+ // Return the start index of a comment block (/* ... */) that sits immediately
40
+ // before `idx` in `content` (separated only by whitespace/newlines), or -1.
41
+ function findPrecedingComment(content, idx) {
42
+ // Walk backwards over whitespace
43
+ let i = idx - 1;
44
+ while (i >= 0 && /[\s]/.test(content[i])) i--;
45
+
46
+ if (i < 1 || content[i] !== '/' || content[i - 1] !== '*') return -1;
47
+
48
+ // Found the end of a block comment — now find its start
49
+ const closePos = i - 1; // position of '*/' second char
50
+ const openPos = content.lastIndexOf('/*', closePos);
51
+ return openPos === -1 ? -1 : openPos;
52
+ }
53
+
54
+ // Delete individual CSS declarations (e.g. ` --token: value;\n`) from content.
55
+ function deleteCssDeclarations(content, varNames) {
56
+ let removed = 0;
57
+ for (const name of varNames) {
58
+ // Match ` --name: anything up to semicolon;\n?` including leading whitespace
59
+ const re = new RegExp(`[ \\t]*${escapeRegex(name)}\\s*:[^;]*;[ \\t]*\\n?`, 'g');
60
+ const next = content.replace(re, () => { removed++; return ''; });
61
+ if (next !== content) content = next;
62
+ }
63
+ return { content, removed };
64
+ }
65
+
66
+ function escapeRegex(str) {
67
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
68
+ }
69
+
70
+ function deleteFromCssFile(filePath, orphans, orphanedVarNames = []) {
40
71
  let content = readFileSync(filePath, 'utf-8');
41
72
  let removed = 0;
42
73
 
74
+ // ── 1. Delete orphaned class/selector rules ────────────────────────────────
43
75
  // Sort longest rules first so that nested/contained rules don't mis-match.
44
76
  const sorted = [...orphans].sort((a, b) => (b.rule?.length ?? 0) - (a.rule?.length ?? 0));
45
77
 
@@ -48,13 +80,24 @@ function deleteFromCssFile(filePath, orphans) {
48
80
  const idx = content.indexOf(rule);
49
81
  if (idx === -1) continue;
50
82
 
51
- const before = content.slice(0, idx);
83
+ // Bug fix: also remove a comment block immediately preceding the rule.
84
+ const commentStart = findPrecedingComment(content, idx);
85
+ const deleteFrom = commentStart !== -1 ? commentStart : idx;
86
+
87
+ const before = content.slice(0, deleteFrom);
52
88
  const after = content.slice(idx + rule.length);
53
89
  // Eat one trailing newline so we don't leave double-blank-lines everywhere.
54
90
  content = before + after.replace(/^\n/, '');
55
91
  removed++;
56
92
  }
57
93
 
94
+ // ── 2. Delete orphaned CSS variable declarations ───────────────────────────
95
+ if (orphanedVarNames.length > 0) {
96
+ const { content: next, removed: varRemoved } = deleteCssDeclarations(content, orphanedVarNames);
97
+ content = next;
98
+ removed += varRemoved;
99
+ }
100
+
58
101
  writeFileSync(filePath, normalise(content), 'utf-8');
59
102
  return removed;
60
103
  }
@@ -110,23 +153,27 @@ function deleteFromVeFile(filePath, orphans) {
110
153
  // ─── Public API ──────────────────────────────────────────────────────────────
111
154
 
112
155
  export async function deleteOrphans(results, { yes = false } = {}) {
113
- const toDelete = results.filter(r => r.orphaned.length > 0);
156
+ // A file is eligible for deletion if it has orphaned selectors OR orphaned vars.
157
+ const toDelete = results.filter(r => r.orphaned.length > 0 || (r.orphanedVars?.length ?? 0) > 0);
114
158
 
115
159
  if (toDelete.length === 0) {
116
160
  console.log('Nothing to delete.\n');
117
161
  return 0;
118
162
  }
119
163
 
120
- const totalCount = toDelete.reduce((n, r) => n + r.orphaned.length, 0);
121
-
122
164
  console.log(
123
- `\n${pc.bold('🗑️ The following orphaned selectors will be permanently deleted:')}\n`
165
+ `\n${pc.bold('🗑️ The following orphaned selectors and CSS variables will be permanently deleted:')}\n`
124
166
  );
125
167
 
126
- for (const { filePath, orphaned } of toDelete) {
168
+ for (const { filePath, orphaned, orphanedVars } of toDelete) {
127
169
  const rel = relative(process.cwd(), filePath);
128
170
  console.log(` ${pc.bold(rel)}`);
129
- orphaned.forEach(o => console.log(` ${pc.yellow('•')} .${o.name} ${pc.dim(`(line ${o.line})`)}`));
171
+ orphaned.forEach(o =>
172
+ console.log(` ${pc.yellow('•')} .${o.name} ${pc.dim(`(line ${o.line})`)}`)
173
+ );
174
+ (orphanedVars ?? []).forEach(o =>
175
+ console.log(` ${pc.magenta('•')} ${o.name} ${pc.dim(`(line ${o.line})`)}`)
176
+ );
130
177
  }
131
178
 
132
179
  if (!yes) {
@@ -142,25 +189,27 @@ export async function deleteOrphans(results, { yes = false } = {}) {
142
189
  console.log('');
143
190
  let totalRemoved = 0;
144
191
 
145
- for (const { filePath, orphaned } of toDelete) {
146
- const ext = extname(filePath);
147
- const rel = relative(process.cwd(), filePath);
192
+ for (const { filePath, orphaned, orphanedVars } of toDelete) {
193
+ const ext = extname(filePath);
194
+ const rel = relative(process.cwd(), filePath);
195
+ const varNames = (orphanedVars ?? []).map(v => v.name);
196
+ const totalExpected = orphaned.length + varNames.length;
148
197
  let removed;
149
198
 
150
199
  try {
151
200
  if (['.css', '.scss', '.sass', '.less'].includes(ext)) {
152
- removed = deleteFromCssFile(filePath, orphaned);
201
+ removed = deleteFromCssFile(filePath, orphaned, varNames);
153
202
  } else if (/\.css\.(ts|js)x?$/.test(filePath)) {
154
203
  removed = deleteFromVeFile(filePath, orphaned);
155
204
  } else {
156
205
  removed = deleteFromJsFile(filePath, orphaned);
157
206
  }
158
207
 
159
- const label = removed === orphaned.length
208
+ const label = removed === totalExpected
160
209
  ? pc.green(`✂️ ${rel}`)
161
210
  : pc.yellow(`✂️ ${rel}`);
162
- const note = removed < orphaned.length
163
- ? pc.dim(` (${orphaned.length - removed} could not be located — check manually)`)
211
+ const note = removed < totalExpected
212
+ ? pc.dim(` (${totalExpected - removed} could not be located — check manually)`)
164
213
  : '';
165
214
  console.log(`${label} — removed ${removed}${note}`);
166
215
  totalRemoved += removed;
@@ -169,8 +218,9 @@ export async function deleteOrphans(results, { yes = false } = {}) {
169
218
  }
170
219
  }
171
220
 
221
+ const noun = totalRemoved === 1 ? 'item' : 'items';
172
222
  console.log(
173
- `\n${pc.green(pc.bold(`✅ Done.`))} Removed ${totalRemoved} orphaned selector${totalRemoved !== 1 ? 's' : ''}.\n`
223
+ `\n${pc.green(pc.bold(`✅ Done.`))} Removed ${totalRemoved} orphaned ${noun}.\n`
174
224
  );
175
225
 
176
226
  return totalRemoved;
package/src/parser.js CHANGED
@@ -53,6 +53,58 @@ export function extractVarUsages(content) {
53
53
 
54
54
  // ─── CSS / SCSS selector extraction ────────────────────────────────────────
55
55
 
56
+ /**
57
+ * Parse SASS indented syntax (.sass) — no braces, indentation defines blocks.
58
+ * Class selectors appear at the start of a line (after optional whitespace),
59
+ * followed by indented property lines.
60
+ */
61
+ function extractSassIndentedSelectors(content) {
62
+ const selectors = new Map();
63
+ const lines = content.split('\n');
64
+
65
+ // Strip // single-line comments (SASS uses // as well as /* */)
66
+ const stripped = content
67
+ .replace(/\/\*[\s\S]*?\*\//g, m => m.replace(/[^\n]/g, ' '))
68
+ .split('\n')
69
+ .map(l => l.replace(/\/\/.*$/, ''));
70
+
71
+ let i = 0;
72
+ while (i < stripped.length) {
73
+ const raw = stripped[i];
74
+ const trimmed = raw.trimStart();
75
+ const indent = raw.length - trimmed.length;
76
+
77
+ // A class selector: zero or shallow indent, starts with "."
78
+ if (indent === 0 && trimmed.startsWith('.')) {
79
+ const m = trimmed.match(/^\.([a-zA-Z_-][a-zA-Z0-9_-]*)/);
80
+ if (m) {
81
+ const name = m[1];
82
+ const lineNum = i + 1;
83
+ // Collect the rule block: this line + indented lines that follow
84
+ const ruleLines = [lines[i]];
85
+ let j = i + 1;
86
+ while (j < lines.length) {
87
+ const nextRaw = stripped[j];
88
+ if (nextRaw.trim() === '') { j++; continue; }
89
+ const nextIndent = nextRaw.length - nextRaw.trimStart().length;
90
+ if (nextIndent > indent) {
91
+ ruleLines.push(lines[j]);
92
+ j++;
93
+ } else {
94
+ break;
95
+ }
96
+ }
97
+ if (!selectors.has(name)) {
98
+ selectors.set(name, { line: lineNum, rule: ruleLines.join('\n').trim() });
99
+ }
100
+ }
101
+ }
102
+ i++;
103
+ }
104
+
105
+ return selectors;
106
+ }
107
+
56
108
  function extractCssSelectors(content) {
57
109
  const selectors = new Map(); // name → { line, rule }
58
110
  const lines = content.split('\n');
@@ -324,7 +376,7 @@ export function parseStyleFile(filePath) {
324
376
  const content = readFileSync(filePath, 'utf-8');
325
377
  const ext = extname(filePath);
326
378
 
327
- if (['.css', '.scss', '.sass', '.less'].includes(ext)) {
379
+ if (['.css', '.scss', '.less'].includes(ext)) {
328
380
  return {
329
381
  selectors: extractCssSelectors(content),
330
382
  cssVars: extractCssVarDefinitions(content),
@@ -333,6 +385,16 @@ export function parseStyleFile(filePath) {
333
385
  };
334
386
  }
335
387
 
388
+ // SASS indented syntax — no braces; needs its own parser branch
389
+ if (ext === '.sass') {
390
+ return {
391
+ selectors: extractSassIndentedSelectors(content),
392
+ cssVars: extractCssVarDefinitions(content),
393
+ varUsages: extractVarUsages(content),
394
+ hasDynamic: false,
395
+ };
396
+ }
397
+
336
398
  // JS/TS style file — CSS custom properties are not typically defined here
337
399
  const selectors = extractJsStyleKeys(content);
338
400
  return { selectors, cssVars: new Map(), varUsages: new Set(), hasDynamic: false };
package/src/reporter.js CHANGED
@@ -197,7 +197,7 @@ export function generateReport(results, outputPath = 'orphan-report.md', aiSumma
197
197
  }
198
198
 
199
199
  for (const result of results) {
200
- const { orphaned, orphanedVars = [], trails, varTrails } = result;
200
+ const { orphaned, orphanedVars = [], trails, varTrails, hasDynamic, dynamicFiles } = result;
201
201
  if (orphaned.length === 0 && orphanedVars.length === 0) continue;
202
202
 
203
203
  const relPath = relative(process.cwd(), result.filePath);
@@ -207,6 +207,17 @@ export function generateReport(results, outputPath = 'orphan-report.md', aiSumma
207
207
  : '';
208
208
  console.log(`✅ ${pc.bold(relPath)}${savingsSuffix}\n`);
209
209
 
210
+ if (hasDynamic) {
211
+ console.log(` ${pc.yellow('⚠️ Dynamic class references detected — results may be incomplete.')}`);
212
+ if (dynamicFiles?.length > 0) {
213
+ const relDynamic = dynamicFiles
214
+ .map(f => `${relative(process.cwd(), f)}`)
215
+ .join(', ');
216
+ console.log(pc.dim(` Dynamic references in: ${relDynamic}`));
217
+ }
218
+ console.log('');
219
+ }
220
+
210
221
  orphaned.forEach(({ name, line, rule }) => {
211
222
  console.log(` 👻 ${pc.yellow('.' + name)} ${pc.dim(`(line ${line})`)}`);
212
223
  const trail = trails?.[name];
package/src/scanner.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readFileSync } from 'fs';
2
- import { extname } from 'path';
2
+ import { extname, dirname } from 'path';
3
3
  import { walkDir } from './walker.js';
4
4
  import { parseStyleFile, parseComponentFile, isJsStyleFile } from './parser.js';
5
5
 
@@ -75,6 +75,12 @@ export function scan(dirs) {
75
75
  }
76
76
 
77
77
  // ── PASS 3: Cross-reference and build results ─────────────────────────────
78
+ // Build a Set of directories that contain dynamic components, so we can
79
+ // scope the hasDynamic flag to style files that share a directory — rather
80
+ // than tagging every style file in the entire scan when just one component
81
+ // anywhere uses dynamic class access.
82
+ const dynamicDirs = new Set(dynamicComponentFiles.map(f => dirname(f)));
83
+
78
84
  const results = [];
79
85
 
80
86
  for (const styleFile of styleFiles) {
@@ -82,6 +88,13 @@ export function scan(dirs) {
82
88
  if (!parsed) continue;
83
89
 
84
90
  const { selectors, cssVars, hasDynamic } = parsed;
91
+ const styleDir = dirname(styleFile);
92
+
93
+ // Only the dynamic components co-located with this style file are relevant
94
+ const localDynamicFiles = dynamicComponentFiles.filter(
95
+ f => dirname(f) === styleDir
96
+ );
97
+ const localHasDynamic = hasDynamic || dynamicDirs.has(styleDir);
85
98
 
86
99
  // Orphaned class selectors
87
100
  const orphaned = [...selectors.entries()]
@@ -101,8 +114,8 @@ export function scan(dirs) {
101
114
  orphaned,
102
115
  orphanedVars,
103
116
  totalVars: cssVars?.size ?? 0,
104
- hasDynamic: hasDynamic || dynamicComponentFiles.length > 0,
105
- dynamicFiles: dynamicComponentFiles,
117
+ hasDynamic: localHasDynamic,
118
+ dynamicFiles: localDynamicFiles,
106
119
  });
107
120
  }
108
121
 
package/src/walker.js CHANGED
@@ -2,7 +2,7 @@ import { readdirSync } from 'fs';
2
2
  import { join, extname, basename } from 'path';
3
3
 
4
4
  const STYLE_EXTENSIONS = ['.css', '.scss', '.sass', '.less'];
5
- const COMPONENT_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx']);
5
+ const COMPONENT_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.vue', '.svelte']);
6
6
  const IGNORE_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'out', 'coverage', '.cache']);
7
7
 
8
8
  export function walkDir(dir) {