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
@@ -0,0 +1,76 @@
1
+ // src/output/json.mjs
2
+ // JSON output formatter
3
+
4
+ /**
5
+ * Format scan results as JSON schema matching the spec
6
+ */
7
+ export function formatJSON(results, options = {}) {
8
+ const { projectPath, security } = options;
9
+
10
+ const output = {
11
+ version: '1.0.0',
12
+ tool: 'swynx-lite',
13
+ timestamp: new Date().toISOString(),
14
+ project: {
15
+ path: projectPath,
16
+ languages: Object.keys(results.summary.languages || {}),
17
+ framework: results.framework || null,
18
+ },
19
+ summary: {
20
+ totalFiles: results.summary.totalFiles,
21
+ entryPoints: results.summary.entryPoints,
22
+ reachableFiles: results.summary.reachableFiles,
23
+ deadFiles: results.summary.deadFiles,
24
+ deadRate: parseFloat(results.summary.deadRate),
25
+ deadBytes: results.summary.totalDeadBytes,
26
+ },
27
+ deadFiles: (results.deadFiles || []).map(f => ({
28
+ path: f.file,
29
+ language: f.language,
30
+ size: f.size,
31
+ lines: f.lines,
32
+ exports: (f.exports || []).map(e => e.name || e),
33
+ })),
34
+ };
35
+
36
+ // Security section
37
+ if (security && security.summary && security.summary.total > 0) {
38
+ output.security = {
39
+ findings: (security.findings || []).map(f => ({
40
+ severity: f.severity,
41
+ cwe: f.cwe,
42
+ cweName: f.cweName,
43
+ file: f.file,
44
+ line: f.line,
45
+ description: f.description,
46
+ inDeadCode: f.isDead || false,
47
+ })),
48
+ };
49
+ }
50
+
51
+ return JSON.stringify(output, null, 2);
52
+ }
53
+
54
+ /**
55
+ * Format clean results as JSON
56
+ */
57
+ export function formatCleanJSON(results) {
58
+ return JSON.stringify({
59
+ version: '1.0.0',
60
+ tool: 'swynx-lite',
61
+ timestamp: new Date().toISOString(),
62
+ action: results.dryRun ? 'dry-run' : 'clean',
63
+ summary: {
64
+ filesRemoved: results.filesRemoved || 0,
65
+ bytesRemoved: results.bytesRemoved || 0,
66
+ importsRemoved: results.importsRemoved || 0,
67
+ barrelExportsRemoved: results.barrelExportsRemoved || 0,
68
+ },
69
+ quarantine: results.sessionId ? {
70
+ sessionId: results.sessionId,
71
+ restoreCommand: 'swynx-lite restore',
72
+ purgeCommand: 'swynx-lite purge',
73
+ } : null,
74
+ files: results.files || [],
75
+ }, null, 2);
76
+ }
@@ -0,0 +1,57 @@
1
+ // src/output/progress.mjs
2
+ // Braille spinner on stderr for interactive mode
3
+
4
+ const BRAILLE = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
5
+
6
+ export class ProgressSpinner {
7
+ constructor(options = {}) {
8
+ this.active = false;
9
+ this.frame = 0;
10
+ this.interval = null;
11
+ this.message = '';
12
+ this.noColor = options.noColor || false;
13
+ this.enabled = options.enabled !== false && process.stderr.isTTY;
14
+ }
15
+
16
+ start(message = '') {
17
+ if (!this.enabled) return;
18
+ this.active = true;
19
+ this.message = message;
20
+ this.interval = setInterval(() => this._render(), 80);
21
+ this._render();
22
+ }
23
+
24
+ update(progress) {
25
+ if (!this.enabled) return;
26
+ if (progress.message) {
27
+ this.message = progress.message;
28
+ }
29
+ if (progress.phase === 'done') {
30
+ this.stop();
31
+ }
32
+ }
33
+
34
+ stop() {
35
+ if (!this.active) return;
36
+ this.active = false;
37
+ if (this.interval) {
38
+ clearInterval(this.interval);
39
+ this.interval = null;
40
+ }
41
+ // Clear the spinner line
42
+ if (this.enabled) {
43
+ process.stderr.write('\r\x1b[K');
44
+ }
45
+ }
46
+
47
+ _render() {
48
+ if (!this.active) return;
49
+ const spinner = BRAILLE[this.frame % BRAILLE.length];
50
+ this.frame++;
51
+
52
+ const dim = this.noColor ? '' : '\x1b[2m';
53
+ const reset = this.noColor ? '' : '\x1b[0m';
54
+
55
+ process.stderr.write(`\r\x1b[K ${dim}${spinner} ${this.message}${reset}`);
56
+ }
57
+ }
package/src/scan.mjs ADDED
@@ -0,0 +1,143 @@
1
+ // src/scan.mjs
2
+ // Scan orchestrator — wraps the shared scanner for Lite
3
+
4
+ import { resolve } from 'path';
5
+ import { existsSync } from 'fs';
6
+ import { loadConfig } from './config.mjs';
7
+ import { scanDeadCode } from './shared/scanner/scan-dead-code.mjs';
8
+ import { ProgressSpinner } from './output/progress.mjs';
9
+ import { renderScanOutput } from './output/console.mjs';
10
+ import { formatJSON } from './output/json.mjs';
11
+
12
+ /**
13
+ * Run a dead code scan
14
+ *
15
+ * @param {string} targetPath - Path to scan
16
+ * @param {object} cliOptions - CLI options
17
+ * @returns {Promise<{ exitCode: number, results: object }>}
18
+ */
19
+ export async function runScan(targetPath, cliOptions = {}) {
20
+ const projectPath = resolve(targetPath || '.');
21
+
22
+ if (!existsSync(projectPath)) {
23
+ console.error(` Error: path not found — ${projectPath}`);
24
+ return { exitCode: 2, results: null };
25
+ }
26
+
27
+ // Load config
28
+ const config = loadConfig(projectPath, cliOptions);
29
+
30
+ // Detect CI
31
+ const isCI = cliOptions.ci || !!process.env.CI || !!process.env.GITHUB_ACTIONS || !!process.env.GITLAB_CI;
32
+ const isJSON = cliOptions.json || false;
33
+ const noColor = cliOptions.color === false || !!process.env.NO_COLOR || isCI;
34
+ const verbose = cliOptions.verbose || false;
35
+
36
+ // Progress spinner (only in interactive mode)
37
+ const spinner = new ProgressSpinner({
38
+ enabled: !isJSON && !isCI,
39
+ noColor,
40
+ });
41
+
42
+ spinner.start('Discovering files...');
43
+
44
+ // Build exclude patterns from config
45
+ const exclude = config.ignore.length > 0 ? undefined : undefined;
46
+
47
+ let results;
48
+ try {
49
+ results = await scanDeadCode(projectPath, {
50
+ exclude: undefined, // use scanner defaults + config ignore is separate
51
+ onProgress: (p) => spinner.update(p),
52
+ });
53
+ } catch (e) {
54
+ spinner.stop();
55
+ console.error(` Error during scan: ${e.message}`);
56
+ if (verbose) console.error(e.stack);
57
+ return { exitCode: 2, results: null };
58
+ }
59
+
60
+ spinner.stop();
61
+
62
+ // Filter out ignored files from results
63
+ if (config.ignore.length > 0) {
64
+ const { minimatch } = await loadMinimatch();
65
+ if (minimatch) {
66
+ results.deadFiles = results.deadFiles.filter(f => {
67
+ const filePath = f.file || f.path || '';
68
+ return !config.ignore.some(pattern => minimatch(filePath, pattern, { dot: true }));
69
+ });
70
+ // Recalculate summary
71
+ const deadCount = results.deadFiles.length;
72
+ const totalDeadBytes = results.deadFiles.reduce((sum, f) => sum + (f.size || 0), 0);
73
+ const deadRate = results.summary.totalFiles > 0
74
+ ? ((deadCount / results.summary.totalFiles) * 100).toFixed(2)
75
+ : '0.00';
76
+ results.summary.deadFiles = deadCount;
77
+ results.summary.totalDeadBytes = totalDeadBytes;
78
+ results.summary.deadRate = `${deadRate}%`;
79
+ }
80
+ }
81
+
82
+ // Security scanning
83
+ let security = null;
84
+ if (config.security.enabled && cliOptions.security !== false) {
85
+ try {
86
+ const { runSecurityScan } = await import('./security.mjs');
87
+ security = await runSecurityScan(projectPath, results, { onProgress: (p) => spinner.update(p) });
88
+ } catch {
89
+ // Security module may not be available — skip silently
90
+ }
91
+ }
92
+
93
+ // Output
94
+ if (isJSON) {
95
+ console.log(formatJSON(results, { projectPath, security }));
96
+ } else {
97
+ console.log(renderScanOutput(results, { noColor, verbose, ci: isCI, security }));
98
+ }
99
+
100
+ // Exit code logic
101
+ let exitCode = 0;
102
+ if (isCI) {
103
+ const threshold = parseFloat(cliOptions.threshold ?? config.ci.threshold ?? 0);
104
+ const deadRate = parseFloat(results.summary.deadRate);
105
+
106
+ if (deadRate > threshold) {
107
+ exitCode = 1;
108
+ }
109
+
110
+ // Security failures in CI
111
+ if (config.ci.failOnSecurity && security && security.summary) {
112
+ const minSev = config.ci.securitySeverity || 'HIGH';
113
+ const sevRank = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1 };
114
+ const minRank = sevRank[minSev] || 3;
115
+
116
+ const hasFailure = (security.findings || []).some(f =>
117
+ (sevRank[f.severity] || 0) >= minRank
118
+ );
119
+ if (hasFailure) exitCode = 1;
120
+ }
121
+ }
122
+
123
+ return { exitCode, results, security };
124
+ }
125
+
126
+ /**
127
+ * Try to load minimatch for ignore pattern matching
128
+ */
129
+ async function loadMinimatch() {
130
+ try {
131
+ const { minimatch } = await import('minimatch');
132
+ return { minimatch };
133
+ } catch {
134
+ // minimatch not available — glob patterns in ignore won't work
135
+ // (it's a transitive dep from glob, so usually available)
136
+ try {
137
+ // Try the glob module's internal minimatch
138
+ const mod = await import('glob');
139
+ if (mod.minimatch) return { minimatch: mod.minimatch };
140
+ } catch { /* */ }
141
+ return { minimatch: null };
142
+ }
143
+ }
@@ -0,0 +1,62 @@
1
+ // src/security.mjs
2
+ // Security scan orchestrator — wraps the shared security scanner
3
+
4
+ import { scanCodePatterns } from './shared/security/scanner.mjs';
5
+ import { readFileSync, existsSync } from 'fs';
6
+ import { join, extname } from 'path';
7
+
8
+ /**
9
+ * Run security scanning on scan results
10
+ *
11
+ * @param {string} projectPath - Project root
12
+ * @param {object} scanResults - Results from scanDeadCode()
13
+ * @param {object} options
14
+ * @returns {object} Security scan results
15
+ */
16
+ export async function runSecurityScan(projectPath, scanResults, options = {}) {
17
+ const { onProgress } = options;
18
+
19
+ // Build dead file set
20
+ const deadFileSet = new Set(
21
+ (scanResults.deadFiles || []).map(f => f.file || f.path || '')
22
+ );
23
+
24
+ // Build analysis objects with content for security scanning
25
+ // We need to re-read file content since the scanner strips it
26
+ const allFiles = [];
27
+ const deadFiles = scanResults.deadFiles || [];
28
+
29
+ // Scan dead files for security patterns
30
+ for (const f of deadFiles) {
31
+ const filePath = f.file || f.path || '';
32
+ if (!filePath) continue;
33
+
34
+ const fullPath = join(projectPath, filePath);
35
+ let content = '';
36
+ try {
37
+ if (existsSync(fullPath)) {
38
+ content = readFileSync(fullPath, 'utf-8');
39
+ }
40
+ } catch { continue; }
41
+
42
+ if (!content) continue;
43
+
44
+ allFiles.push({
45
+ file: filePath,
46
+ relativePath: filePath,
47
+ content,
48
+ });
49
+ }
50
+
51
+ if (onProgress) {
52
+ onProgress({ phase: 'security', message: `Scanning ${allFiles.length} files for security patterns...` });
53
+ }
54
+
55
+ const results = scanCodePatterns(allFiles, deadFileSet, projectPath, onProgress);
56
+
57
+ if (onProgress) {
58
+ onProgress({ phase: 'done', message: 'Security scan complete' });
59
+ }
60
+
61
+ return results;
62
+ }
@@ -0,0 +1,192 @@
1
+ // src/fixer/barrel-cleaner.mjs
2
+ // Clean up barrel/index file re-exports that reference deleted files
3
+
4
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
5
+ import { join, dirname, basename, extname } from 'path';
6
+
7
+ /**
8
+ * Find and clean barrel exports that reference deleted files
9
+ * @param {string} projectPath - Project root
10
+ * @param {string[]} deletedFiles - List of deleted file paths (relative)
11
+ * @param {string[]} liveFiles - List of live files to check (relative)
12
+ * @param {object} options - Options
13
+ */
14
+ export async function cleanBarrelExports(projectPath, deletedFiles, liveFiles, options = {}) {
15
+ const { dryRun = false } = options;
16
+
17
+ const result = {
18
+ filesModified: [],
19
+ exportsRemoved: [],
20
+ errors: []
21
+ };
22
+
23
+ // Find potential barrel files (index.js, index.ts, etc.)
24
+ const barrelFiles = liveFiles.filter(f => {
25
+ const base = basename(f);
26
+ return /^index\.(js|ts|mjs|cjs|jsx|tsx)$/.test(base);
27
+ });
28
+
29
+ // Build a set of deleted file basenames for matching
30
+ const deletedBasenames = new Set();
31
+ for (const file of deletedFiles) {
32
+ const ext = extname(file);
33
+ const base = basename(file, ext);
34
+ deletedBasenames.add(base);
35
+ deletedBasenames.add(base.toLowerCase());
36
+ }
37
+
38
+ // Process each barrel file
39
+ for (const barrelFile of barrelFiles) {
40
+ const fullPath = join(projectPath, barrelFile);
41
+ const barrelDir = dirname(barrelFile);
42
+
43
+ if (!existsSync(fullPath)) continue;
44
+
45
+ try {
46
+ const content = readFileSync(fullPath, 'utf-8');
47
+ const { modified, changes } = cleanExportsInFile(content, barrelDir, deletedFiles, deletedBasenames);
48
+
49
+ if (changes.length > 0) {
50
+ if (!dryRun) {
51
+ writeFileSync(fullPath, modified, 'utf-8');
52
+ }
53
+
54
+ result.filesModified.push(barrelFile);
55
+ result.exportsRemoved.push({
56
+ file: barrelFile,
57
+ exports: changes,
58
+ dryRun
59
+ });
60
+ }
61
+ } catch (error) {
62
+ result.errors.push({ file: barrelFile, error: error.message });
63
+ }
64
+ }
65
+
66
+ return result;
67
+ }
68
+
69
+ /**
70
+ * Clean exports in a single file's content
71
+ */
72
+ function cleanExportsInFile(content, barrelDir, deletedFiles, deletedBasenames) {
73
+ const lines = content.split('\n');
74
+ const changes = [];
75
+ const newLines = [];
76
+
77
+ for (let i = 0; i < lines.length; i++) {
78
+ const line = lines[i];
79
+ const exportMatch = matchExportStatement(line);
80
+
81
+ if (exportMatch) {
82
+ const { exportPath, fullMatch } = exportMatch;
83
+
84
+ // Check if this export references a deleted file
85
+ if (isDeletedExport(barrelDir, exportPath, deletedFiles, deletedBasenames)) {
86
+ changes.push({
87
+ line: i + 1,
88
+ removed: line.trim(),
89
+ exportPath
90
+ });
91
+ // Skip this line
92
+ continue;
93
+ }
94
+ }
95
+
96
+ newLines.push(line);
97
+ }
98
+
99
+ // Clean up consecutive empty lines
100
+ const cleaned = cleanEmptyLines(newLines);
101
+
102
+ return {
103
+ modified: cleaned.join('\n'),
104
+ changes
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Match export statements
110
+ */
111
+ function matchExportStatement(line) {
112
+ const trimmed = line.trim();
113
+
114
+ // export { X } from 'path'
115
+ // export * from 'path'
116
+ // export { default as X } from 'path'
117
+ const reExportMatch = trimmed.match(/^export\s+(?:\{[^}]*\}|\*(?:\s+as\s+\w+)?)\s+from\s+['"]([^'"]+)['"]/);
118
+ if (reExportMatch) {
119
+ return { exportPath: reExportMatch[1], fullMatch: reExportMatch[0] };
120
+ }
121
+
122
+ // Named export with default: export { default } from 'path'
123
+ const defaultExportMatch = trimmed.match(/^export\s+\{\s*default[^}]*\}\s+from\s+['"]([^'"]+)['"]/);
124
+ if (defaultExportMatch) {
125
+ return { exportPath: defaultExportMatch[1], fullMatch: defaultExportMatch[0] };
126
+ }
127
+
128
+ return null;
129
+ }
130
+
131
+ /**
132
+ * Check if an export path references a deleted file
133
+ */
134
+ function isDeletedExport(barrelDir, exportPath, deletedFiles, deletedBasenames) {
135
+ // Skip non-relative exports
136
+ if (!exportPath.startsWith('.')) {
137
+ return false;
138
+ }
139
+
140
+ // Resolve the export path relative to barrel file
141
+ const resolved = join(barrelDir, exportPath).replace(/\\/g, '/').replace(/^\.\//, '');
142
+
143
+ // Check against deleted files
144
+ for (const deleted of deletedFiles) {
145
+ const deletedNorm = deleted.replace(/\\/g, '/').replace(/^\.\//, '');
146
+
147
+ // Exact match
148
+ if (resolved === deletedNorm) {
149
+ return true;
150
+ }
151
+
152
+ // Match without extension
153
+ const deletedNoExt = deletedNorm.replace(/\.[^/.]+$/, '');
154
+ const resolvedNoExt = resolved.replace(/\.[^/.]+$/, '');
155
+
156
+ if (resolvedNoExt === deletedNoExt) {
157
+ return true;
158
+ }
159
+
160
+ // Match with /index suffix
161
+ if (resolved + '/index' === deletedNoExt || resolvedNoExt + '/index' === deletedNoExt) {
162
+ return true;
163
+ }
164
+ }
165
+
166
+ return false;
167
+ }
168
+
169
+ /**
170
+ * Clean up consecutive empty lines
171
+ */
172
+ function cleanEmptyLines(lines) {
173
+ const result = [];
174
+ let lastWasEmpty = false;
175
+
176
+ for (const line of lines) {
177
+ const isEmpty = line.trim() === '';
178
+
179
+ if (isEmpty && lastWasEmpty) {
180
+ continue;
181
+ }
182
+
183
+ result.push(line);
184
+ lastWasEmpty = isEmpty;
185
+ }
186
+
187
+ return result;
188
+ }
189
+
190
+ export default {
191
+ cleanBarrelExports
192
+ };