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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "swynx-lite",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "Dead code detection and cleanup for 35 languages",
5
5
  "type": "module",
6
6
  "bin": {
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('Remove dead code with quarantine safety net')
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
 
@@ -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 clean', nc)} to remove ${formatNumber(deadCount)} dead file${deadCount === 1 ? '' : 's'} ${c(DIM, `(saves ${formatBytes(summary.totalDeadBytes)})`, nc)}`);
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
+ }