swynx-lite 1.0.1 → 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/LICENSE ADDED
@@ -0,0 +1,12 @@
1
+ Business Source License 1.1
2
+
3
+ Licensor: Swynx (swynx.io)
4
+ Licensed Work: Swynx Lite
5
+ Change Date: Four years from each release date
6
+ Change License: Apache License, Version 2.0
7
+
8
+ For the full BSL 1.1 license text, see: https://mariadb.com/bsl11/
9
+
10
+ Additional Use Grant: You may use the Licensed Work for any purpose,
11
+ including production use, without charge. You may not use the Licensed
12
+ Work to build a competing dead code detection product or service.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "swynx-lite",
3
- "version": "1.0.1",
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
 
@@ -108,7 +108,7 @@ export function renderScanOutput(results, options = {}) {
108
108
  }
109
109
 
110
110
  if (!verbose && deadFiles.length > 5) {
111
- lines.push(` ${c(DIM, `... and ${deadFiles.length - 5} more`, nc)}`);
111
+ lines.push(` ${c(DIM, `... and ${deadFiles.length - 5} more (use --verbose to show all)`, nc)}`);
112
112
  }
113
113
 
114
114
  lines.push('');
@@ -130,7 +130,7 @@ export function renderScanOutput(results, options = {}) {
130
130
  }
131
131
 
132
132
  if (!verbose && partialFiles.length > 5) {
133
- lines.push(` ${c(DIM, `... and ${partialFiles.length - 5} more`, nc)}`);
133
+ lines.push(` ${c(DIM, `... and ${partialFiles.length - 5} more (use --verbose to show all)`, nc)}`);
134
134
  }
135
135
 
136
136
  lines.push('');
@@ -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
+ }
@@ -5979,6 +5979,13 @@ export async function findDeadCode(jsAnalysis, importGraph, projectPath = null,
5979
5979
  }
5980
5980
  if (!content) continue;
5981
5981
 
5982
+ // Developer-override: skip files with explicit "keep" comments in the first ~50 lines
5983
+ // Matches: DO NOT DELETE, DO NOT REMOVE, KEEP THIS FILE, @preserve
5984
+ const head = content.slice(0, 2000);
5985
+ if (/\b(DO\s+NOT\s+(DELETE|REMOVE)|KEEP\s+THIS\s+FILE|@preserve)\b/i.test(head)) {
5986
+ continue;
5987
+ }
5988
+
5982
5989
  // A8: Skip git history when there are many dead files (>200) to avoid thousands of subprocess forks
5983
5990
  // Only fetch git history for the first 200 dead files (sorted by size later)
5984
5991
  const gitHistory = results.fullyDeadFiles.length < 200
@@ -521,7 +521,10 @@ export async function parseJavaScript(file) {
521
521
 
522
522
  } catch (parseError) {
523
523
  // Fallback to regex parsing for files Babel can't handle
524
- console.warn(`[Parser] Babel failed for ${relativePath}, using regex fallback: ${parseError.message}`);
524
+ // Suppress warnings for Vue/Svelte — Babel can't parse the full SFC, regex fallback is expected
525
+ if (!isVueSFC && process.env.SWYNX_VERBOSE) {
526
+ console.warn(`[Parser] Babel failed for ${relativePath}, using regex fallback: ${parseError.message}`);
527
+ }
525
528
  return parseWithRegex(filePath, relativePath, content, lines);
526
529
  }
527
530
  }