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 +12 -0
- package/package.json +1 -1
- package/src/cli.mjs +18 -2
- package/src/output/console.mjs +46 -3
- package/src/quarantine-cmd.mjs +201 -0
- package/src/shared/scanner/analysers/deadcode.mjs +7 -0
- package/src/shared/scanner/parsers/javascript.mjs +4 -1
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
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
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|