@rolandjlevy/css-ghost 1.0.2 → 2.0.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/LICENSE +24 -0
- package/README.md +228 -19
- package/bin/css-ghost.js +103 -23
- package/package.json +11 -3
- package/src/ai.js +94 -0
- package/src/deleter.js +177 -0
- package/src/git.js +73 -0
- package/src/hook.js +64 -0
- package/src/parser.js +194 -28
- package/src/picker.js +123 -0
- package/src/reporter.js +158 -28
- package/src/scanner.js +55 -21
- package/src/setup.js +60 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Roland Levy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
Standard MIT License text sourced from https://choosealicense.com/licenses/mit/
|
package/README.md
CHANGED
|
@@ -7,17 +7,41 @@ Find orphaned CSS class selectors in your codebase — defined in style files bu
|
|
|
7
7
|
Scans one or more directories for style files, extracts every class selector defined, then cross-references them against usages in your component files. Anything defined but never used is reported as an orphan — with its line number and the actual CSS printed inline.
|
|
8
8
|
|
|
9
9
|
**Supported style files:**
|
|
10
|
+
|
|
10
11
|
- `.css`, `.module.css`, `.scss`, `.sass`, `.less`
|
|
11
|
-
- `.js` / `.ts` files using `StyleSheet.create({})` or named style objects
|
|
12
|
+
- `.js` / `.ts` files using `StyleSheet.create({})`, `makeStyles({})` (MUI), or named style objects
|
|
13
|
+
- `.css.ts` / `.css.js` files using `style()` or `styleVariants()` (vanilla-extract)
|
|
12
14
|
|
|
13
15
|
**Detected usage patterns:**
|
|
16
|
+
|
|
14
17
|
- `className="foo"`
|
|
15
18
|
- `className={styles.foo}`
|
|
16
19
|
- `styles['foo']`
|
|
20
|
+
- `styles[condition ? 'foo' : 'bar']` — both branches extracted
|
|
17
21
|
- `cx('foo')`, `clsx('foo')`, `classnames('foo')`
|
|
22
|
+
- `cx({ active: isOn, disabled: true })` — object keys extracted
|
|
23
|
+
- `cx(['foo', condition && 'bar'])` — array string literals extracted
|
|
24
|
+
- `classes.foo` — MUI `makeStyles` / `useStyles` convention
|
|
25
|
+
- `import { foo } from './styles.css'` — vanilla-extract named imports
|
|
18
26
|
|
|
19
27
|
**Dynamic class detection:**
|
|
20
|
-
|
|
28
|
+
Patterns where the class name is truly unknowable at parse time — `styles[variable]` or template literals containing `${}` — are flagged with a ⚠️ warning. All statically resolvable patterns (string literals, ternaries, object keys) are analysed without warnings.
|
|
29
|
+
|
|
30
|
+
## To enable AI features
|
|
31
|
+
|
|
32
|
+
css-ghost can generate a plain English summary of your orphaned selectors using a free [OpenRouter](https://openrouter.ai) API key. Run the setup wizard once:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
css-ghost --setup
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
It will prompt you to paste your key (free at [openrouter.ai/keys](https://openrouter.ai/keys)) and save it to `~/.css-ghost`. Then pass `--ai` to any scan:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
css-ghost ./src --ai
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The AI summary appears in both the terminal output and the markdown report. The core scan always runs without a key — `--ai` is entirely optional.
|
|
21
45
|
|
|
22
46
|
## Install
|
|
23
47
|
|
|
@@ -34,25 +58,183 @@ npx @rolandjlevy/css-ghost ./src
|
|
|
34
58
|
## Usage
|
|
35
59
|
|
|
36
60
|
```bash
|
|
37
|
-
css-ghost
|
|
61
|
+
css-ghost [directory] [directory2] ... [options]
|
|
38
62
|
```
|
|
39
63
|
|
|
40
64
|
**Options:**
|
|
41
65
|
|
|
42
|
-
| Flag
|
|
43
|
-
|
|
|
44
|
-
| `--output <file>` | Output report file path
|
|
45
|
-
|
|
|
66
|
+
| Flag | Description | Default |
|
|
67
|
+
| ----------------- | ---------------------------------------------------- | ------------------ |
|
|
68
|
+
| `--output <file>` | Output report file path | `orphan-report.md` |
|
|
69
|
+
| `--delete` | Delete all orphaned selectors from source files | off |
|
|
70
|
+
| `--yes` | Skip the deletion confirmation prompt | off |
|
|
71
|
+
| `--strict` | Exit with code 1 when any orphans are found (CI use) | off |
|
|
72
|
+
| `--ghost-trail` | Show the last git commit that touched each orphan | off |
|
|
73
|
+
| `--install-hook` | Write a pre-commit hook to `.git/hooks/pre-commit` | — |
|
|
74
|
+
| `--ai` | Append an AI-generated summary (requires setup) | off |
|
|
75
|
+
| `--setup` | Save your OpenRouter API key for `--ai` | — |
|
|
76
|
+
| `-h, --help` | Show help | — |
|
|
77
|
+
|
|
78
|
+
## CSS variable orphan detection
|
|
79
|
+
|
|
80
|
+
css-ghost also catches **orphaned CSS custom properties** — variables defined with `--my-token: value` but never consumed anywhere via `var(--my-token)`.
|
|
81
|
+
|
|
82
|
+
Usages are detected across all file types: CSS, SCSS, JS, TS, JSX, TSX, Vue, Svelte, HTML. A variable defined in `variables.css` and used in a React component is correctly seen as _used_.
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
🔮 --colour-danger-hover (line 8)
|
|
86
|
+
--colour-danger-hover: #dc2626;
|
|
87
|
+
|
|
88
|
+
🔮 --spacing-xl (line 26)
|
|
89
|
+
--spacing-xl: 64px;
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Orphaned variables appear under a separate **🔮 Orphaned CSS variables** section in both the terminal output and the markdown report.
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Ghost trail — git history for orphans
|
|
97
|
+
|
|
98
|
+
Pass `--ghost-trail` to fetch the last git commit that changed each orphan's occurrence count in the codebase. For class-name orphans the search is limited to component file types (`.tsx`, `.jsx`, `.ts`, `.js`, etc.) so the result reflects _usage_ history rather than the style-file definition.
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
css-ghost ./src --ghost-trail
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
👻 .iconRight (line 28)
|
|
106
|
+
🕵️ b5c6efb · Roland Levy · 3 weeks ago — "Refactor Button component"
|
|
107
|
+
.iconRight {
|
|
108
|
+
margin-left: 6px;
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
For CSS variables the search looks for `var(--name` across all file types.
|
|
113
|
+
|
|
114
|
+
> **Note:** ghost trail runs one `git log -S` per orphan in parallel. On large histories with many orphans it may take a few seconds.
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## Pre-commit hook
|
|
119
|
+
|
|
120
|
+
Install a hook that blocks commits when orphaned selectors are present:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
css-ghost --install-hook ./src
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
This writes `.git/hooks/pre-commit` (executable, `chmod 755`) containing:
|
|
127
|
+
|
|
128
|
+
```sh
|
|
129
|
+
#!/bin/sh
|
|
130
|
+
echo "👻 css-ghost — checking for orphaned CSS selectors..."
|
|
131
|
+
npx --yes @rolandjlevy/css-ghost ./src --strict --output /tmp/css-ghost-hook-report.md
|
|
132
|
+
|
|
133
|
+
if [ $? -ne 0 ]; then
|
|
134
|
+
echo "❌ Commit blocked: orphaned CSS selectors found"
|
|
135
|
+
echo " Run 'css-ghost ./src --delete' to remove them"
|
|
136
|
+
echo " Or skip: git commit --no-verify"
|
|
137
|
+
exit 1
|
|
138
|
+
fi
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Pass multiple directories to the hook installer the same way you would to a normal scan:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
css-ghost --install-hook ./src ./components ./pages
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
To uninstall: `rm .git/hooks/pre-commit`
|
|
148
|
+
|
|
149
|
+
### `--strict` flag
|
|
150
|
+
|
|
151
|
+
`--strict` makes css-ghost exit with code 1 when any orphans are found — the mechanism the hook depends on, but also useful in CI workflows independently:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
# In CI: fail the build if orphans exist
|
|
155
|
+
css-ghost ./src --strict
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## Interactive folder picker
|
|
161
|
+
|
|
162
|
+
Run `css-ghost` with no arguments and an arrow-key picker opens in your terminal:
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
📂 Directory picker — navigate into a folder, then press [a] to add it
|
|
166
|
+
|
|
167
|
+
📍 /home/user/my-project
|
|
168
|
+
|
|
169
|
+
📁 components
|
|
170
|
+
▶ 📁 src
|
|
171
|
+
📁 styles
|
|
172
|
+
|
|
173
|
+
✅ Added to scan:
|
|
174
|
+
/home/user/my-project/src
|
|
175
|
+
|
|
176
|
+
[↑↓/jk] navigate [↵/→] enter [←] parent [a] add current dir [d] done [^C] quit
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
| Key | Action |
|
|
180
|
+
| ---------------------- | --------------------------------------- |
|
|
181
|
+
| `↑` / `↓` or `k` / `j` | Move up and down the folder list |
|
|
182
|
+
| `↵` or `→` | Enter the highlighted folder |
|
|
183
|
+
| `←` or Backspace | Go up to the parent folder |
|
|
184
|
+
| `a` | Add the current folder to the scan list |
|
|
185
|
+
| `d` | Done — start the scan |
|
|
186
|
+
| `Ctrl+C` | Quit without scanning |
|
|
187
|
+
|
|
188
|
+
You can add **multiple folders** before pressing `d` — just navigate to each one and press `a`. If you press `d` without adding anything, the folder you're currently browsing is used.
|
|
189
|
+
|
|
190
|
+
## Deleting orphans
|
|
191
|
+
|
|
192
|
+
Pass `--delete` after a scan to remove every flagged selector directly from its source file:
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
css-ghost ./src --delete
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
css-ghost prints the full report first, then lists what will be deleted and asks for confirmation before touching anything. Pass `--yes` to skip the prompt (useful in CI or scripts):
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
css-ghost ./src --delete --yes
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Works across all supported file types — `.css`, `.scss`, `.less`, MUI `makeStyles`, and vanilla-extract exports.
|
|
205
|
+
|
|
206
|
+
> **Tip:** commit or stash your work before running `--delete`. There is no undo.
|
|
46
207
|
|
|
47
208
|
## Examples
|
|
48
209
|
|
|
49
210
|
```bash
|
|
211
|
+
# Interactive folder picker (no args)
|
|
212
|
+
css-ghost
|
|
213
|
+
|
|
50
214
|
# Scan your src folder
|
|
51
215
|
css-ghost ./src
|
|
52
216
|
|
|
217
|
+
# Show git history for each orphan
|
|
218
|
+
css-ghost ./src --ghost-trail
|
|
219
|
+
|
|
220
|
+
# Fail with exit code 1 when orphans exist (CI)
|
|
221
|
+
css-ghost ./src --strict
|
|
222
|
+
|
|
223
|
+
# Install a pre-commit hook that scans ./src
|
|
224
|
+
css-ghost --install-hook ./src
|
|
225
|
+
|
|
226
|
+
# Scan and delete all orphans (with confirmation prompt)
|
|
227
|
+
css-ghost ./src --delete
|
|
228
|
+
|
|
229
|
+
# Scan and delete without prompting
|
|
230
|
+
css-ghost ./src --delete --yes
|
|
231
|
+
|
|
53
232
|
# Scan multiple directories
|
|
54
233
|
css-ghost ./src ./components ./pages
|
|
55
234
|
|
|
235
|
+
# With AI summary
|
|
236
|
+
css-ghost ./src --ai
|
|
237
|
+
|
|
56
238
|
# Write report to a custom path
|
|
57
239
|
css-ghost ./src --output ./reports/css-audit.md
|
|
58
240
|
```
|
|
@@ -87,6 +269,7 @@ Running `css-ghost ./src` prints a live summary to your terminal:
|
|
|
87
269
|
```
|
|
88
270
|
|
|
89
271
|
Each orphan shows:
|
|
272
|
+
|
|
90
273
|
- the selector name and the **exact line number** where it's defined
|
|
91
274
|
- the **full CSS rule** so you can see what it does before deciding to delete it
|
|
92
275
|
|
|
@@ -136,39 +319,65 @@ npm run scan
|
|
|
136
319
|
npm run scan -- --output ./reports/audit.md
|
|
137
320
|
```
|
|
138
321
|
|
|
139
|
-
##
|
|
322
|
+
## Troubleshooting
|
|
323
|
+
|
|
324
|
+
### `css-ghost: command not found` after global install
|
|
325
|
+
|
|
326
|
+
Your npm global bin directory may not be on your PATH. Run:
|
|
327
|
+
|
|
328
|
+
```bash
|
|
329
|
+
npm bin -g
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
Then add that path to your shell config (`~/.zshrc`, `~/.bashrc`, etc.):
|
|
140
333
|
|
|
141
|
-
|
|
334
|
+
```bash
|
|
335
|
+
export PATH="$(npm bin -g):$PATH"
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
Reload with `source ~/.zshrc`. This is a one-time fix — it won't be needed again after that.
|
|
339
|
+
|
|
340
|
+
> This is common when Node was installed via the official installer rather than nvm or Homebrew.
|
|
341
|
+
|
|
342
|
+
## Dependencies
|
|
343
|
+
|
|
344
|
+
One runtime dependency: [`picocolors`](https://github.com/alexeyraspopov/picocolors) for terminal colours. Everything else uses Node.js built-ins (`fs`, `path`, `readline`).
|
|
142
345
|
|
|
143
346
|
## Origin — built with OpenRouter free models via Claude Code
|
|
144
347
|
|
|
145
348
|
This tool was created as a test project to explore [using OpenRouter's free model tier with Claude Code](https://www.mindstudio.ai/blog/open-router-free-models-claude-code-cost-reduction) as a way to cut AI development costs.
|
|
146
349
|
|
|
147
|
-
Claude Code normally calls Anthropic's API directly. By overriding two environment variables you can route it through OpenRouter's free tier instead — meaning you can run agentic coding sessions at no cost
|
|
148
|
-
|
|
149
|
-
### Setup
|
|
350
|
+
Claude Code normally calls Anthropic's API directly. By overriding two environment variables you can route it through OpenRouter's free tier instead — meaning you can run agentic coding sessions at no cost.
|
|
150
351
|
|
|
151
|
-
|
|
352
|
+
If you've already run `css-ghost --setup`, you have an OpenRouter key. You can also export it as an environment variable — css-ghost picks it up automatically, so you don't even need `--setup`:
|
|
152
353
|
|
|
153
354
|
```bash
|
|
154
|
-
# Store your key once
|
|
155
355
|
export OPENROUTER_API_KEY="sk-or-v1-YOUR_KEY"
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
And you can reuse the same key with Claude Code by adding these aliases to your `~/.zshrc`:
|
|
156
359
|
|
|
157
|
-
|
|
360
|
+
```bash
|
|
158
361
|
alias claude-free='ANTHROPIC_BASE_URL="https://openrouter.ai/api/v1" \
|
|
159
362
|
ANTHROPIC_API_KEY="$OPENROUTER_API_KEY" \
|
|
160
|
-
claude --model "
|
|
363
|
+
claude --model "nvidia/nemotron-3.5-lightning:free"'
|
|
161
364
|
|
|
162
|
-
# Deepseek R1 — strongest free option for reasoning and debugging
|
|
163
365
|
alias claude-r1='ANTHROPIC_BASE_URL="https://openrouter.ai/api/v1" \
|
|
164
366
|
ANTHROPIC_API_KEY="$OPENROUTER_API_KEY" \
|
|
165
|
-
claude --model "
|
|
367
|
+
claude --model "qwen/qwen3.8-27b:free"'
|
|
166
368
|
```
|
|
167
369
|
|
|
168
370
|
Then `source ~/.zshrc`. Use `claude-free` or `claude-r1` in place of `claude` to start a free session.
|
|
169
371
|
|
|
170
|
-
> **Note:** Free
|
|
372
|
+
> **Note:** Free model availability changes frequently. Browse current options at [openrouter.ai/models](https://openrouter.ai/models?q=free) — filter by `:free`.
|
|
171
373
|
|
|
172
374
|
## License
|
|
173
375
|
|
|
174
376
|
MIT
|
|
377
|
+
|
|
378
|
+
```bash
|
|
379
|
+
.-.
|
|
380
|
+
(o o)
|
|
381
|
+
| O |
|
|
382
|
+
'~~~'
|
|
383
|
+
```
|
package/bin/css-ghost.js
CHANGED
|
@@ -4,54 +4,98 @@ import { resolve } from 'path';
|
|
|
4
4
|
import { existsSync as fsExists } from 'fs';
|
|
5
5
|
import { scan } from '../src/scanner.js';
|
|
6
6
|
import { generateReport } from '../src/reporter.js';
|
|
7
|
+
import { deleteOrphans } from '../src/deleter.js';
|
|
8
|
+
import { runSetup } from '../src/setup.js';
|
|
9
|
+
import { getAiSummary } from '../src/ai.js';
|
|
7
10
|
|
|
8
11
|
const args = process.argv.slice(2);
|
|
9
12
|
|
|
10
|
-
|
|
13
|
+
// --setup
|
|
14
|
+
if (args.includes('--setup')) {
|
|
15
|
+
await runSetup();
|
|
16
|
+
process.exit(0);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// --install-hook [dirs...]
|
|
20
|
+
if (args.includes('--install-hook')) {
|
|
21
|
+
const { installHook } = await import('../src/hook.js');
|
|
22
|
+
// Any positional args become the directories the hook will scan
|
|
23
|
+
const hookDirs = args.filter(a => !a.startsWith('-'));
|
|
24
|
+
installHook(hookDirs, process.cwd());
|
|
25
|
+
process.exit(0);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
11
29
|
console.log(`
|
|
12
|
-
👻 css-ghost — Find orphaned CSS class selectors
|
|
30
|
+
👻 css-ghost — Find orphaned CSS class selectors and custom properties
|
|
13
31
|
|
|
14
32
|
Usage:
|
|
15
|
-
css-ghost
|
|
33
|
+
css-ghost [directory] [directory2] ... [options]
|
|
34
|
+
|
|
35
|
+
When no directory is given, an interactive folder picker opens.
|
|
16
36
|
|
|
17
37
|
Options:
|
|
18
|
-
--output <file>
|
|
19
|
-
|
|
38
|
+
--output <file> Output report file path (default: orphan-report.md)
|
|
39
|
+
--delete Delete all orphaned selectors from source files (prompts first)
|
|
40
|
+
--yes Skip the deletion confirmation prompt (use with --delete)
|
|
41
|
+
--strict Exit with code 1 when orphans are found (useful in CI)
|
|
42
|
+
--ghost-trail Show the last git commit that touched each orphan
|
|
43
|
+
--install-hook Write a pre-commit hook to .git/hooks/pre-commit
|
|
44
|
+
--ai Append an AI-generated summary to the report
|
|
45
|
+
--setup Save your OpenRouter API key for use with --ai
|
|
46
|
+
-h, --help Show this help message
|
|
20
47
|
|
|
21
48
|
Examples:
|
|
49
|
+
css-ghost # interactive folder picker
|
|
22
50
|
css-ghost ./src
|
|
23
51
|
css-ghost ./src ./components
|
|
52
|
+
css-ghost ./src --ghost-trail # show git history for each orphan
|
|
53
|
+
css-ghost ./src --strict # exit 1 when orphans found (CI mode)
|
|
54
|
+
css-ghost ./src --delete
|
|
55
|
+
css-ghost ./src --delete --yes
|
|
56
|
+
css-ghost --install-hook ./src # install pre-commit hook for ./src
|
|
57
|
+
css-ghost ./src --ai
|
|
24
58
|
css-ghost ./src --output ./reports/css-audit.md
|
|
25
59
|
`);
|
|
26
60
|
process.exit(0);
|
|
27
61
|
}
|
|
28
62
|
|
|
29
|
-
// Parse
|
|
30
|
-
const
|
|
31
|
-
const
|
|
63
|
+
// Parse flags
|
|
64
|
+
const useAi = args.includes('--ai');
|
|
65
|
+
const doDelete = args.includes('--delete');
|
|
66
|
+
const skipConfirm = args.includes('--yes') || args.includes('-y');
|
|
67
|
+
const strict = args.includes('--strict');
|
|
68
|
+
const ghostTrail = args.includes('--ghost-trail');
|
|
69
|
+
const outputIdx = args.indexOf('--output');
|
|
70
|
+
const outputPath = outputIdx !== -1 ? args[outputIdx + 1] : 'orphan-report.md';
|
|
32
71
|
|
|
33
72
|
if (outputIdx !== -1 && !outputPath) {
|
|
34
73
|
console.error('❌ --output requires a file path');
|
|
35
74
|
process.exit(1);
|
|
36
75
|
}
|
|
37
76
|
|
|
38
|
-
// Remaining args are directories
|
|
39
|
-
const rawDirs = args.filter((a, i) =>
|
|
77
|
+
// Remaining positional args are directories (strip flags and their values)
|
|
78
|
+
const rawDirs = args.filter((a, i) =>
|
|
79
|
+
!a.startsWith('-') && (outputIdx === -1 || i !== outputIdx + 1)
|
|
80
|
+
);
|
|
40
81
|
|
|
82
|
+
// No directories given → launch interactive picker
|
|
83
|
+
let dirs;
|
|
41
84
|
if (rawDirs.length === 0) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
// Validate directories exist
|
|
47
|
-
|
|
48
|
-
for (const d of rawDirs) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
85
|
+
const { pickDirectories } = await import('../src/picker.js');
|
|
86
|
+
dirs = await pickDirectories();
|
|
87
|
+
console.log('');
|
|
88
|
+
} else {
|
|
89
|
+
// Validate provided directories exist
|
|
90
|
+
dirs = [];
|
|
91
|
+
for (const d of rawDirs) {
|
|
92
|
+
const resolved = resolve(d);
|
|
93
|
+
if (!fsExists(resolved)) {
|
|
94
|
+
console.error(`❌ Directory not found: ${d}`);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
dirs.push(resolved);
|
|
53
98
|
}
|
|
54
|
-
dirs.push(resolved);
|
|
55
99
|
}
|
|
56
100
|
|
|
57
101
|
console.log(`\n👻 css-ghost scanning ${dirs.length} director${dirs.length === 1 ? 'y' : 'ies'}...`);
|
|
@@ -65,4 +109,40 @@ if (results.length === 0) {
|
|
|
65
109
|
process.exit(0);
|
|
66
110
|
}
|
|
67
111
|
|
|
68
|
-
|
|
112
|
+
// Ghost trail — fetch git history for each orphan (opt-in, runs before report)
|
|
113
|
+
if (ghostTrail) {
|
|
114
|
+
const { isGitRepo, getGhostTrails } = await import('../src/git.js');
|
|
115
|
+
if (!isGitRepo(process.cwd())) {
|
|
116
|
+
console.warn('⚠️ --ghost-trail: not a git repository — skipping git history.\n');
|
|
117
|
+
} else {
|
|
118
|
+
const totalOrphans = results.reduce(
|
|
119
|
+
(n, r) => n + r.orphaned.length + (r.orphanedVars?.length ?? 0), 0
|
|
120
|
+
);
|
|
121
|
+
if (totalOrphans > 0) {
|
|
122
|
+
process.stdout.write(`🔍 Fetching git history for ${totalOrphans} orphan${totalOrphans !== 1 ? 's' : ''}...`);
|
|
123
|
+
for (const result of results) {
|
|
124
|
+
if (result.orphaned.length > 0) {
|
|
125
|
+
result.trails = await getGhostTrails(result.orphaned, process.cwd());
|
|
126
|
+
}
|
|
127
|
+
if ((result.orphanedVars?.length ?? 0) > 0) {
|
|
128
|
+
result.varTrails = await getGhostTrails(result.orphanedVars, process.cwd(), { isVar: true });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
process.stdout.write(' done\n\n');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Get AI summary before writing report (so it can be included in the .md)
|
|
137
|
+
const aiSummary = useAi ? await getAiSummary(results) : null;
|
|
138
|
+
|
|
139
|
+
const { totalAllOrphans } = generateReport(results, outputPath, aiSummary);
|
|
140
|
+
|
|
141
|
+
if (doDelete) {
|
|
142
|
+
await deleteOrphans(results, { yes: skipConfirm });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// --strict: exit 1 if any orphans were found (for CI / pre-commit hooks)
|
|
146
|
+
if (strict && totalAllOrphans > 0) {
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rolandjlevy/css-ghost",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Find orphaned CSS class selectors in your codebase",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,12 +8,17 @@
|
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"start": "node bin/css-ghost.js",
|
|
11
|
-
"scan": "node bin/css-ghost.js ./test"
|
|
11
|
+
"scan": "node bin/css-ghost.js ./test",
|
|
12
|
+
"scan:trail": "node bin/css-ghost.js ./test --ghost-trail",
|
|
13
|
+
"delete": "node bin/css-ghost.js ./test --delete",
|
|
14
|
+
"delete:yes": "node bin/css-ghost.js ./test --delete --yes",
|
|
15
|
+
"setup": "node bin/css-ghost.js --setup"
|
|
12
16
|
},
|
|
13
17
|
"files": [
|
|
14
18
|
"bin",
|
|
15
19
|
"src",
|
|
16
|
-
"README.md"
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE"
|
|
17
22
|
],
|
|
18
23
|
"keywords": [
|
|
19
24
|
"css",
|
|
@@ -36,5 +41,8 @@
|
|
|
36
41
|
"homepage": "https://github.com/rolandjlevy/css-ghost#readme",
|
|
37
42
|
"engines": {
|
|
38
43
|
"node": ">=18"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"picocolors": "^1.1.1"
|
|
39
47
|
}
|
|
40
48
|
}
|
package/src/ai.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { relative } from 'path';
|
|
2
|
+
import { loadConfig } from './setup.js';
|
|
3
|
+
|
|
4
|
+
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
|
5
|
+
// Fallback chain — tried in order until one returns a response
|
|
6
|
+
const FREE_MODELS = [
|
|
7
|
+
'nvidia/nemotron-3.5-lightning:free',
|
|
8
|
+
'google/gemma-4-31b-it:free',
|
|
9
|
+
'qwen/qwen3.8-27b:free',
|
|
10
|
+
'thinkingmachines/inkling:free',
|
|
11
|
+
'nvidia/nemotron-3-super-120b-a12b:free',
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
function getApiKey() {
|
|
15
|
+
if (process.env.OPENROUTER_API_KEY) return process.env.OPENROUTER_API_KEY;
|
|
16
|
+
const config = loadConfig();
|
|
17
|
+
return config.openrouterApiKey || null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function getModelOverride() {
|
|
21
|
+
if (process.env.OPENROUTER_MODEL) return process.env.OPENROUTER_MODEL;
|
|
22
|
+
const config = loadConfig();
|
|
23
|
+
return config.openrouterModel || null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function tryModel(model, key, prompt) {
|
|
27
|
+
const response = await fetch(OPENROUTER_URL, {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
headers: {
|
|
30
|
+
'Authorization': `Bearer ${key}`,
|
|
31
|
+
'Content-Type': 'application/json',
|
|
32
|
+
'HTTP-Referer': 'https://github.com/rolandjlevy/css-ghost',
|
|
33
|
+
'X-Title': 'css-ghost',
|
|
34
|
+
},
|
|
35
|
+
body: JSON.stringify({
|
|
36
|
+
model,
|
|
37
|
+
messages: [{ role: 'user', content: prompt }],
|
|
38
|
+
max_tokens: 300,
|
|
39
|
+
}),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (!response.ok) return null;
|
|
43
|
+
const data = await response.json();
|
|
44
|
+
return data.choices?.[0]?.message?.content?.trim() || null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function buildPrompt(results) {
|
|
48
|
+
const totalOrphaned = results.reduce((n, r) => n + r.orphaned.length, 0);
|
|
49
|
+
const filesWithOrphans = results.filter(r => r.orphaned.length > 0);
|
|
50
|
+
|
|
51
|
+
const details = filesWithOrphans.map(r => {
|
|
52
|
+
const relPath = relative(process.cwd(), r.filePath);
|
|
53
|
+
const names = r.orphaned.map(o => `.${o.name}`).join(', ');
|
|
54
|
+
return `- ${relPath}: ${names}`;
|
|
55
|
+
}).join('\n');
|
|
56
|
+
|
|
57
|
+
return `You are a CSS code quality assistant. A static analysis tool found ${totalOrphaned} orphaned CSS selectors (defined but never referenced in any component file) across ${filesWithOrphans.length} file(s):
|
|
58
|
+
|
|
59
|
+
${details}
|
|
60
|
+
|
|
61
|
+
Write a short, practical summary (3–5 sentences) for a developer. Cover:
|
|
62
|
+
1. Whether this looks like a significant problem or minor cleanup
|
|
63
|
+
2. Which files or selectors are most worth investigating first
|
|
64
|
+
3. One concrete suggestion for how to approach the cleanup
|
|
65
|
+
|
|
66
|
+
Be direct and specific. No fluff.`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function getAiSummary(results) {
|
|
70
|
+
const key = getApiKey();
|
|
71
|
+
|
|
72
|
+
if (!key) {
|
|
73
|
+
console.log('💡 Tip: Run css-ghost --setup to enable AI summaries with --ai\n');
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const override = getModelOverride();
|
|
78
|
+
const models = override ? [override] : FREE_MODELS;
|
|
79
|
+
const prompt = buildPrompt(results);
|
|
80
|
+
|
|
81
|
+
console.log('🤖 Generating AI summary...\n');
|
|
82
|
+
|
|
83
|
+
for (const model of models) {
|
|
84
|
+
try {
|
|
85
|
+
const content = await tryModel(model, key, prompt);
|
|
86
|
+
if (content) return content;
|
|
87
|
+
} catch {
|
|
88
|
+
// try next model
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.warn('⚠️ AI summary unavailable — all free models failed or returned empty responses.\n');
|
|
93
|
+
return null;
|
|
94
|
+
}
|