entkapp 5.3.0 → 5.4.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 +1 -7
- package/bin/cli.js +58 -117
- package/package.json +1 -1
- package/src/EngineContext.js +6 -0
- package/src/ast/ASTAnalyzer.js +94 -6
- package/src/ast/MagicDetector.js +23 -43
- package/src/ast/OxcAnalyzer.js +190 -27
- package/src/ast/SecretScanner.js +112 -50
- package/src/index.js +87 -4
- package/src/performance/GraphCache.js +27 -0
- package/src/performance/WorkerPool.js +22 -4
- package/src/performance/WorkerTaskRunner.js +26 -0
- package/src/plugins/BasePlugin.js +16 -27
- package/src/plugins/PluginRegistry.js +44 -0
- package/src/plugins/ecosystems/UltimateBundle.js +259 -0
- package/src/resolution/ConfigLoader.js +190 -26
- package/src/resolution/FrameworkConfigParser.js +119 -0
- package/src/resolution/GraphAnalyzer.js +65 -1
- package/src/resolution/PathMapper.js +81 -0
- package/src/resolution/TSConfigLoader.js +3 -1
- package/src/resolution/WorkSpaceGraph.js +260 -89
|
@@ -3,73 +3,239 @@ import path from 'path';
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* ConfigLoader
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* - package.json "entkapp" key
|
|
10
|
-
* - CLI flags (passed as overrides)
|
|
6
|
+
*
|
|
7
|
+
* Loads and merges entkapp configuration from multiple sources.
|
|
8
|
+
* Supports Monorepo detection for pnpm, npm/yarn/bun, Lerna, Turbo, and Nx.
|
|
11
9
|
*/
|
|
12
10
|
export class ConfigLoader {
|
|
13
|
-
constructor(cwd) {
|
|
14
|
-
this.cwd = cwd;
|
|
11
|
+
constructor(cwd) {
|
|
12
|
+
this.cwd = cwd;
|
|
15
13
|
}
|
|
16
14
|
|
|
17
15
|
async loadConfig(overrides = {}) {
|
|
18
16
|
let config = this._defaultConfig();
|
|
19
17
|
|
|
20
|
-
// 1.
|
|
18
|
+
// 1. entkapp/config.json
|
|
21
19
|
const jsonConfigPath = path.join(this.cwd, 'entkapp', 'config.json');
|
|
22
20
|
try {
|
|
23
21
|
const raw = await fs.readFile(jsonConfigPath, 'utf8');
|
|
24
|
-
// Strip comments from JSON (JSONC support)
|
|
25
22
|
const stripped = raw.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
26
|
-
|
|
27
|
-
config = this._merge(config, parsed);
|
|
23
|
+
config = this._merge(config, JSON.parse(stripped));
|
|
28
24
|
} catch (e) {}
|
|
29
25
|
|
|
30
|
-
// 2.
|
|
31
|
-
for (const configFile of ['entkapp.config.mjs', 'entkapp.config.js', 'entkapp.config.cjs']) {
|
|
26
|
+
// 2. entkapp.config.ts / .mjs / .js / .cjs
|
|
27
|
+
for (const configFile of ['entkapp.config.ts', 'entkapp.config.mjs', 'entkapp.config.js', 'entkapp.config.cjs']) {
|
|
32
28
|
const jsConfigPath = path.join(this.cwd, configFile);
|
|
33
29
|
try {
|
|
34
30
|
const mod = await import(jsConfigPath);
|
|
35
31
|
const jsConfig = mod.default || mod;
|
|
36
|
-
if (typeof jsConfig === 'object') {
|
|
32
|
+
if (typeof jsConfig === 'object' && jsConfig !== null) {
|
|
37
33
|
config = this._merge(config, jsConfig);
|
|
38
34
|
break;
|
|
39
35
|
}
|
|
40
36
|
} catch (e) {}
|
|
41
37
|
}
|
|
42
38
|
|
|
43
|
-
// 3.
|
|
44
|
-
const pkgPath = path.join(this.cwd, 'package.json');
|
|
39
|
+
// 3. package.json "entkapp" key
|
|
45
40
|
try {
|
|
46
|
-
const pkg = JSON.parse(await fs.readFile(
|
|
41
|
+
const pkg = JSON.parse(await fs.readFile(path.join(this.cwd, 'package.json'), 'utf8'));
|
|
47
42
|
if (pkg.entkapp && typeof pkg.entkapp === 'object') {
|
|
48
43
|
config = this._merge(config, pkg.entkapp);
|
|
49
44
|
}
|
|
50
45
|
} catch (e) {}
|
|
51
46
|
|
|
52
|
-
// 4.
|
|
47
|
+
// 4. Workspace / monorepo packages
|
|
48
|
+
const workspaceData = await this.loadWorkspaceConfigs();
|
|
49
|
+
if (workspaceData.packages.length > 0) {
|
|
50
|
+
config.workspace = true;
|
|
51
|
+
config.workspacePackages = workspaceData.packages;
|
|
52
|
+
config.workspaceTsConfigs = workspaceData.tsConfigs;
|
|
53
|
+
config.workspaceConfigFiles = workspaceData.configFiles;
|
|
54
|
+
config.monorepoConfigs = workspaceData.monorepoConfigs;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 5. CLI overrides
|
|
53
58
|
config = this._merge(config, overrides);
|
|
54
59
|
|
|
60
|
+
// Final sanity check: ensure arrays exist
|
|
61
|
+
if (!Array.isArray(config.entryPoints)) config.entryPoints = [];
|
|
62
|
+
if (!Array.isArray(config.workspacePackages)) config.workspacePackages = [];
|
|
63
|
+
if (!Array.isArray(config.workspaceTsConfigs)) config.workspaceTsConfigs = [];
|
|
64
|
+
if (!Array.isArray(config.workspaceConfigFiles)) config.workspaceConfigFiles = [];
|
|
65
|
+
|
|
55
66
|
return config;
|
|
56
67
|
}
|
|
57
68
|
|
|
69
|
+
async loadWorkspaceConfigs() {
|
|
70
|
+
const result = {
|
|
71
|
+
packages: [],
|
|
72
|
+
tsConfigs: [],
|
|
73
|
+
configFiles: [],
|
|
74
|
+
monorepoConfigs: {}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Load global configs first
|
|
78
|
+
const globalConfigs = ['turbo.json', 'nx.json', 'lerna.json'];
|
|
79
|
+
for (const file of globalConfigs) {
|
|
80
|
+
try {
|
|
81
|
+
const content = JSON.parse(await fs.readFile(path.join(this.cwd, file), 'utf8'));
|
|
82
|
+
result.monorepoConfigs[file] = content;
|
|
83
|
+
} catch (e) {}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const patterns = await this._resolveWorkspacePatterns();
|
|
87
|
+
if (patterns.length === 0) return result;
|
|
88
|
+
|
|
89
|
+
for (const pattern of patterns) {
|
|
90
|
+
const dirs = await this._expandGlob(pattern);
|
|
91
|
+
for (const dir of dirs) {
|
|
92
|
+
await this._readPackageDir(dir, result);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async _resolveWorkspacePatterns() {
|
|
100
|
+
const pnpmPatterns = await this._parseWorkspaceYaml('pnpm-workspace.yaml');
|
|
101
|
+
if (pnpmPatterns.length > 0) return pnpmPatterns;
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const pkg = JSON.parse(await fs.readFile(path.join(this.cwd, 'package.json'), 'utf8'));
|
|
105
|
+
if (pkg.workspaces) {
|
|
106
|
+
return Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces.packages || [];
|
|
107
|
+
}
|
|
108
|
+
} catch (e) {}
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
const lerna = JSON.parse(await fs.readFile(path.join(this.cwd, 'lerna.json'), 'utf8'));
|
|
112
|
+
if (lerna.packages) return lerna.packages;
|
|
113
|
+
} catch (e) {}
|
|
114
|
+
|
|
115
|
+
return this._parseWorkspaceYaml('workspace.yaml');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async _parseWorkspaceYaml(fileName) {
|
|
119
|
+
const filePath = path.join(this.cwd, fileName);
|
|
120
|
+
try {
|
|
121
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
122
|
+
const patterns = [];
|
|
123
|
+
const lines = raw.split('\n');
|
|
124
|
+
let inPackages = false;
|
|
125
|
+
for (const line of lines) {
|
|
126
|
+
const trimmed = line.trim();
|
|
127
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
128
|
+
if (/^packages\s*:/.test(trimmed)) {
|
|
129
|
+
inPackages = true;
|
|
130
|
+
const inlineMatch = trimmed.match(/^packages\s*:\s*\[([^\]]*)\]/);
|
|
131
|
+
if (inlineMatch) {
|
|
132
|
+
inPackages = false;
|
|
133
|
+
for (const item of inlineMatch[1].split(',')) {
|
|
134
|
+
const val = item.trim().replace(/^['"]|['"]$/g, '');
|
|
135
|
+
if (val) patterns.push(val);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (inPackages) {
|
|
141
|
+
if (line.match(/^[a-zA-Z0-9_-]/) && trimmed.endsWith(':')) {
|
|
142
|
+
inPackages = false;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (trimmed.startsWith('-')) {
|
|
146
|
+
const val = trimmed.substring(1).trim().replace(/^['"]|['"]$/g, '');
|
|
147
|
+
if (val) patterns.push(val);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return patterns;
|
|
152
|
+
} catch (e) {
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async _readPackageDir(dir, result) {
|
|
158
|
+
const normalizedDir = dir.replace(/\\/g, '/');
|
|
159
|
+
let packageName;
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const pkg = JSON.parse(await fs.readFile(path.join(dir, 'package.json'), 'utf8'));
|
|
163
|
+
packageName = pkg.name;
|
|
164
|
+
result.packages.push({
|
|
165
|
+
dir: normalizedDir,
|
|
166
|
+
name: packageName,
|
|
167
|
+
entkappConfig: (pkg.entkapp && typeof pkg.entkapp === 'object') ? pkg.entkapp : {}
|
|
168
|
+
});
|
|
169
|
+
} catch (e) {
|
|
170
|
+
// If no package.json, skip as requested in WorkSpaceGraph logic
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// tsconfig.json
|
|
175
|
+
try {
|
|
176
|
+
const tsconfigRaw = await fs.readFile(path.join(dir, 'tsconfig.json'), 'utf8');
|
|
177
|
+
const stripped = tsconfigRaw.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
178
|
+
result.tsConfigs.push({ dir: normalizedDir, name: packageName, tsconfig: JSON.parse(stripped) });
|
|
179
|
+
} catch (e) {}
|
|
180
|
+
|
|
181
|
+
// *.config.ts / .js
|
|
182
|
+
try {
|
|
183
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
184
|
+
for (const entry of entries) {
|
|
185
|
+
if (!entry.isFile() || !/\.config\.(ts|js|mjs|cjs)$/.test(entry.name)) continue;
|
|
186
|
+
const filePath = path.join(dir, entry.name).replace(/\\/g, '/');
|
|
187
|
+
try {
|
|
188
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
189
|
+
result.configFiles.push({ dir: normalizedDir, name: packageName, fileName: entry.name, filePath, content });
|
|
190
|
+
} catch (e) {}
|
|
191
|
+
}
|
|
192
|
+
} catch (e) {}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async _expandGlob(pattern) {
|
|
196
|
+
const results = [];
|
|
197
|
+
const cleanPattern = pattern.replace(/\/$/, '');
|
|
198
|
+
if (cleanPattern.includes('*')) {
|
|
199
|
+
const parts = cleanPattern.split('/');
|
|
200
|
+
const wildcardIndex = parts.findIndex(p => p.includes('*'));
|
|
201
|
+
const baseDir = path.join(this.cwd, ...parts.slice(0, wildcardIndex));
|
|
202
|
+
try {
|
|
203
|
+
const entries = await fs.readdir(baseDir, { withFileTypes: true });
|
|
204
|
+
for (const entry of entries) {
|
|
205
|
+
if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
|
206
|
+
const fullPath = path.join(baseDir, entry.name);
|
|
207
|
+
try {
|
|
208
|
+
await fs.access(path.join(fullPath, 'package.json'));
|
|
209
|
+
results.push(fullPath.replace(/\\/g, '/'));
|
|
210
|
+
} catch (e) {}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
} catch (e) {}
|
|
214
|
+
} else {
|
|
215
|
+
const fullPath = path.resolve(this.cwd, cleanPattern);
|
|
216
|
+
try {
|
|
217
|
+
const stat = await fs.stat(fullPath);
|
|
218
|
+
if (stat.isDirectory()) results.push(fullPath.replace(/\\/g, '/'));
|
|
219
|
+
} catch (e) {}
|
|
220
|
+
}
|
|
221
|
+
return results;
|
|
222
|
+
}
|
|
223
|
+
|
|
58
224
|
_defaultConfig() {
|
|
59
225
|
return {
|
|
60
226
|
interface: 'CLI',
|
|
61
227
|
useBuiltinPlugins: true,
|
|
62
228
|
useCustomPlugins: true,
|
|
63
|
-
options: {
|
|
64
|
-
verbose: false,
|
|
65
|
-
fastMode: true,
|
|
66
|
-
selfHealing: true
|
|
67
|
-
},
|
|
229
|
+
options: { verbose: false, fastMode: true, selfHealing: true },
|
|
68
230
|
enabledPlugins: [],
|
|
69
231
|
ignoreDependencies: ['entkapp', '@types/*'],
|
|
70
232
|
exclude: ['node_modules', '.git', 'dist', 'build', 'coverage'],
|
|
71
233
|
entryPoints: [],
|
|
72
|
-
workspace: false
|
|
234
|
+
workspace: false,
|
|
235
|
+
workspacePackages: [],
|
|
236
|
+
workspaceTsConfigs: [],
|
|
237
|
+
workspaceConfigFiles: [],
|
|
238
|
+
monorepoConfigs: {}
|
|
73
239
|
};
|
|
74
240
|
}
|
|
75
241
|
|
|
@@ -79,8 +245,6 @@ export class ConfigLoader {
|
|
|
79
245
|
for (const key of Object.keys(override)) {
|
|
80
246
|
if (key === 'options' && typeof override[key] === 'object') {
|
|
81
247
|
result.options = { ...(base.options || {}), ...override[key] };
|
|
82
|
-
} else if (Array.isArray(override[key])) {
|
|
83
|
-
result[key] = override[key];
|
|
84
248
|
} else {
|
|
85
249
|
result[key] = override[key];
|
|
86
250
|
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Lightweight Static Analysis Parser for Vite / Vitest / Framework Configurations.
|
|
6
|
+
* Extracts 'resolve.alias' and 'build.lib.entry' without executing the file.
|
|
7
|
+
* Version 5.4.0: Added poly-extension path resolution.
|
|
8
|
+
*/
|
|
9
|
+
export class FrameworkConfigParser {
|
|
10
|
+
constructor(context) {
|
|
11
|
+
this.context = context;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parses a Framework configuration file and extracts structural metadata.
|
|
16
|
+
* @param {string} content - Raw source code of the config file
|
|
17
|
+
* @param {string} filePath - Absolute path to the config file
|
|
18
|
+
* @returns {Object} { aliases: Map<string, string>, entries: Set<string> }
|
|
19
|
+
*/
|
|
20
|
+
parse(content, filePath) {
|
|
21
|
+
const results = {
|
|
22
|
+
aliases: new Map(),
|
|
23
|
+
entries: new Set()
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
if (!content) return results;
|
|
27
|
+
|
|
28
|
+
const configDir = path.dirname(filePath);
|
|
29
|
+
|
|
30
|
+
// 1. Extract Aliases from resolve: { alias: { ... } } or alias: [ ... ]
|
|
31
|
+
this._extractAliases(content, configDir, results.aliases);
|
|
32
|
+
|
|
33
|
+
// 2. Extract Entry Points from build: { lib: { entry: '...' } } or rollupOptions: { input: '...' }
|
|
34
|
+
this._extractEntries(content, configDir, results.entries);
|
|
35
|
+
|
|
36
|
+
return results;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
_extractAliases(content, configDir, aliasMap) {
|
|
40
|
+
const objAliasPatterns = [
|
|
41
|
+
/alias\s*:\s*\{([\s\S]*?)\}/g,
|
|
42
|
+
/resolve\s*:\s*\{[\s\S]*?alias\s*:\s*\{([\s\S]*?)\}/g
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
for (const pattern of objAliasPatterns) {
|
|
46
|
+
let match;
|
|
47
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
48
|
+
const inner = match[1];
|
|
49
|
+
const pairPattern = /(?:['"]?)(@?[a-zA-Z0-9_\-\/*]+)(?:['"]?)\s*:\s*(?:['"]([^'"]+)['"]|(path\.(?:resolve|join)\([\s\S]*?\)))/g;
|
|
50
|
+
let pair;
|
|
51
|
+
while ((pair = pairPattern.exec(inner)) !== null) {
|
|
52
|
+
const [_, key, stringVal, callVal] = pair;
|
|
53
|
+
aliasMap.set(key, this._resolveValue(stringVal || callVal, configDir));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const arrAliasPattern = /alias\s*:\s*\[([\s\S]*?)\]/;
|
|
59
|
+
const arrMatch = arrAliasPattern.exec(content);
|
|
60
|
+
if (arrMatch) {
|
|
61
|
+
const inner = arrMatch[1];
|
|
62
|
+
const findPattern = /\{\s*find\s*:\s*['"]([^'"]+)['"]\s*,\s*replacement\s*:\s*['"]([^'"]+)['"]\s*\}/g;
|
|
63
|
+
let findMatch;
|
|
64
|
+
while ((findMatch = findPattern.exec(inner)) !== null) {
|
|
65
|
+
const [_, key, value] = findMatch;
|
|
66
|
+
aliasMap.set(key, this._resolveValue(value, configDir));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
_extractEntries(content, configDir, entrySet) {
|
|
72
|
+
const libEntryPattern = /entry\s*:\s*['"]([^'"]+)['"]/;
|
|
73
|
+
const libMatch = libEntryPattern.exec(content);
|
|
74
|
+
if (libMatch) {
|
|
75
|
+
entrySet.add(this._resolveValue(libMatch[1], configDir));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const inputPattern = /input\s*:\s*(?:['"]([^'"]+)['"]|\[([\s\S]*?)\])/;
|
|
79
|
+
const inputMatch = inputPattern.exec(content);
|
|
80
|
+
if (inputMatch) {
|
|
81
|
+
if (inputMatch[1]) {
|
|
82
|
+
entrySet.add(this._resolveValue(inputMatch[1], configDir));
|
|
83
|
+
} else if (inputMatch[2]) {
|
|
84
|
+
const paths = inputMatch[2].match(/['"]([^'"]+)['"]/g);
|
|
85
|
+
if (paths) {
|
|
86
|
+
paths.forEach(p => entrySet.add(this._resolveValue(p.replace(/['"]/g, ''), configDir)));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Resolves a path value and applies poly-extension fallback if the file doesn't exist.
|
|
94
|
+
*/
|
|
95
|
+
_resolveValue(val, configDir) {
|
|
96
|
+
const pathCallPattern = /path\.(?:resolve|join)\s*\(\s*(?:__dirname\s*,\s*)?['"]([^'"]+)['"]\s*\)/;
|
|
97
|
+
const pathMatch = pathCallPattern.exec(val);
|
|
98
|
+
if (pathMatch) {
|
|
99
|
+
val = pathMatch[1];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (val.startsWith('.') || val.startsWith('/') || /^[a-zA-Z]:/.test(val)) {
|
|
103
|
+
let resolved = path.resolve(configDir, val).replace(/\\/g, '/');
|
|
104
|
+
|
|
105
|
+
// UPGRADE: Poly-extension fallback
|
|
106
|
+
if (!fs.existsSync(resolved)) {
|
|
107
|
+
const base = resolved.replace(/\.[a-zA-Z0-9]+$/, '');
|
|
108
|
+
const extensions = ['.ts', '.js', '.tsx', '.jsx', '.mjs', '.cjs'];
|
|
109
|
+
for (const ext of extensions) {
|
|
110
|
+
if (fs.existsSync(base + ext)) {
|
|
111
|
+
return (base + ext).replace(/\\/g, '/');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return resolved;
|
|
116
|
+
}
|
|
117
|
+
return val;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -10,6 +10,36 @@ export class GraphAnalyzer {
|
|
|
10
10
|
constructor(context) {
|
|
11
11
|
this.context = context;
|
|
12
12
|
this.cwd = context.cwd;
|
|
13
|
+
// UPGRADE 5.4.3: Default Boundary Rules
|
|
14
|
+
this.boundaryRules = context.config?.boundaries || [
|
|
15
|
+
{ from: 'packages/shared-*', to: 'apps/*', allow: false, message: 'Shared packages must not import from applications.' },
|
|
16
|
+
{ from: '*', to: '**/internal/**', allow: false, message: 'Private internal utilities must not be imported externally.' }
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* UPGRADE 5.4.3: Boundary Enforcement
|
|
22
|
+
* Checks if an import violates defined architectural boundaries.
|
|
23
|
+
*/
|
|
24
|
+
checkBoundaries(fromPath, toPath) {
|
|
25
|
+
const relFrom = path.relative(this.cwd, fromPath).replace(/\\/g, '/');
|
|
26
|
+
const relTo = path.relative(this.cwd, toPath).replace(/\\/g, '/');
|
|
27
|
+
|
|
28
|
+
for (const rule of this.boundaryRules) {
|
|
29
|
+
const fromMatch = this._globMatch(relFrom, rule.from);
|
|
30
|
+
const toMatch = this._globMatch(relTo, rule.to);
|
|
31
|
+
|
|
32
|
+
if (fromMatch && toMatch && !rule.allow) {
|
|
33
|
+
return { violated: true, message: rule.message };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { violated: false };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
_globMatch(str, pattern) {
|
|
40
|
+
if (pattern === '*') return true;
|
|
41
|
+
const regex = new RegExp('^' + pattern.replace(/\*\*/g, '.*').replace(/\*/g, '[^/]*') + '$');
|
|
42
|
+
return regex.test(str);
|
|
13
43
|
}
|
|
14
44
|
|
|
15
45
|
/**
|
|
@@ -63,7 +93,41 @@ export class GraphAnalyzer {
|
|
|
63
93
|
}
|
|
64
94
|
}
|
|
65
95
|
|
|
66
|
-
|
|
96
|
+
const unusedImports = [];
|
|
97
|
+
const boundaryViolations = [];
|
|
98
|
+
|
|
99
|
+
for (const [filePath, node] of graph.entries()) {
|
|
100
|
+
if (!reachable.has(filePath)) continue;
|
|
101
|
+
|
|
102
|
+
// UPGRADE 5.4.3: Check boundaries for every import
|
|
103
|
+
for (const impPath of node.explicitImports) {
|
|
104
|
+
if (graph.has(impPath)) {
|
|
105
|
+
const violation = this.checkBoundaries(filePath, impPath);
|
|
106
|
+
if (violation.violated) {
|
|
107
|
+
boundaryViolations.push({
|
|
108
|
+
file: path.relative(this.cwd, filePath).replace(/\\/g, '/'),
|
|
109
|
+
target: path.relative(this.cwd, impPath).replace(/\\/g, '/'),
|
|
110
|
+
message: violation.message
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (node.localImportBindings) {
|
|
117
|
+
for (const [localName, meta] of node.localImportBindings.entries()) {
|
|
118
|
+
// Check if this local binding is used anywhere in the file
|
|
119
|
+
if (!node.instantiatedIdentifiers.has(localName)) {
|
|
120
|
+
unusedImports.push({
|
|
121
|
+
file: path.relative(this.cwd, filePath).replace(/\\/g, '/'),
|
|
122
|
+
specifier: meta.specifier,
|
|
123
|
+
symbol: meta.originalName === '*' ? 'Namespace' : meta.originalName
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { deadFiles, zombieExports, unusedImports, boundaryViolations };
|
|
67
131
|
}
|
|
68
132
|
|
|
69
133
|
_walk(filePath, reachable) {
|
|
@@ -6,15 +6,22 @@ export class PathMapper {
|
|
|
6
6
|
constructor(context) {
|
|
7
7
|
this.context = context;
|
|
8
8
|
this.aliasMappers = []; // list of alias mapping functions
|
|
9
|
+
// UPGRADE: Raw alias patterns for fast "is this an alias?" checks without file-existence tests
|
|
10
|
+
this._aliasPatterns = []; // Array of { regex }
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
async loadMappings(tsconfigFilename = 'tsconfig.json') {
|
|
14
|
+
// UPGRADE: Reset on reload to avoid duplicate entries when called twice (workspace re-load)
|
|
15
|
+
this.aliasMappers = [];
|
|
16
|
+
this._aliasPatterns = [];
|
|
17
|
+
|
|
12
18
|
// Load root tsconfig
|
|
13
19
|
const loader = new TSConfigLoader(this.context.cwd);
|
|
14
20
|
const config = loader.load();
|
|
15
21
|
if (config) {
|
|
16
22
|
const mapper = loader.getAliasMapper(config);
|
|
17
23
|
this.aliasMappers.push(mapper);
|
|
24
|
+
this._collectAliasPatterns(config, loader);
|
|
18
25
|
if (this.context.verbose) console.log(`[PathMapper] Loaded root tsconfig aliases`);
|
|
19
26
|
}
|
|
20
27
|
|
|
@@ -26,12 +33,86 @@ export class PathMapper {
|
|
|
26
33
|
if (wsConfig) {
|
|
27
34
|
const wsMapper = wsLoader.getAliasMapper(wsConfig);
|
|
28
35
|
this.aliasMappers.push(wsMapper);
|
|
36
|
+
this._collectAliasPatterns(wsConfig, wsLoader);
|
|
29
37
|
if (this.context.verbose) console.log(`[PathMapper] Loaded workspace tsconfig aliases from ${root}`);
|
|
30
38
|
}
|
|
31
39
|
}
|
|
32
40
|
}
|
|
33
41
|
}
|
|
34
42
|
|
|
43
|
+
/**
|
|
44
|
+
* UPGRADE: Collect raw alias patterns from a parsed tsconfig so we can answer
|
|
45
|
+
* "is this specifier a tsconfig alias?" without needing the target file to exist on disk.
|
|
46
|
+
* Handles patterns like "@shared/*", "@idk/*", "~/*", etc.
|
|
47
|
+
* @param {Object} parsedConfig - Result of TSConfigLoader.load()
|
|
48
|
+
* @param {TSConfigLoader} loader
|
|
49
|
+
*/
|
|
50
|
+
_collectAliasPatterns(parsedConfig, loader) {
|
|
51
|
+
if (!parsedConfig || !parsedConfig.options) return;
|
|
52
|
+
const { paths, baseUrl } = parsedConfig.options;
|
|
53
|
+
|
|
54
|
+
if (paths) {
|
|
55
|
+
for (const pattern in paths) {
|
|
56
|
+
// Convert tsconfig glob pattern to a regex
|
|
57
|
+
// e.g. "@shared/*" -> /^@shared\// or "@shared" -> /^@shared$/
|
|
58
|
+
const escaped = pattern
|
|
59
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // escape regex special chars except *
|
|
60
|
+
.replace(/\\\*/g, '.*'); // replace escaped \* with .*
|
|
61
|
+
try {
|
|
62
|
+
this._aliasPatterns.push({ regex: new RegExp('^' + escaped + '$') });
|
|
63
|
+
// Also add a prefix variant so "@shared/foo/bar" matches "@shared/*"
|
|
64
|
+
const prefixEscaped = pattern.replace(/\*.*$/, '').replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
65
|
+
if (prefixEscaped && prefixEscaped !== escaped) {
|
|
66
|
+
this._aliasPatterns.push({ regex: new RegExp('^' + prefixEscaped) });
|
|
67
|
+
}
|
|
68
|
+
} catch (e) { /* ignore invalid regex */ }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* UPGRADE: Returns true if the given specifier matches any tsconfig path alias pattern.
|
|
75
|
+
* This check does NOT require the target file to exist on disk.
|
|
76
|
+
* Used by the unlisted-dependency audit to skip tsconfig-aliased imports.
|
|
77
|
+
* @param {string} specifier
|
|
78
|
+
* @returns {boolean}
|
|
79
|
+
*/
|
|
80
|
+
isTsconfigAlias(specifier) {
|
|
81
|
+
if (!specifier || typeof specifier !== 'string') return false;
|
|
82
|
+
for (const { regex } of this._aliasPatterns) {
|
|
83
|
+
if (regex.test(specifier)) return true;
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* UPGRADE: Manually register a dynamic alias (e.g. from vite.config.js).
|
|
90
|
+
* @param {string} key - Alias key (e.g. "@shared")
|
|
91
|
+
* @param {string} target - Absolute path or relative path to resolve
|
|
92
|
+
*/
|
|
93
|
+
addAlias(key, target) {
|
|
94
|
+
if (!key || !target) return;
|
|
95
|
+
|
|
96
|
+
// 1. Add to aliasMappers for resolvePath()
|
|
97
|
+
const mapper = (p) => {
|
|
98
|
+
if (p === key) return target;
|
|
99
|
+
if (p.startsWith(key + '/')) {
|
|
100
|
+
return path.join(target, p.substring(key.length + 1)).replace(/\\/g, '/');
|
|
101
|
+
}
|
|
102
|
+
return p;
|
|
103
|
+
};
|
|
104
|
+
this.aliasMappers.push(mapper);
|
|
105
|
+
|
|
106
|
+
// 2. Add to _aliasPatterns for isTsconfigAlias() checks
|
|
107
|
+
const escaped = key.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
108
|
+
try {
|
|
109
|
+
this._aliasPatterns.push({ regex: new RegExp('^' + escaped + '$') });
|
|
110
|
+
this._aliasPatterns.push({ regex: new RegExp('^' + escaped + '/') });
|
|
111
|
+
} catch (e) {}
|
|
112
|
+
|
|
113
|
+
if (this.context.verbose) console.log(`[PathMapper] Added dynamic alias: ${key} -> ${target}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
35
116
|
/**
|
|
36
117
|
* Resolves physical module paths on disk, translating modern .js imports
|
|
37
118
|
* back to their actual TypeScript source files.
|
|
@@ -109,7 +109,9 @@ export class TSConfigLoader {
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
const { paths, baseUrl } = parsedConfig.options;
|
|
112
|
-
|
|
112
|
+
// In ts.parseJsonConfigFileContent, baseUrl is already resolved to an absolute path if it was present
|
|
113
|
+
// If not, we fall back to targetDir
|
|
114
|
+
const base = baseUrl ? baseUrl : this.targetDir;
|
|
113
115
|
|
|
114
116
|
return (source) => {
|
|
115
117
|
// If no paths configured, still try baseUrl resolution
|