swynx-lite 1.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.
Files changed (36) hide show
  1. package/README.md +113 -0
  2. package/bin/swynx-lite +3 -0
  3. package/package.json +47 -0
  4. package/src/clean.mjs +280 -0
  5. package/src/cli.mjs +264 -0
  6. package/src/config.mjs +121 -0
  7. package/src/output/console.mjs +298 -0
  8. package/src/output/json.mjs +76 -0
  9. package/src/output/progress.mjs +57 -0
  10. package/src/scan.mjs +143 -0
  11. package/src/security.mjs +62 -0
  12. package/src/shared/fixer/barrel-cleaner.mjs +192 -0
  13. package/src/shared/fixer/import-cleaner.mjs +237 -0
  14. package/src/shared/fixer/quarantine.mjs +218 -0
  15. package/src/shared/scanner/analysers/buildSystems.mjs +647 -0
  16. package/src/shared/scanner/analysers/configParsers.mjs +1086 -0
  17. package/src/shared/scanner/analysers/deadcode.mjs +6194 -0
  18. package/src/shared/scanner/analysers/entryPointDetector.mjs +634 -0
  19. package/src/shared/scanner/analysers/generatedCode.mjs +297 -0
  20. package/src/shared/scanner/analysers/imports.mjs +60 -0
  21. package/src/shared/scanner/discovery.mjs +240 -0
  22. package/src/shared/scanner/parse-worker.mjs +82 -0
  23. package/src/shared/scanner/parsers/assets.mjs +44 -0
  24. package/src/shared/scanner/parsers/csharp.mjs +400 -0
  25. package/src/shared/scanner/parsers/css.mjs +60 -0
  26. package/src/shared/scanner/parsers/go.mjs +445 -0
  27. package/src/shared/scanner/parsers/java.mjs +364 -0
  28. package/src/shared/scanner/parsers/javascript.mjs +823 -0
  29. package/src/shared/scanner/parsers/kotlin.mjs +350 -0
  30. package/src/shared/scanner/parsers/python.mjs +497 -0
  31. package/src/shared/scanner/parsers/registry.mjs +233 -0
  32. package/src/shared/scanner/parsers/rust.mjs +427 -0
  33. package/src/shared/scanner/scan-dead-code.mjs +316 -0
  34. package/src/shared/security/patterns.mjs +349 -0
  35. package/src/shared/security/proximity.mjs +84 -0
  36. package/src/shared/security/scanner.mjs +269 -0
package/src/cli.mjs ADDED
@@ -0,0 +1,264 @@
1
+ // src/cli.mjs
2
+ // Commander-based CLI for swynx-lite
3
+
4
+ import { Command } from 'commander';
5
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
6
+ import { join, resolve } from 'path';
7
+
8
+ const VERSION = '1.0.0';
9
+
10
+ // ── First-run detection ──────────────────────────────────────────────────────
11
+
12
+ function checkFirstRun() {
13
+ const marker = join(
14
+ process.env.HOME || process.env.USERPROFILE || '/tmp',
15
+ '.swynx-lite-init'
16
+ );
17
+ if (!existsSync(marker)) {
18
+ try { writeFileSync(marker, Date.now().toString()); } catch { /* */ }
19
+ return true;
20
+ }
21
+ return false;
22
+ }
23
+
24
+ // ── CLI Setup ────────────────────────────────────────────────────────────────
25
+
26
+ const program = new Command();
27
+
28
+ program
29
+ .name('swynx-lite')
30
+ .description('Dead code detection and cleanup for 35 languages')
31
+ .version(VERSION, '-v, --version');
32
+
33
+ // ── scan ─────────────────────────────────────────────────────────────────────
34
+
35
+ program
36
+ .command('scan')
37
+ .argument('[path]', 'Path to scan', '.')
38
+ .description('Scan for dead code and security issues')
39
+ .option('--json', 'Output as JSON instead of console')
40
+ .option('--ci', 'CI mode: exit code 1 if dead code found')
41
+ .option('--threshold <n>', 'In CI mode, only fail if dead rate > n%', parseFloat)
42
+ .option('--no-security', 'Skip security scanning')
43
+ .option('--ignore <glob>', 'Additional paths to ignore (repeatable)', collect, [])
44
+ .option('--verbose', 'Show detailed progress')
45
+ .option('--no-color', 'Disable ANSI colours')
46
+ .action(async (path, opts) => {
47
+ if (checkFirstRun() && !opts.json && !opts.ci) {
48
+ console.log(`\n ${dim('swynx lite — no telemetry, no tracking, fully offline.')}`);
49
+ }
50
+
51
+ const { runScan } = await import('./scan.mjs');
52
+ const { exitCode } = await runScan(path, opts);
53
+ process.exit(exitCode);
54
+ });
55
+
56
+ // ── clean ────────────────────────────────────────────────────────────────────
57
+
58
+ program
59
+ .command('clean')
60
+ .argument('[path]', 'Path to clean', '.')
61
+ .description('Remove dead code with quarantine safety net')
62
+ .option('--dry-run', 'Preview what would be removed')
63
+ .option('--no-quarantine', 'Delete directly without quarantine backup')
64
+ .option('--no-import-clean', 'Skip cleaning dead imports from live files')
65
+ .option('--no-barrel-clean', 'Skip cleaning dead re-exports from barrel files')
66
+ .option('--yes', 'Skip confirmation prompt')
67
+ .option('--json', 'Output as JSON')
68
+ .option('--no-color', 'Disable ANSI colours')
69
+ .action(async (path, opts) => {
70
+ const { runClean } = await import('./clean.mjs');
71
+ const { exitCode } = await runClean(path, opts);
72
+ process.exit(exitCode);
73
+ });
74
+
75
+ // ── restore ──────────────────────────────────────────────────────────────────
76
+
77
+ program
78
+ .command('restore')
79
+ .description('Undo the last clean operation')
80
+ .option('--list', 'List all available restore points')
81
+ .option('--id <id>', 'Restore a specific quarantine session')
82
+ .option('--no-color', 'Disable ANSI colours')
83
+ .action(async (opts) => {
84
+ const { listSessions, restoreSession } = await import('./shared/fixer/quarantine.mjs');
85
+ const { renderRestoreOutput, renderSessionList } = await import('./output/console.mjs');
86
+ const noColor = opts.color === false || !!process.env.NO_COLOR;
87
+ const projectPath = resolve('.');
88
+
89
+ if (opts.list) {
90
+ const sessions = listSessions(projectPath);
91
+ console.log(renderSessionList(sessions, { noColor }));
92
+ process.exit(0);
93
+ }
94
+
95
+ const sessions = listSessions(projectPath);
96
+ if (sessions.length === 0) {
97
+ console.log('\n No quarantine sessions found.\n');
98
+ process.exit(0);
99
+ }
100
+
101
+ // Restore specific or most recent active session
102
+ const sessionId = opts.id || sessions.find(s => s.status === 'active')?.sessionId || sessions[0].sessionId;
103
+
104
+ try {
105
+ const result = restoreSession(projectPath, sessionId);
106
+ console.log(renderRestoreOutput(result, { noColor }));
107
+ } catch (e) {
108
+ console.error(` Error: ${e.message}\n`);
109
+ process.exit(2);
110
+ }
111
+ });
112
+
113
+ // ── purge ────────────────────────────────────────────────────────────────────
114
+
115
+ program
116
+ .command('purge')
117
+ .description('Permanently delete quarantined files')
118
+ .option('--id <id>', 'Purge a specific session only')
119
+ .option('--yes', 'Skip confirmation')
120
+ .option('--no-color', 'Disable ANSI colours')
121
+ .action(async (opts) => {
122
+ const { listSessions, purgeSession } = await import('./shared/fixer/quarantine.mjs');
123
+ const { renderPurgeOutput } = await import('./output/console.mjs');
124
+ const noColor = opts.color === false || !!process.env.NO_COLOR;
125
+ const projectPath = resolve('.');
126
+
127
+ const sessions = listSessions(projectPath);
128
+ if (sessions.length === 0) {
129
+ console.log('\n No quarantine sessions to purge.\n');
130
+ process.exit(0);
131
+ }
132
+
133
+ const autoYes = opts.yes || false;
134
+
135
+ if (!autoYes) {
136
+ const totalSize = sessions.reduce((sum, s) => sum + (s.totalSize || 0), 0);
137
+ const totalFiles = sessions.reduce((sum, s) => sum + (s.fileCount || 0), 0);
138
+ console.log(`\n ${sessions.length} quarantine session${sessions.length === 1 ? '' : 's'} found (${formatBytes(totalSize)} total)\n`);
139
+
140
+ const ok = await confirmPrompt(` Permanently delete all quarantined files? (y/N) `);
141
+ if (!ok) {
142
+ console.log(' Cancelled.\n');
143
+ process.exit(0);
144
+ }
145
+ }
146
+
147
+ let purged = 0;
148
+ let totalFiles = 0;
149
+ let totalSize = 0;
150
+
151
+ if (opts.id) {
152
+ const result = purgeSession(projectPath, opts.id);
153
+ purged = 1;
154
+ totalFiles = result.purgedFiles;
155
+ } else {
156
+ for (const s of sessions) {
157
+ try {
158
+ purgeSession(projectPath, s.sessionId);
159
+ purged++;
160
+ totalFiles += s.fileCount || 0;
161
+ totalSize += s.totalSize || 0;
162
+ } catch { /* skip */ }
163
+ }
164
+ }
165
+
166
+ console.log(renderPurgeOutput({ purged, totalFiles, totalSize }, { noColor }));
167
+ });
168
+
169
+ // ── init ─────────────────────────────────────────────────────────────────────
170
+
171
+ program
172
+ .command('init')
173
+ .description('Create a .swynx-lite.json config file')
174
+ .action(async () => {
175
+ const configPath = join(resolve('.'), '.swynx-lite.json');
176
+ if (existsSync(configPath)) {
177
+ console.log('\n .swynx-lite.json already exists.\n');
178
+ process.exit(0);
179
+ }
180
+
181
+ const { generateConfigTemplate } = await import('./config.mjs');
182
+ writeFileSync(configPath, generateConfigTemplate() + '\n');
183
+
184
+ console.log('\n Created .swynx-lite.json');
185
+ console.log('');
186
+ console.log(' Edit this file to:');
187
+ console.log(' \u2022 Ignore specific paths or patterns');
188
+ console.log(' \u2022 Set CI thresholds');
189
+ console.log(' \u2022 Configure security scanning');
190
+ console.log('');
191
+ });
192
+
193
+ // ── --pro flag ───────────────────────────────────────────────────────────────
194
+
195
+ program.option('--pro', 'Show Swynx Pro features');
196
+
197
+ program.on('option:pro', () => {
198
+ const d = process.env.NO_COLOR ? (t) => t : (t) => `\x1b[2m${t}\x1b[0m`;
199
+ const b = process.env.NO_COLOR ? (t) => t : (t) => `\x1b[1m${t}\x1b[0m`;
200
+ const c = process.env.NO_COLOR ? (t) => t : (t) => `\x1b[1;96m${t}\x1b[0m`;
201
+
202
+ console.log('');
203
+ console.log(` ${c('swynx lite')} ${d(`v${VERSION}`)} — Free forever`);
204
+ console.log('');
205
+ console.log(` ${b('Swynx Pro')} is built for engineering teams:`);
206
+ console.log(' \u2022 Web dashboard with historical trends');
207
+ console.log(' \u2022 Predictive intelligence (decay signals)');
208
+ console.log(' \u2022 Per-export dead code detection');
209
+ console.log(' \u2022 Emissions and waste analysis');
210
+ console.log(' \u2022 Dependency and license scanning');
211
+ console.log(' \u2022 SARIF, Markdown, and PDF reports');
212
+ console.log(' \u2022 Enterprise reporting suite');
213
+ console.log(' \u2022 Air-gapped deployment');
214
+ console.log(' \u2022 Priority support');
215
+ console.log('');
216
+ console.log(` From \u00a32,000/year \u00b7 https://swynx.io/pro`);
217
+ console.log('');
218
+ process.exit(0);
219
+ });
220
+
221
+ // ── Helpers ──────────────────────────────────────────────────────────────────
222
+
223
+ function collect(val, arr) {
224
+ arr.push(val);
225
+ return arr;
226
+ }
227
+
228
+ function dim(text) {
229
+ return process.env.NO_COLOR ? text : `\x1b[2m${text}\x1b[0m`;
230
+ }
231
+
232
+ function formatBytes(bytes) {
233
+ if (bytes < 1024) return `${bytes} B`;
234
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
235
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
236
+ }
237
+
238
+ function confirmPrompt(message) {
239
+ return new Promise((resolve) => {
240
+ process.stdout.write(message);
241
+ process.stdin.setEncoding('utf-8');
242
+ process.stdin.resume();
243
+ process.stdin.once('data', (data) => {
244
+ process.stdin.pause();
245
+ resolve(data.trim().toLowerCase() === 'y');
246
+ });
247
+ });
248
+ }
249
+
250
+ // ── Default action: scan when no command given ──────────────────────────────
251
+
252
+ // If no command provided, default to `scan .`
253
+ // This makes `npx swynx-lite` work like `npx knip`
254
+ const args = process.argv.slice(2);
255
+ const commands = ['scan', 'clean', 'restore', 'purge', 'init', 'help'];
256
+ const hasCommand = args.some(a => commands.includes(a));
257
+ const hasHelpOrVersion = args.some(a => ['-h', '--help', '-v', '--version', '--pro'].includes(a));
258
+
259
+ if (args.length === 0 || (!hasCommand && !hasHelpOrVersion)) {
260
+ // Inject 'scan' as default command, pass remaining args as flags
261
+ process.argv.splice(2, 0, 'scan');
262
+ }
263
+
264
+ program.parse();
package/src/config.mjs ADDED
@@ -0,0 +1,121 @@
1
+ // src/config.mjs
2
+ // Config file loading: .swynx-lite.json + .swynxignore
3
+
4
+ import { existsSync, readFileSync } from 'fs';
5
+ import { join } from 'path';
6
+
7
+ const DEFAULTS = {
8
+ ignore: [],
9
+ ci: {
10
+ threshold: 0,
11
+ failOnSecurity: true,
12
+ securitySeverity: 'HIGH',
13
+ },
14
+ security: {
15
+ enabled: true,
16
+ },
17
+ clean: {
18
+ quarantine: true,
19
+ importClean: true,
20
+ barrelClean: true,
21
+ },
22
+ };
23
+
24
+ /**
25
+ * Load .swynx-lite.json from project root
26
+ */
27
+ function loadConfigFile(projectPath) {
28
+ const configPath = join(projectPath, '.swynx-lite.json');
29
+ if (!existsSync(configPath)) return {};
30
+
31
+ try {
32
+ return JSON.parse(readFileSync(configPath, 'utf-8'));
33
+ } catch (e) {
34
+ console.error(` Warning: invalid .swynx-lite.json — ${e.message}`);
35
+ return {};
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Load .swynxignore from project root (gitignore-style lines)
41
+ */
42
+ function loadIgnoreFile(projectPath) {
43
+ const ignorePath = join(projectPath, '.swynxignore');
44
+ if (!existsSync(ignorePath)) return [];
45
+
46
+ try {
47
+ const content = readFileSync(ignorePath, 'utf-8');
48
+ return content
49
+ .split('\n')
50
+ .map(line => line.trim())
51
+ .filter(line => line && !line.startsWith('#'));
52
+ } catch {
53
+ return [];
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Load config, merging: defaults < .swynx-lite.json < .swynxignore < CLI options
59
+ */
60
+ export function loadConfig(projectPath, cliOptions = {}) {
61
+ const fileConfig = loadConfigFile(projectPath);
62
+ const ignorePatterns = loadIgnoreFile(projectPath);
63
+
64
+ // Merge ignore patterns: file config + .swynxignore + CLI --ignore
65
+ const ignore = [
66
+ ...(DEFAULTS.ignore),
67
+ ...(fileConfig.ignore || []),
68
+ ...ignorePatterns,
69
+ ...(cliOptions.ignore || []),
70
+ ];
71
+
72
+ const config = {
73
+ ignore,
74
+ ci: {
75
+ ...DEFAULTS.ci,
76
+ ...(fileConfig.ci || {}),
77
+ },
78
+ security: {
79
+ ...DEFAULTS.security,
80
+ ...(fileConfig.security || {}),
81
+ },
82
+ clean: {
83
+ ...DEFAULTS.clean,
84
+ ...(fileConfig.clean || {}),
85
+ },
86
+ };
87
+
88
+ // CLI overrides
89
+ if (cliOptions.threshold !== undefined) config.ci.threshold = cliOptions.threshold;
90
+ if (cliOptions.security === false) config.security.enabled = false;
91
+
92
+ return config;
93
+ }
94
+
95
+ /**
96
+ * Generate a default .swynx-lite.json template
97
+ */
98
+ export function generateConfigTemplate() {
99
+ return JSON.stringify({
100
+ ignore: [
101
+ '**/__tests__/**',
102
+ '**/*.test.*',
103
+ '**/*.spec.*',
104
+ 'scripts/**',
105
+ 'docs/**',
106
+ ],
107
+ ci: {
108
+ threshold: 5,
109
+ failOnSecurity: true,
110
+ securitySeverity: 'HIGH',
111
+ },
112
+ security: {
113
+ enabled: true,
114
+ },
115
+ clean: {
116
+ quarantine: true,
117
+ importClean: true,
118
+ barrelClean: true,
119
+ },
120
+ }, null, 2);
121
+ }
@@ -0,0 +1,298 @@
1
+ // src/output/console.mjs
2
+ // Terminal output formatter — raw ANSI codes (no chalk)
3
+
4
+ const ESC = '\x1b[';
5
+ const RESET = `${ESC}0m`;
6
+ const BOLD = `${ESC}1m`;
7
+ const DIM = `${ESC}2m`;
8
+ const RED = `${ESC}31m`;
9
+ const GREEN = `${ESC}32m`;
10
+ const YELLOW = `${ESC}33m`;
11
+ const CYAN = `${ESC}36m`;
12
+ const WHITE = `${ESC}37m`;
13
+ const BRIGHT_RED = `${ESC}91m`;
14
+ const BRIGHT_GREEN = `${ESC}92m`;
15
+ const BRIGHT_YELLOW = `${ESC}93m`;
16
+ const BRIGHT_CYAN = `${ESC}96m`;
17
+
18
+ function c(style, text, noColor) {
19
+ if (noColor) return text;
20
+ return `${style}${text}${RESET}`;
21
+ }
22
+
23
+ function formatBytes(bytes) {
24
+ if (bytes < 1024) return `${bytes} B`;
25
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
26
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
27
+ }
28
+
29
+ function formatNumber(n) {
30
+ return n.toLocaleString('en-US');
31
+ }
32
+
33
+ function pad(str, len) {
34
+ return str + ' '.repeat(Math.max(0, len - str.length));
35
+ }
36
+
37
+ function rpad(str, len) {
38
+ return ' '.repeat(Math.max(0, len - str.length)) + str;
39
+ }
40
+
41
+ /**
42
+ * Render scan results to the console
43
+ */
44
+ export function renderScanOutput(results, options = {}) {
45
+ const { noColor = false, verbose = false, ci = false, security = null } = options;
46
+ const lines = [];
47
+
48
+ const nc = noColor;
49
+ const hr = '─'.repeat(48);
50
+
51
+ // ── Header ──
52
+ lines.push('');
53
+ lines.push(` ${c(BOLD + BRIGHT_CYAN, 'swynx lite', nc)} ${c(DIM, `v1.0.0`, nc)}`);
54
+ lines.push('');
55
+
56
+ // ── Summary ──
57
+ lines.push(` ${c(DIM, '──', nc)} ${c(BOLD, 'Summary', nc)} ${c(DIM, hr, nc)}`);
58
+ lines.push('');
59
+
60
+ const summary = results.summary;
61
+
62
+ const summaryRows = [
63
+ ['Files scanned', formatNumber(summary.totalFiles)],
64
+ ['Entry points', formatNumber(summary.entryPoints)],
65
+ ['Reachable', formatNumber(summary.reachableFiles)],
66
+ ];
67
+
68
+ // Dead files — highlight red if > 0
69
+ const deadLabel = 'Dead files';
70
+ const deadCount = summary.deadFiles;
71
+ const deadRate = summary.deadRate;
72
+ const deadValue = deadCount > 0
73
+ ? `${c(BRIGHT_RED, formatNumber(deadCount), nc)} ${c(DIM, `(${deadRate})`, nc)}`
74
+ : `${c(BRIGHT_GREEN, '0', nc)}`;
75
+ summaryRows.push([deadLabel, deadValue]);
76
+
77
+ // Dead size
78
+ if (summary.totalDeadBytes > 0) {
79
+ summaryRows.push(['Dead code size', formatBytes(summary.totalDeadBytes)]);
80
+ }
81
+
82
+ for (const [label, value] of summaryRows) {
83
+ lines.push(` ${c(DIM, pad(label, 18), nc)}${value}`);
84
+ }
85
+
86
+ lines.push('');
87
+
88
+ // ── Dead Files ──
89
+ if (deadCount > 0) {
90
+ lines.push(` ${c(DIM, '──', nc)} ${c(BOLD, 'Dead Files', nc)} ${c(DIM, hr, nc)}`);
91
+ lines.push('');
92
+
93
+ const deadFiles = results.deadFiles || [];
94
+ const showCount = verbose ? deadFiles.length : Math.min(5, deadFiles.length);
95
+
96
+ for (let i = 0; i < showCount; i++) {
97
+ const f = deadFiles[i];
98
+ const path = f.file || f.path || '';
99
+ const size = formatBytes(f.size);
100
+ const lineCount = f.lines ? `${formatNumber(f.lines)} lines` : '';
101
+ lines.push(` ${c(WHITE, pad(path, 42), nc)} ${c(DIM, rpad(size, 10), nc)} ${c(DIM, lineCount, nc)}`);
102
+ }
103
+
104
+ if (!verbose && deadFiles.length > 5) {
105
+ lines.push(` ${c(DIM, `... and ${deadFiles.length - 5} more`, nc)}`);
106
+ }
107
+
108
+ lines.push('');
109
+ }
110
+
111
+ // ── Security ──
112
+ if (security && security.summary && security.summary.total > 0) {
113
+ lines.push(` ${c(DIM, '──', nc)} ${c(BOLD, 'Security', nc)} ${c(DIM, hr, nc)}`);
114
+ lines.push('');
115
+
116
+ const secSummary = security.summary;
117
+ const inDeadLabel = secSummary.inDeadCode > 0
118
+ ? `${secSummary.inDeadCode} finding${secSummary.inDeadCode === 1 ? '' : 's'} in dead code`
119
+ : '';
120
+ const inLiveLabel = secSummary.inLiveCode > 0
121
+ ? `${secSummary.inLiveCode} finding${secSummary.inLiveCode === 1 ? '' : 's'} in live code`
122
+ : '';
123
+ if (inDeadLabel) lines.push(` ${c(YELLOW, inDeadLabel, nc)}`);
124
+ if (inLiveLabel) lines.push(` ${c(RED, inLiveLabel, nc)}`);
125
+ lines.push('');
126
+
127
+ // Show top findings
128
+ const findings = security.findings || [];
129
+ const showSecCount = verbose ? findings.length : Math.min(5, findings.length);
130
+
131
+ for (let i = 0; i < showSecCount; i++) {
132
+ const f = findings[i];
133
+ const sevColor = f.severity === 'CRITICAL' ? BRIGHT_RED
134
+ : f.severity === 'HIGH' ? RED
135
+ : f.severity === 'MEDIUM' ? YELLOW
136
+ : DIM;
137
+
138
+ lines.push(` ${c(BOLD + sevColor, pad(f.severity, 10), nc)}${c(WHITE, f.file, nc)}${c(DIM, `:${f.line}`, nc)}`);
139
+ lines.push(` ${c(DIM, `${f.cwe} ${f.cweName}`, nc)} ${c(DIM, '—', nc)} ${f.description}`);
140
+ if (f.risk) {
141
+ lines.push(` ${c(DIM, f.risk, nc)}`);
142
+ }
143
+ lines.push('');
144
+ }
145
+
146
+ if (!verbose && findings.length > 5) {
147
+ lines.push(` ${c(DIM, `... and ${findings.length - 5} more findings`, nc)}`);
148
+ lines.push('');
149
+ }
150
+ }
151
+
152
+ // ── What Next ── (interactive only)
153
+ if (!ci) {
154
+ if (deadCount > 0) {
155
+ lines.push(` ${c(DIM, '──', nc)} ${c(BOLD, 'What Next', nc)} ${c(DIM, hr, nc)}`);
156
+ lines.push('');
157
+ 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)}`);
158
+ lines.push(` Run ${c(BRIGHT_CYAN, 'swynx-lite scan --json', nc)} for machine-readable output`);
159
+ lines.push('');
160
+ }
161
+
162
+ // Pro footer — dim, unobtrusive
163
+ lines.push(` ${c(DIM, 'Dashboard, predictions, and more → swynx.io/pro', nc)}`);
164
+ lines.push('');
165
+ }
166
+
167
+ return lines.join('\n');
168
+ }
169
+
170
+ /**
171
+ * Render clean results to the console
172
+ */
173
+ export function renderCleanOutput(results, options = {}) {
174
+ const { noColor = false, dryRun = false } = options;
175
+ const lines = [];
176
+ const nc = noColor;
177
+
178
+ lines.push('');
179
+ lines.push(` ${c(BOLD + BRIGHT_CYAN, 'swynx lite', nc)} ${c(DIM, 'v1.0.0', nc)}${dryRun ? c(DIM, ' — dry run (no files will be modified)', nc) : ''}`);
180
+ lines.push('');
181
+
182
+ if (dryRun) {
183
+ lines.push(` Would remove ${c(BOLD, formatNumber(results.filesRemoved), nc)} file${results.filesRemoved === 1 ? '' : 's'} ${c(DIM, `(${formatBytes(results.bytesRemoved)})`, nc)}:`);
184
+ } else {
185
+ lines.push(` ${formatNumber(results.deadCount || 0)} dead files found ${c(DIM, `(${formatBytes(results.bytesRemoved)})`, nc)}`);
186
+ }
187
+
188
+ lines.push('');
189
+
190
+ // File list
191
+ const files = results.files || [];
192
+ const showCount = Math.min(5, files.length);
193
+
194
+ for (let i = 0; i < showCount; i++) {
195
+ const f = files[i];
196
+ const path = typeof f === 'string' ? f : (f.file || f.path || '');
197
+ const size = typeof f === 'object' && f.size ? formatBytes(f.size) : '';
198
+ lines.push(` ${c(WHITE, pad(path, 42), nc)} ${c(DIM, rpad(size, 10), nc)}`);
199
+ }
200
+
201
+ if (files.length > 5) {
202
+ lines.push(` ${c(DIM, `... and ${files.length - 5} more`, nc)}`);
203
+ }
204
+
205
+ lines.push('');
206
+
207
+ if (results.importsRemoved > 0 || results.barrelExportsRemoved > 0) {
208
+ if (dryRun) {
209
+ lines.push(` Would clean:`);
210
+ } else {
211
+ lines.push(` Also cleaned:`);
212
+ }
213
+ if (results.importsRemoved > 0) {
214
+ lines.push(` ${formatNumber(results.importsRemoved)} dead import${results.importsRemoved === 1 ? '' : 's'} from live files`);
215
+ }
216
+ if (results.barrelExportsRemoved > 0) {
217
+ lines.push(` ${formatNumber(results.barrelExportsRemoved)} dead re-export${results.barrelExportsRemoved === 1 ? '' : 's'} from barrel files`);
218
+ }
219
+ lines.push('');
220
+ }
221
+
222
+ if (!dryRun && results.sessionId) {
223
+ lines.push(` ${c(BRIGHT_GREEN, '\u2713', nc)} Quarantined ${formatNumber(results.filesRemoved)} file${results.filesRemoved === 1 ? '' : 's'} to .swynx-quarantine/`);
224
+ if (results.importsRemoved > 0 || results.barrelExportsRemoved > 0) {
225
+ lines.push(` ${c(BRIGHT_GREEN, '\u2713', nc)} Cleaned ${formatNumber(results.importsRemoved)} import${results.importsRemoved === 1 ? '' : 's'}, ${formatNumber(results.barrelExportsRemoved)} barrel export${results.barrelExportsRemoved === 1 ? '' : 's'}`);
226
+ }
227
+ lines.push(` ${c(BRIGHT_GREEN, '\u2713', nc)} Removed ${formatBytes(results.bytesRemoved)} of dead code`);
228
+ lines.push('');
229
+ lines.push(` Undo with ${c(BRIGHT_CYAN, 'swynx-lite restore', nc)}`);
230
+ lines.push(` Finalise with ${c(BRIGHT_CYAN, 'swynx-lite purge', nc)}`);
231
+ lines.push('');
232
+ }
233
+
234
+ return lines.join('\n');
235
+ }
236
+
237
+ /**
238
+ * Render restore result
239
+ */
240
+ export function renderRestoreOutput(result, options = {}) {
241
+ const nc = options.noColor || false;
242
+ const lines = [];
243
+
244
+ lines.push('');
245
+ if (result.restored && result.restored.length > 0) {
246
+ lines.push(` ${c(BRIGHT_GREEN, '\u2713', nc)} Restored ${result.restored.length} file${result.restored.length === 1 ? '' : 's'} from quarantine session ${c(DIM, result.sessionId, nc)}`);
247
+ lines.push('');
248
+ lines.push(` Restored files are back in their original locations.`);
249
+ lines.push(` Quarantine session kept — run ${c(BRIGHT_CYAN, 'swynx-lite purge', nc)} to clean up.`);
250
+ } else {
251
+ lines.push(` No files to restore.`);
252
+ }
253
+ lines.push('');
254
+
255
+ return lines.join('\n');
256
+ }
257
+
258
+ /**
259
+ * Render session list
260
+ */
261
+ export function renderSessionList(sessions, options = {}) {
262
+ const nc = options.noColor || false;
263
+ const lines = [];
264
+
265
+ lines.push('');
266
+ if (sessions.length === 0) {
267
+ lines.push(` No quarantine sessions found.`);
268
+ } else {
269
+ lines.push(` ${sessions.length} quarantine session${sessions.length === 1 ? '' : 's'}:`);
270
+ lines.push('');
271
+ for (const s of sessions) {
272
+ const date = new Date(s.createdAt).toLocaleString();
273
+ const status = s.status === 'restored' ? c(YELLOW, '[restored]', nc) : c(GREEN, '[active]', nc);
274
+ lines.push(` ${c(DIM, s.sessionId, nc)} ${pad(date, 22)} ${formatNumber(s.fileCount)} file${s.fileCount === 1 ? '' : 's'} ${formatBytes(s.totalSize)} ${status}`);
275
+ }
276
+ }
277
+ lines.push('');
278
+
279
+ return lines.join('\n');
280
+ }
281
+
282
+ /**
283
+ * Render purge result
284
+ */
285
+ export function renderPurgeOutput(results, options = {}) {
286
+ const nc = options.noColor || false;
287
+ const lines = [];
288
+
289
+ lines.push('');
290
+ if (results.purged > 0) {
291
+ lines.push(` ${c(BRIGHT_GREEN, '\u2713', nc)} Purged ${results.purged} session${results.purged === 1 ? '' : 's'} (${formatNumber(results.totalFiles)} file${results.totalFiles === 1 ? '' : 's'}, ${formatBytes(results.totalSize)})`);
292
+ } else {
293
+ lines.push(` No quarantine sessions to purge.`);
294
+ }
295
+ lines.push('');
296
+
297
+ return lines.join('\n');
298
+ }