swynx-lite 1.0.2 → 1.1.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/package.json +1 -1
- package/src/cli.mjs +18 -2
- package/src/output/console.mjs +44 -1
- package/src/quarantine-cmd.mjs +201 -0
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -53,12 +53,28 @@ program
|
|
|
53
53
|
process.exit(exitCode);
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
+
// ── quarantine ───────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
program
|
|
59
|
+
.command('quarantine')
|
|
60
|
+
.argument('[path]', 'Path to scan', '.')
|
|
61
|
+
.description('Safely move dead files to .swynx-quarantine/ (reversible)')
|
|
62
|
+
.option('--dry-run', 'Preview what would be quarantined')
|
|
63
|
+
.option('--yes', 'Skip confirmation prompt')
|
|
64
|
+
.option('--json', 'Output as JSON')
|
|
65
|
+
.option('--no-color', 'Disable ANSI colours')
|
|
66
|
+
.action(async (path, opts) => {
|
|
67
|
+
const { runQuarantine } = await import('./quarantine-cmd.mjs');
|
|
68
|
+
const { exitCode } = await runQuarantine(path, opts);
|
|
69
|
+
process.exit(exitCode);
|
|
70
|
+
});
|
|
71
|
+
|
|
56
72
|
// ── clean ────────────────────────────────────────────────────────────────────
|
|
57
73
|
|
|
58
74
|
program
|
|
59
75
|
.command('clean')
|
|
60
76
|
.argument('[path]', 'Path to clean', '.')
|
|
61
|
-
.description('
|
|
77
|
+
.description('Quarantine dead files + clean dead imports and barrel exports')
|
|
62
78
|
.option('--dry-run', 'Preview what would be removed')
|
|
63
79
|
.option('--no-quarantine', 'Delete directly without quarantine backup')
|
|
64
80
|
.option('--no-import-clean', 'Skip cleaning dead imports from live files')
|
|
@@ -252,7 +268,7 @@ function confirmPrompt(message) {
|
|
|
252
268
|
// If no command provided, default to `scan .`
|
|
253
269
|
// This makes `npx swynx-lite` work like `npx knip`
|
|
254
270
|
const args = process.argv.slice(2);
|
|
255
|
-
const commands = ['scan', 'clean', 'restore', 'purge', 'init', 'help'];
|
|
271
|
+
const commands = ['scan', 'quarantine', 'clean', 'restore', 'purge', 'init', 'help'];
|
|
256
272
|
const hasCommand = args.some(a => commands.includes(a));
|
|
257
273
|
const hasHelpOrVersion = args.some(a => ['-h', '--help', '-v', '--version', '--pro'].includes(a));
|
|
258
274
|
|
package/src/output/console.mjs
CHANGED
|
@@ -183,7 +183,7 @@ export function renderScanOutput(results, options = {}) {
|
|
|
183
183
|
lines.push(` ${c(DIM, '──', nc)} ${c(BOLD, 'What Next', nc)} ${c(DIM, hr, nc)}`);
|
|
184
184
|
lines.push('');
|
|
185
185
|
if (deadCount > 0) {
|
|
186
|
-
lines.push(` Run ${c(BRIGHT_CYAN, 'swynx-lite
|
|
186
|
+
lines.push(` Run ${c(BRIGHT_CYAN, 'swynx-lite quarantine', nc)} to safely move ${formatNumber(deadCount)} dead file${deadCount === 1 ? '' : 's'} to .swynx-quarantine/ ${c(DIM, `(${formatBytes(summary.totalDeadBytes)})`, nc)}`);
|
|
187
187
|
}
|
|
188
188
|
if (partialFiles.length > 0) {
|
|
189
189
|
const totalDeadExports = partialFiles.reduce((sum, f) => sum + (f.deadExports || []).length, 0);
|
|
@@ -268,6 +268,49 @@ export function renderCleanOutput(results, options = {}) {
|
|
|
268
268
|
return lines.join('\n');
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
+
/**
|
|
272
|
+
* Render quarantine results to the console
|
|
273
|
+
*/
|
|
274
|
+
export function renderQuarantineOutput(results, options = {}) {
|
|
275
|
+
const { noColor = false } = options;
|
|
276
|
+
const lines = [];
|
|
277
|
+
const nc = noColor;
|
|
278
|
+
|
|
279
|
+
lines.push('');
|
|
280
|
+
lines.push(` ${c(BOLD + BRIGHT_CYAN, 'swynx lite', nc)} ${c(DIM, 'v1.0.0', nc)}${results.dryRun ? c(DIM, ' — dry run (no files will be moved)', nc) : ''}`);
|
|
281
|
+
lines.push('');
|
|
282
|
+
|
|
283
|
+
if (results.dryRun) {
|
|
284
|
+
lines.push(` Would quarantine ${c(BOLD, formatNumber(results.filesQuarantined), nc)} file${results.filesQuarantined === 1 ? '' : 's'} ${c(DIM, `(${formatBytes(results.bytesRemoved)})`, nc)}:`);
|
|
285
|
+
lines.push('');
|
|
286
|
+
|
|
287
|
+
const files = results.files || [];
|
|
288
|
+
const showCount = Math.min(10, files.length);
|
|
289
|
+
for (let i = 0; i < showCount; i++) {
|
|
290
|
+
const f = files[i];
|
|
291
|
+
const path = typeof f === 'string' ? f : (f.file || f.path || '');
|
|
292
|
+
const size = typeof f === 'object' && f.size ? formatBytes(f.size) : '';
|
|
293
|
+
lines.push(` ${c(WHITE, pad(path, 42), nc)} ${c(DIM, rpad(size, 10), nc)}`);
|
|
294
|
+
}
|
|
295
|
+
if (files.length > 10) {
|
|
296
|
+
lines.push(` ${c(DIM, `... and ${files.length - 10} more`, nc)}`);
|
|
297
|
+
}
|
|
298
|
+
lines.push('');
|
|
299
|
+
} else {
|
|
300
|
+
lines.push(` ${c(BRIGHT_GREEN, '\u2713', nc)} Quarantined ${formatNumber(results.filesQuarantined)} file${results.filesQuarantined === 1 ? '' : 's'} to .swynx-quarantine/ ${c(DIM, `(${formatBytes(results.bytesRemoved)})`, nc)}`);
|
|
301
|
+
lines.push('');
|
|
302
|
+
lines.push(` ${c(BOLD, 'Test your app now.', nc)} If anything breaks:`);
|
|
303
|
+
lines.push(` Run ${c(BRIGHT_CYAN, 'swynx-lite restore', nc)} to bring everything back`);
|
|
304
|
+
lines.push('');
|
|
305
|
+
lines.push(` If everything works:`);
|
|
306
|
+
lines.push(` Run ${c(BRIGHT_CYAN, 'swynx-lite purge', nc)} to permanently delete the quarantined files`);
|
|
307
|
+
lines.push(` Run ${c(BRIGHT_CYAN, 'swynx-lite clean', nc)} to also clean dead imports and barrel exports`);
|
|
308
|
+
lines.push('');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return lines.join('\n');
|
|
312
|
+
}
|
|
313
|
+
|
|
271
314
|
/**
|
|
272
315
|
* Render restore result
|
|
273
316
|
*/
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// src/quarantine-cmd.mjs
|
|
2
|
+
// Quarantine orchestrator — scan, move dead files to .swynx-quarantine/, done.
|
|
3
|
+
// No import/barrel cleanup — just the safe move-and-test flow.
|
|
4
|
+
|
|
5
|
+
import { resolve, join } from 'path';
|
|
6
|
+
import { existsSync, readFileSync, appendFileSync, writeFileSync } from 'fs';
|
|
7
|
+
import { loadConfig } from './config.mjs';
|
|
8
|
+
import { scanDeadCode } from './shared/scanner/scan-dead-code.mjs';
|
|
9
|
+
import { createSession, quarantineFile } from './shared/fixer/quarantine.mjs';
|
|
10
|
+
import { ProgressSpinner } from './output/progress.mjs';
|
|
11
|
+
import { renderQuarantineOutput } from './output/console.mjs';
|
|
12
|
+
import { formatCleanJSON } from './output/json.mjs';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Run the quarantine command
|
|
16
|
+
*/
|
|
17
|
+
export async function runQuarantine(targetPath, cliOptions = {}) {
|
|
18
|
+
const projectPath = resolve(targetPath || '.');
|
|
19
|
+
|
|
20
|
+
if (!existsSync(projectPath)) {
|
|
21
|
+
console.error(` Error: path not found — ${projectPath}`);
|
|
22
|
+
return { exitCode: 2 };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const config = loadConfig(projectPath, cliOptions);
|
|
26
|
+
const isJSON = cliOptions.json || false;
|
|
27
|
+
const noColor = cliOptions.color === false || !!process.env.NO_COLOR;
|
|
28
|
+
const dryRun = cliOptions.dryRun || false;
|
|
29
|
+
const autoYes = cliOptions.yes || false;
|
|
30
|
+
|
|
31
|
+
const spinner = new ProgressSpinner({
|
|
32
|
+
enabled: !isJSON,
|
|
33
|
+
noColor,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Step 1: Scan
|
|
37
|
+
spinner.start('Scanning for dead code...');
|
|
38
|
+
|
|
39
|
+
let scanResults;
|
|
40
|
+
try {
|
|
41
|
+
scanResults = await scanDeadCode(projectPath, {
|
|
42
|
+
onProgress: (p) => spinner.update(p),
|
|
43
|
+
});
|
|
44
|
+
} catch (e) {
|
|
45
|
+
spinner.stop();
|
|
46
|
+
console.error(` Error during scan: ${e.message}`);
|
|
47
|
+
return { exitCode: 2 };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
spinner.stop();
|
|
51
|
+
|
|
52
|
+
// Apply ignore filters
|
|
53
|
+
if (config.ignore.length > 0) {
|
|
54
|
+
let minimatch;
|
|
55
|
+
try {
|
|
56
|
+
const mod = await import('minimatch');
|
|
57
|
+
minimatch = mod.minimatch || mod.default;
|
|
58
|
+
} catch {
|
|
59
|
+
try {
|
|
60
|
+
const g = await import('glob');
|
|
61
|
+
minimatch = g.minimatch;
|
|
62
|
+
} catch { /* skip */ }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (minimatch) {
|
|
66
|
+
const ignoreFilter = f => {
|
|
67
|
+
const filePath = f.file || f.path || '';
|
|
68
|
+
return !config.ignore.some(pattern => minimatch(filePath, pattern, { dot: true }));
|
|
69
|
+
};
|
|
70
|
+
scanResults.deadFiles = scanResults.deadFiles.filter(ignoreFilter);
|
|
71
|
+
if (scanResults.partialFiles) {
|
|
72
|
+
scanResults.partialFiles = scanResults.partialFiles.filter(ignoreFilter);
|
|
73
|
+
}
|
|
74
|
+
const deadCount = scanResults.deadFiles.length;
|
|
75
|
+
const totalDeadBytes = scanResults.deadFiles.reduce((sum, f) => sum + (f.size || 0), 0);
|
|
76
|
+
const deadRate = scanResults.summary.totalFiles > 0
|
|
77
|
+
? ((deadCount / scanResults.summary.totalFiles) * 100).toFixed(2)
|
|
78
|
+
: '0.00';
|
|
79
|
+
scanResults.summary.deadFiles = deadCount;
|
|
80
|
+
scanResults.summary.partialFiles = (scanResults.partialFiles || []).length;
|
|
81
|
+
scanResults.summary.totalDeadBytes = totalDeadBytes;
|
|
82
|
+
scanResults.summary.deadRate = `${deadRate}%`;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const deadFiles = scanResults.deadFiles || [];
|
|
87
|
+
if (deadFiles.length === 0) {
|
|
88
|
+
if (isJSON) {
|
|
89
|
+
console.log(formatCleanJSON({ dryRun, filesRemoved: 0, bytesRemoved: 0, files: [] }));
|
|
90
|
+
} else {
|
|
91
|
+
console.log('\n No dead code found. Your codebase is clean!\n');
|
|
92
|
+
}
|
|
93
|
+
return { exitCode: 0 };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const totalBytes = deadFiles.reduce((sum, f) => sum + (f.size || 0), 0);
|
|
97
|
+
|
|
98
|
+
// Step 2: Confirmation
|
|
99
|
+
if (!dryRun && !autoYes && !isJSON) {
|
|
100
|
+
console.log('');
|
|
101
|
+
console.log(` ${deadFiles.length} dead file${deadFiles.length === 1 ? '' : 's'} found (${formatBytes(totalBytes)})`);
|
|
102
|
+
console.log('');
|
|
103
|
+
|
|
104
|
+
const showCount = Math.min(5, deadFiles.length);
|
|
105
|
+
for (let i = 0; i < showCount; i++) {
|
|
106
|
+
const f = deadFiles[i];
|
|
107
|
+
console.log(` ${(f.file || '').padEnd(42)} ${formatBytes(f.size).padStart(10)}`);
|
|
108
|
+
}
|
|
109
|
+
if (deadFiles.length > 5) {
|
|
110
|
+
console.log(` ... and ${deadFiles.length - 5} more`);
|
|
111
|
+
}
|
|
112
|
+
console.log('');
|
|
113
|
+
console.log(` Files will be moved to .swynx-quarantine/ (reversible)`);
|
|
114
|
+
console.log('');
|
|
115
|
+
|
|
116
|
+
const ok = await confirm(` Quarantine ${deadFiles.length} file${deadFiles.length === 1 ? '' : 's'}? (y/N) `);
|
|
117
|
+
if (!ok) {
|
|
118
|
+
console.log(' Cancelled.\n');
|
|
119
|
+
return { exitCode: 0 };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Step 3: Quarantine
|
|
124
|
+
const quarantinedFiles = [];
|
|
125
|
+
let sessionId = null;
|
|
126
|
+
let bytesRemoved = 0;
|
|
127
|
+
|
|
128
|
+
if (!dryRun) {
|
|
129
|
+
const session = createSession(projectPath, 'quarantine');
|
|
130
|
+
sessionId = session.sessionId;
|
|
131
|
+
|
|
132
|
+
spinner.start('Quarantining files...');
|
|
133
|
+
for (const f of deadFiles) {
|
|
134
|
+
const filePath = f.file || f.path || '';
|
|
135
|
+
const fullPath = join(projectPath, filePath);
|
|
136
|
+
try {
|
|
137
|
+
quarantineFile(projectPath, sessionId, fullPath);
|
|
138
|
+
quarantinedFiles.push(filePath);
|
|
139
|
+
bytesRemoved += f.size || 0;
|
|
140
|
+
} catch {
|
|
141
|
+
// Skip files that can't be quarantined
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
spinner.stop();
|
|
145
|
+
|
|
146
|
+
// Auto-add .swynx-quarantine/ to .gitignore
|
|
147
|
+
ensureGitignore(projectPath);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const result = {
|
|
151
|
+
dryRun,
|
|
152
|
+
filesQuarantined: dryRun ? deadFiles.length : quarantinedFiles.length,
|
|
153
|
+
bytesRemoved: dryRun ? totalBytes : bytesRemoved,
|
|
154
|
+
sessionId,
|
|
155
|
+
files: deadFiles.map(f => ({ file: f.file, size: f.size })),
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
if (isJSON) {
|
|
159
|
+
console.log(formatCleanJSON({ ...result, filesRemoved: result.filesQuarantined }));
|
|
160
|
+
} else {
|
|
161
|
+
console.log(renderQuarantineOutput(result, { noColor }));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return { exitCode: 0, result };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Add .swynx-quarantine/ to .gitignore if not already there
|
|
169
|
+
*/
|
|
170
|
+
function ensureGitignore(projectPath) {
|
|
171
|
+
const gitignorePath = join(projectPath, '.gitignore');
|
|
172
|
+
const entry = '.swynx-quarantine/';
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
if (existsSync(gitignorePath)) {
|
|
176
|
+
const content = readFileSync(gitignorePath, 'utf-8');
|
|
177
|
+
if (content.includes(entry)) return;
|
|
178
|
+
appendFileSync(gitignorePath, `\n# Swynx quarantine\n${entry}\n`);
|
|
179
|
+
} else {
|
|
180
|
+
writeFileSync(gitignorePath, `# Swynx quarantine\n${entry}\n`);
|
|
181
|
+
}
|
|
182
|
+
} catch { /* best effort */ }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function formatBytes(bytes) {
|
|
186
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
187
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
188
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function confirm(message) {
|
|
192
|
+
return new Promise((resolve) => {
|
|
193
|
+
process.stdout.write(message);
|
|
194
|
+
process.stdin.setEncoding('utf-8');
|
|
195
|
+
process.stdin.resume();
|
|
196
|
+
process.stdin.once('data', (data) => {
|
|
197
|
+
process.stdin.pause();
|
|
198
|
+
resolve(data.trim().toLowerCase() === 'y');
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
}
|