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.
- package/README.md +113 -0
- package/bin/swynx-lite +3 -0
- package/package.json +47 -0
- package/src/clean.mjs +280 -0
- package/src/cli.mjs +264 -0
- package/src/config.mjs +121 -0
- package/src/output/console.mjs +298 -0
- package/src/output/json.mjs +76 -0
- package/src/output/progress.mjs +57 -0
- package/src/scan.mjs +143 -0
- package/src/security.mjs +62 -0
- package/src/shared/fixer/barrel-cleaner.mjs +192 -0
- package/src/shared/fixer/import-cleaner.mjs +237 -0
- package/src/shared/fixer/quarantine.mjs +218 -0
- package/src/shared/scanner/analysers/buildSystems.mjs +647 -0
- package/src/shared/scanner/analysers/configParsers.mjs +1086 -0
- package/src/shared/scanner/analysers/deadcode.mjs +6194 -0
- package/src/shared/scanner/analysers/entryPointDetector.mjs +634 -0
- package/src/shared/scanner/analysers/generatedCode.mjs +297 -0
- package/src/shared/scanner/analysers/imports.mjs +60 -0
- package/src/shared/scanner/discovery.mjs +240 -0
- package/src/shared/scanner/parse-worker.mjs +82 -0
- package/src/shared/scanner/parsers/assets.mjs +44 -0
- package/src/shared/scanner/parsers/csharp.mjs +400 -0
- package/src/shared/scanner/parsers/css.mjs +60 -0
- package/src/shared/scanner/parsers/go.mjs +445 -0
- package/src/shared/scanner/parsers/java.mjs +364 -0
- package/src/shared/scanner/parsers/javascript.mjs +823 -0
- package/src/shared/scanner/parsers/kotlin.mjs +350 -0
- package/src/shared/scanner/parsers/python.mjs +497 -0
- package/src/shared/scanner/parsers/registry.mjs +233 -0
- package/src/shared/scanner/parsers/rust.mjs +427 -0
- package/src/shared/scanner/scan-dead-code.mjs +316 -0
- package/src/shared/security/patterns.mjs +349 -0
- package/src/shared/security/proximity.mjs +84 -0
- 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
|
+
}
|
package/src/security.mjs
ADDED
|
@@ -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
|
+
};
|