@kodus/kodus-graph 0.2.9 → 0.2.10
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/dist/analysis/blast-radius.d.ts +1 -1
- package/dist/analysis/blast-radius.js +19 -21
- package/dist/analysis/context-builder.js +13 -4
- package/dist/analysis/diff.d.ts +6 -0
- package/dist/analysis/diff.js +16 -1
- package/dist/analysis/enrich.d.ts +1 -1
- package/dist/analysis/enrich.js +37 -9
- package/dist/analysis/prompt-formatter.js +8 -1
- package/dist/cli.js +2 -0
- package/dist/commands/analyze.js +5 -3
- package/dist/commands/diff.js +2 -2
- package/dist/commands/parse.d.ts +1 -0
- package/dist/commands/parse.js +3 -3
- package/dist/commands/update.js +2 -2
- package/dist/graph/builder.d.ts +5 -1
- package/dist/graph/builder.js +35 -3
- package/dist/graph/edges.d.ts +5 -1
- package/dist/graph/edges.js +61 -7
- package/dist/graph/types.d.ts +3 -0
- package/dist/parser/batch.d.ts +1 -0
- package/dist/parser/batch.js +18 -3
- package/dist/parser/languages.js +1 -0
- package/dist/resolver/external-detector.d.ts +11 -0
- package/dist/resolver/external-detector.js +820 -0
- package/dist/resolver/fs-cache.d.ts +8 -0
- package/dist/resolver/fs-cache.js +36 -0
- package/dist/resolver/import-resolver.js +130 -32
- package/dist/resolver/languages/csharp.d.ts +2 -0
- package/dist/resolver/languages/csharp.js +69 -6
- package/dist/resolver/languages/go.js +8 -7
- package/dist/resolver/languages/java.js +102 -17
- package/dist/resolver/languages/php.js +26 -5
- package/dist/resolver/languages/python.js +79 -3
- package/dist/resolver/languages/ruby.d.ts +16 -1
- package/dist/resolver/languages/ruby.js +58 -7
- package/dist/resolver/languages/rust.js +8 -7
- package/dist/resolver/languages/typescript.d.ts +8 -0
- package/dist/resolver/languages/typescript.js +193 -17
- package/package.json +1 -1
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { join, resolve as resolvePath } from 'path';
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { dirname, join, resolve as resolvePath } from 'path';
|
|
3
|
+
import { cachedExists } from '../fs-cache';
|
|
3
4
|
const psr4Cache = new Map();
|
|
4
5
|
/** Clear cached composer.json PSR-4 data. Call between analysis runs or when switching repos. */
|
|
5
6
|
export function clearCache() {
|
|
6
7
|
psr4Cache.clear();
|
|
7
8
|
}
|
|
9
|
+
/** Check if the modulePath looks like a file path (require/include style) rather than a namespace. */
|
|
10
|
+
function looksLikeFilePath(modulePath) {
|
|
11
|
+
return modulePath.includes('/') || modulePath.endsWith('.php');
|
|
12
|
+
}
|
|
8
13
|
function loadPsr4(repoRoot) {
|
|
9
14
|
const cached = psr4Cache.get(repoRoot);
|
|
10
15
|
if (cached) {
|
|
@@ -12,7 +17,7 @@ function loadPsr4(repoRoot) {
|
|
|
12
17
|
}
|
|
13
18
|
const map = new Map();
|
|
14
19
|
const composerPath = join(repoRoot, 'composer.json');
|
|
15
|
-
if (
|
|
20
|
+
if (cachedExists(composerPath)) {
|
|
16
21
|
try {
|
|
17
22
|
const content = readFileSync(composerPath, 'utf-8');
|
|
18
23
|
const config = JSON.parse(content);
|
|
@@ -32,13 +37,29 @@ function loadPsr4(repoRoot) {
|
|
|
32
37
|
return map;
|
|
33
38
|
}
|
|
34
39
|
export function resolve(_fromAbsFile, modulePath, repoRoot) {
|
|
40
|
+
// Handle require/include style file paths before PSR-4 resolution
|
|
41
|
+
if (looksLikeFilePath(modulePath)) {
|
|
42
|
+
// Try resolving relative to the importing file's directory
|
|
43
|
+
if (_fromAbsFile) {
|
|
44
|
+
const fromDir = dirname(_fromAbsFile);
|
|
45
|
+
const candidate = resolvePath(join(fromDir, modulePath));
|
|
46
|
+
if (cachedExists(candidate)) {
|
|
47
|
+
return candidate;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// Try resolving relative to repo root
|
|
51
|
+
const rootCandidate = resolvePath(join(repoRoot, modulePath));
|
|
52
|
+
if (cachedExists(rootCandidate)) {
|
|
53
|
+
return rootCandidate;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
35
56
|
const psr4 = loadPsr4(repoRoot);
|
|
36
57
|
for (const [prefix, dir] of psr4) {
|
|
37
58
|
if (modulePath.startsWith(prefix)) {
|
|
38
59
|
const rest = modulePath.slice(prefix.length);
|
|
39
60
|
const relPath = `${rest.replace(/\\/g, '/')}.php`;
|
|
40
61
|
const candidate = join(repoRoot, dir, relPath);
|
|
41
|
-
if (
|
|
62
|
+
if (cachedExists(candidate)) {
|
|
42
63
|
return resolvePath(candidate);
|
|
43
64
|
}
|
|
44
65
|
}
|
|
@@ -46,7 +67,7 @@ export function resolve(_fromAbsFile, modulePath, repoRoot) {
|
|
|
46
67
|
const relPath = `${modulePath.replace(/\\/g, '/')}.php`;
|
|
47
68
|
for (const base of ['', 'src', 'lib', 'app']) {
|
|
48
69
|
const candidate = join(repoRoot, base, relPath);
|
|
49
|
-
if (
|
|
70
|
+
if (cachedExists(candidate)) {
|
|
50
71
|
return resolvePath(candidate);
|
|
51
72
|
}
|
|
52
73
|
}
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* Handles dotted module paths (e.g., "from x.y import z").
|
|
5
5
|
* Walks up directories to find packages.
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
7
|
+
import { readFileSync } from 'fs';
|
|
8
8
|
import { dirname, join, resolve as resolvePath } from 'path';
|
|
9
|
+
import { cachedExists } from '../fs-cache';
|
|
9
10
|
/**
|
|
10
11
|
* Resolve a Python dotted import to a file path.
|
|
11
12
|
* Walks up from the importing file's directory to find the module.
|
|
@@ -26,7 +27,7 @@ export function resolve(fromAbsFile, modulePath, _repoRoot) {
|
|
|
26
27
|
const candidates = rest ? [`${rest}.py`, `${rest}/__init__.py`] : [`__init__.py`];
|
|
27
28
|
for (const candidate of candidates) {
|
|
28
29
|
const full = join(base, candidate);
|
|
29
|
-
if (
|
|
30
|
+
if (cachedExists(full)) {
|
|
30
31
|
return resolvePath(full);
|
|
31
32
|
}
|
|
32
33
|
}
|
|
@@ -37,7 +38,7 @@ export function resolve(fromAbsFile, modulePath, _repoRoot) {
|
|
|
37
38
|
for (let i = 0; i < 10; i++) {
|
|
38
39
|
for (const candidate of [`${parts}.py`, `${parts}/__init__.py`]) {
|
|
39
40
|
const full = join(current, candidate);
|
|
40
|
-
if (
|
|
41
|
+
if (cachedExists(full)) {
|
|
41
42
|
return resolvePath(full);
|
|
42
43
|
}
|
|
43
44
|
}
|
|
@@ -47,5 +48,80 @@ export function resolve(fromAbsFile, modulePath, _repoRoot) {
|
|
|
47
48
|
}
|
|
48
49
|
current = parent;
|
|
49
50
|
}
|
|
51
|
+
// Fallback: check setup.cfg for package_dir directive (e.g., package_dir = = src)
|
|
52
|
+
const setupCfgResult = resolveViaSetupCfg(parts, _repoRoot);
|
|
53
|
+
if (setupCfgResult) {
|
|
54
|
+
return setupCfgResult;
|
|
55
|
+
}
|
|
56
|
+
// Fallback: check pyproject.toml for package-dir (e.g., [tool.setuptools.package-dir] "" = "src")
|
|
57
|
+
const pyprojectResult = resolveViaPyprojectPackageDir(parts, _repoRoot);
|
|
58
|
+
if (pyprojectResult) {
|
|
59
|
+
return pyprojectResult;
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Try resolving via setup.cfg package_dir directive.
|
|
65
|
+
* Looks for patterns like:
|
|
66
|
+
* [options]
|
|
67
|
+
* package_dir =
|
|
68
|
+
* = src
|
|
69
|
+
* which means the root package directory is "src/".
|
|
70
|
+
*/
|
|
71
|
+
function resolveViaSetupCfg(relPath, repoRoot) {
|
|
72
|
+
const setupCfgPath = join(repoRoot, 'setup.cfg');
|
|
73
|
+
if (!cachedExists(setupCfgPath)) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const content = readFileSync(setupCfgPath, 'utf-8');
|
|
78
|
+
// Look for package_dir under [options]
|
|
79
|
+
// Common patterns:
|
|
80
|
+
// package_dir =
|
|
81
|
+
// = src
|
|
82
|
+
// package_dir = = src
|
|
83
|
+
const packageDirRegex = /package_dir\s*=\s*(?:\n\s+)?=\s*(\S+)/;
|
|
84
|
+
const match = packageDirRegex.exec(content);
|
|
85
|
+
if (match) {
|
|
86
|
+
const srcDir = match[1];
|
|
87
|
+
for (const candidate of [`${relPath}.py`, `${relPath}/__init__.py`]) {
|
|
88
|
+
const full = join(repoRoot, srcDir, candidate);
|
|
89
|
+
if (cachedExists(full)) {
|
|
90
|
+
return resolvePath(full);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// setup.cfg read failed, continue
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Try resolving via pyproject.toml [tool.setuptools.package-dir] directive.
|
|
102
|
+
*/
|
|
103
|
+
function resolveViaPyprojectPackageDir(relPath, repoRoot) {
|
|
104
|
+
const pyprojectPath = join(repoRoot, 'pyproject.toml');
|
|
105
|
+
if (!cachedExists(pyprojectPath)) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
const content = readFileSync(pyprojectPath, 'utf-8');
|
|
110
|
+
// Look for [tool.setuptools.package-dir] section with "" = "src" or similar
|
|
111
|
+
const packageDirRegex = /\[tool\.setuptools\.package-dir\]\s*\n\s*""\s*=\s*"(\S+)"/;
|
|
112
|
+
const match = packageDirRegex.exec(content);
|
|
113
|
+
if (match) {
|
|
114
|
+
const srcDir = match[1];
|
|
115
|
+
for (const candidate of [`${relPath}.py`, `${relPath}/__init__.py`]) {
|
|
116
|
+
const full = join(repoRoot, srcDir, candidate);
|
|
117
|
+
if (cachedExists(full)) {
|
|
118
|
+
return resolvePath(full);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// pyproject.toml read failed, continue
|
|
125
|
+
}
|
|
50
126
|
return null;
|
|
51
127
|
}
|
|
@@ -1,9 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Ruby import resolver.
|
|
3
3
|
*
|
|
4
|
-
* Handles require_relative paths
|
|
4
|
+
* Handles require_relative paths, Gemfile path: gems, and Zeitwerk autoload.
|
|
5
5
|
*/
|
|
6
6
|
/**
|
|
7
7
|
* Resolve a Ruby require/require_relative to a file path.
|
|
8
8
|
*/
|
|
9
9
|
export declare function resolve(fromAbsFile: string, modulePath: string, repoRoot: string): string | null;
|
|
10
|
+
/**
|
|
11
|
+
* Resolve a Ruby class/module constant name to a file path using Zeitwerk conventions.
|
|
12
|
+
*
|
|
13
|
+
* Zeitwerk maps constant names to file paths:
|
|
14
|
+
* - `User` → `user.rb`
|
|
15
|
+
* - `AuthService` → `auth_service.rb`
|
|
16
|
+
* - `Admin::UsersController` → `admin/users_controller.rb`
|
|
17
|
+
*
|
|
18
|
+
* This searches common Rails autoload paths for a matching file.
|
|
19
|
+
*
|
|
20
|
+
* @param className - The fully-qualified constant name (e.g., "Admin::UsersController")
|
|
21
|
+
* @param repoRoot - The root of the repository / Rails project
|
|
22
|
+
* @returns Absolute path to the resolved file, or null if not found
|
|
23
|
+
*/
|
|
24
|
+
export declare function resolveZeitwerk(className: string, repoRoot: string): string | null;
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Ruby import resolver.
|
|
3
3
|
*
|
|
4
|
-
* Handles require_relative paths
|
|
4
|
+
* Handles require_relative paths, Gemfile path: gems, and Zeitwerk autoload.
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
6
|
+
import { readFileSync } from 'fs';
|
|
7
7
|
import { dirname, join, resolve as resolvePath } from 'path';
|
|
8
|
+
import { cachedExists } from '../fs-cache';
|
|
8
9
|
/** Cache parsed Gemfile path gems per repo root. */
|
|
9
10
|
const gemfileCache = new Map();
|
|
10
11
|
/**
|
|
@@ -16,7 +17,7 @@ function getGemPathLibDirs(repoRoot) {
|
|
|
16
17
|
}
|
|
17
18
|
const gemfilePath = join(repoRoot, 'Gemfile');
|
|
18
19
|
const libDirs = [];
|
|
19
|
-
if (
|
|
20
|
+
if (cachedExists(gemfilePath)) {
|
|
20
21
|
const content = readFileSync(gemfilePath, 'utf-8');
|
|
21
22
|
// Match lines like: gem 'mylib', path: './libs/mylib'
|
|
22
23
|
const regex = /^\s*gem\s+['"][^'"]+['"]\s*,\s*path:\s*['"]([^'"]+)['"]/gm;
|
|
@@ -39,19 +40,69 @@ export function resolve(fromAbsFile, modulePath, repoRoot) {
|
|
|
39
40
|
}
|
|
40
41
|
// 1. Try relative resolution (require_relative style)
|
|
41
42
|
const base = join(dirname(fromAbsFile), modulePath);
|
|
42
|
-
if (
|
|
43
|
+
if (cachedExists(`${base}.rb`)) {
|
|
43
44
|
return resolvePath(`${base}.rb`);
|
|
44
45
|
}
|
|
45
|
-
if (
|
|
46
|
+
if (cachedExists(base)) {
|
|
46
47
|
return resolvePath(base);
|
|
47
48
|
}
|
|
48
49
|
// 2. Try Gemfile path: gems
|
|
49
50
|
for (const libDir of getGemPathLibDirs(repoRoot)) {
|
|
50
51
|
const candidate = join(libDir, modulePath);
|
|
51
|
-
if (
|
|
52
|
+
if (cachedExists(`${candidate}.rb`)) {
|
|
52
53
|
return resolvePath(`${candidate}.rb`);
|
|
53
54
|
}
|
|
54
|
-
if (
|
|
55
|
+
if (cachedExists(candidate)) {
|
|
56
|
+
return resolvePath(candidate);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
/** Common Rails autoload paths that Zeitwerk watches. */
|
|
62
|
+
const ZEITWERK_AUTOLOAD_PATHS = [
|
|
63
|
+
'app/models',
|
|
64
|
+
'app/controllers',
|
|
65
|
+
'app/services',
|
|
66
|
+
'app/jobs',
|
|
67
|
+
'app/mailers',
|
|
68
|
+
'app/helpers',
|
|
69
|
+
'lib',
|
|
70
|
+
];
|
|
71
|
+
/**
|
|
72
|
+
* Convert a CamelCase segment to snake_case.
|
|
73
|
+
* E.g., "AuthService" → "auth_service", "UsersController" → "users_controller"
|
|
74
|
+
*/
|
|
75
|
+
function camelToSnake(name) {
|
|
76
|
+
return name
|
|
77
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
|
|
78
|
+
.replace(/([a-z\d])([A-Z])/g, '$1_$2')
|
|
79
|
+
.toLowerCase();
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Resolve a Ruby class/module constant name to a file path using Zeitwerk conventions.
|
|
83
|
+
*
|
|
84
|
+
* Zeitwerk maps constant names to file paths:
|
|
85
|
+
* - `User` → `user.rb`
|
|
86
|
+
* - `AuthService` → `auth_service.rb`
|
|
87
|
+
* - `Admin::UsersController` → `admin/users_controller.rb`
|
|
88
|
+
*
|
|
89
|
+
* This searches common Rails autoload paths for a matching file.
|
|
90
|
+
*
|
|
91
|
+
* @param className - The fully-qualified constant name (e.g., "Admin::UsersController")
|
|
92
|
+
* @param repoRoot - The root of the repository / Rails project
|
|
93
|
+
* @returns Absolute path to the resolved file, or null if not found
|
|
94
|
+
*/
|
|
95
|
+
export function resolveZeitwerk(className, repoRoot) {
|
|
96
|
+
if (!className) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
// Split on :: and convert each segment from CamelCase to snake_case
|
|
100
|
+
const segments = className.split('::');
|
|
101
|
+
const relativePath = segments.map(camelToSnake).join('/');
|
|
102
|
+
// Search each autoload path
|
|
103
|
+
for (const autoloadPath of ZEITWERK_AUTOLOAD_PATHS) {
|
|
104
|
+
const candidate = join(repoRoot, autoloadPath, `${relativePath}.rb`);
|
|
105
|
+
if (cachedExists(candidate)) {
|
|
55
106
|
return resolvePath(candidate);
|
|
56
107
|
}
|
|
57
108
|
}
|
|
@@ -1,16 +1,17 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
2
|
import { basename, dirname, join, resolve as resolvePath } from 'path';
|
|
3
|
+
import { cachedExists } from '../fs-cache';
|
|
3
4
|
function probeRustPath(baseDir, relPath) {
|
|
4
5
|
const asFile = join(baseDir, `${relPath}.rs`);
|
|
5
|
-
if (
|
|
6
|
+
if (cachedExists(asFile)) {
|
|
6
7
|
return resolvePath(asFile);
|
|
7
8
|
}
|
|
8
9
|
const asMod = join(baseDir, relPath, 'mod.rs');
|
|
9
|
-
if (
|
|
10
|
+
if (cachedExists(asMod)) {
|
|
10
11
|
return resolvePath(asMod);
|
|
11
12
|
}
|
|
12
13
|
const asLib = join(baseDir, relPath, 'lib.rs');
|
|
13
|
-
if (
|
|
14
|
+
if (cachedExists(asLib)) {
|
|
14
15
|
return resolvePath(asLib);
|
|
15
16
|
}
|
|
16
17
|
return null;
|
|
@@ -96,7 +97,7 @@ function findCrateDir(fromAbsFile) {
|
|
|
96
97
|
let dir = dirname(fromAbsFile);
|
|
97
98
|
const root = resolvePath('/');
|
|
98
99
|
while (dir !== root) {
|
|
99
|
-
if (
|
|
100
|
+
if (cachedExists(join(dir, 'Cargo.toml'))) {
|
|
100
101
|
return dir;
|
|
101
102
|
}
|
|
102
103
|
const parent = dirname(dir);
|
|
@@ -123,7 +124,7 @@ function findLocalPackageName(fromAbsFile) {
|
|
|
123
124
|
return cached;
|
|
124
125
|
}
|
|
125
126
|
const cargoPath = join(crateDir, 'Cargo.toml');
|
|
126
|
-
if (!
|
|
127
|
+
if (!cachedExists(cargoPath)) {
|
|
127
128
|
pkgNameCache.set(crateDir, null);
|
|
128
129
|
return null;
|
|
129
130
|
}
|
|
@@ -146,7 +147,7 @@ function findLocalPackageName(fromAbsFile) {
|
|
|
146
147
|
function parsePathDeps(crateDir) {
|
|
147
148
|
const result = new Map();
|
|
148
149
|
const cargoPath = join(crateDir, 'Cargo.toml');
|
|
149
|
-
if (!
|
|
150
|
+
if (!cachedExists(cargoPath)) {
|
|
150
151
|
return result;
|
|
151
152
|
}
|
|
152
153
|
const content = readFileSync(cargoPath, 'utf-8');
|
|
@@ -7,6 +7,14 @@
|
|
|
7
7
|
* - Directory index files
|
|
8
8
|
* - tsconfig path aliases
|
|
9
9
|
*/
|
|
10
|
+
/**
|
|
11
|
+
* Load aliases from webpack.config.ts/js and vite.config.ts/js.
|
|
12
|
+
* These are NOT in tsconfig — many large projects use bundler aliases instead.
|
|
13
|
+
*
|
|
14
|
+
* Parses simple alias patterns from resolve.alias blocks.
|
|
15
|
+
* Returns Map<prefix, absoluteDir> — same format as tsconfig aliases.
|
|
16
|
+
*/
|
|
17
|
+
export declare function loadBundlerAliases(repoRoot: string): Map<string, string[]>;
|
|
10
18
|
/**
|
|
11
19
|
* Resolve a TypeScript/JavaScript relative import to an absolute file path.
|
|
12
20
|
* Returns null for non-relative (external package) imports.
|
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
* - Directory index files
|
|
8
8
|
* - tsconfig path aliases
|
|
9
9
|
*/
|
|
10
|
-
import {
|
|
10
|
+
import { readFileSync } from 'fs';
|
|
11
11
|
import { dirname, join, resolve as resolvePath } from 'path';
|
|
12
12
|
import { log } from '../../shared/logger';
|
|
13
|
+
import { cachedExists } from '../fs-cache';
|
|
13
14
|
const TS_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx'];
|
|
14
15
|
/**
|
|
15
16
|
* Probe a base path for TS/JS files: try extensions, then index files.
|
|
@@ -18,13 +19,13 @@ const TS_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx'];
|
|
|
18
19
|
function probeExtensions(base) {
|
|
19
20
|
for (const ext of TS_EXTENSIONS) {
|
|
20
21
|
const candidate = base + ext;
|
|
21
|
-
if (
|
|
22
|
+
if (cachedExists(candidate)) {
|
|
22
23
|
return resolvePath(candidate);
|
|
23
24
|
}
|
|
24
25
|
}
|
|
25
26
|
for (const ext of TS_EXTENSIONS) {
|
|
26
27
|
const candidate = join(base, `index${ext}`);
|
|
27
|
-
if (
|
|
28
|
+
if (cachedExists(candidate)) {
|
|
28
29
|
return resolvePath(candidate);
|
|
29
30
|
}
|
|
30
31
|
}
|
|
@@ -32,6 +33,154 @@ function probeExtensions(base) {
|
|
|
32
33
|
}
|
|
33
34
|
/** Cache for parsed tsconfig.json (keyed by repoRoot). */
|
|
34
35
|
const tsconfigCache = new Map();
|
|
36
|
+
/** Cache for parsed bundler aliases (keyed by repoRoot). */
|
|
37
|
+
const bundlerAliasCache = new Map();
|
|
38
|
+
/**
|
|
39
|
+
* Load aliases from webpack.config.ts/js and vite.config.ts/js.
|
|
40
|
+
* These are NOT in tsconfig — many large projects use bundler aliases instead.
|
|
41
|
+
*
|
|
42
|
+
* Parses simple alias patterns from resolve.alias blocks.
|
|
43
|
+
* Returns Map<prefix, absoluteDir> — same format as tsconfig aliases.
|
|
44
|
+
*/
|
|
45
|
+
export function loadBundlerAliases(repoRoot) {
|
|
46
|
+
const cached = bundlerAliasCache.get(repoRoot);
|
|
47
|
+
if (cached !== undefined) {
|
|
48
|
+
return cached;
|
|
49
|
+
}
|
|
50
|
+
const aliases = new Map();
|
|
51
|
+
const configFiles = ['webpack.config.js', 'webpack.config.ts', 'vite.config.js', 'vite.config.ts'];
|
|
52
|
+
for (const configFile of configFiles) {
|
|
53
|
+
const configPath = join(repoRoot, configFile);
|
|
54
|
+
if (!cachedExists(configPath)) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
59
|
+
parseBundlerAliases(content, repoRoot, aliases);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// config file read failed, continue
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
bundlerAliasCache.set(repoRoot, aliases);
|
|
66
|
+
return aliases;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Parse alias definitions from a webpack or vite config file content.
|
|
70
|
+
* Handles:
|
|
71
|
+
* - path.join(__dirname, 'a', 'b') and path.resolve(__dirname, 'a', 'b')
|
|
72
|
+
* - Simple string literal values: 'key': '/path/to/dir'
|
|
73
|
+
* - Variable references like path.join(varName, 'sub') where varName is defined
|
|
74
|
+
* earlier as const varName = path.join(__dirname, ...)
|
|
75
|
+
*/
|
|
76
|
+
function parseBundlerAliases(content, repoRoot, aliases) {
|
|
77
|
+
// First, extract top-level variable definitions like:
|
|
78
|
+
// const staticPrefix = path.join(__dirname, 'static')
|
|
79
|
+
const varDefs = new Map();
|
|
80
|
+
const varDefRegex = /(?:const|let|var)\s+(\w+)\s*=\s*path\.(?:join|resolve)\s*\(\s*__dirname\s*,\s*([^)]+)\)/g;
|
|
81
|
+
let varMatch = varDefRegex.exec(content);
|
|
82
|
+
while (varMatch !== null) {
|
|
83
|
+
const varName = varMatch[1];
|
|
84
|
+
const argsStr = varMatch[2];
|
|
85
|
+
const segments = extractStringArgs(argsStr);
|
|
86
|
+
if (segments.length > 0) {
|
|
87
|
+
varDefs.set(varName, join(repoRoot, ...segments));
|
|
88
|
+
}
|
|
89
|
+
varMatch = varDefRegex.exec(content);
|
|
90
|
+
}
|
|
91
|
+
// Find the alias block — look for alias: { ... } or alias: [ ... ]
|
|
92
|
+
// We search for "alias:" or "alias :" possibly inside resolve: { ... }
|
|
93
|
+
const aliasBlockRegex = /alias\s*:\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/gs;
|
|
94
|
+
let aliasMatch = aliasBlockRegex.exec(content);
|
|
95
|
+
while (aliasMatch !== null) {
|
|
96
|
+
const aliasBlock = aliasMatch[1];
|
|
97
|
+
parseAliasEntries(aliasBlock, repoRoot, varDefs, aliases);
|
|
98
|
+
aliasMatch = aliasBlockRegex.exec(content);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Parse individual alias entries from inside an alias block.
|
|
103
|
+
*/
|
|
104
|
+
function parseAliasEntries(block, repoRoot, varDefs, aliases) {
|
|
105
|
+
// Match entries like:
|
|
106
|
+
// key: path.join(__dirname, 'a', 'b'),
|
|
107
|
+
// 'key': path.join(__dirname, 'a', 'b'),
|
|
108
|
+
// "key": path.resolve(__dirname, 'a'),
|
|
109
|
+
// key: path.join(varName, 'sub'),
|
|
110
|
+
// key: 'literal/path',
|
|
111
|
+
// 'key': 'literal/path',
|
|
112
|
+
// Pattern for key (unquoted identifier or quoted string)
|
|
113
|
+
const keyPattern = /(?:'([^']+)'|"([^"]+)"|(\w+))\s*:\s*/g;
|
|
114
|
+
let keyMatch = keyPattern.exec(block);
|
|
115
|
+
while (keyMatch !== null) {
|
|
116
|
+
const key = keyMatch[1] ?? keyMatch[2] ?? keyMatch[3];
|
|
117
|
+
const valueStart = keyMatch.index + keyMatch[0].length;
|
|
118
|
+
const restOfBlock = block.slice(valueStart);
|
|
119
|
+
const resolvedDir = resolveAliasValue(restOfBlock, repoRoot, varDefs);
|
|
120
|
+
if (resolvedDir !== null && !aliases.has(`${key}/`) && !aliases.has(key)) {
|
|
121
|
+
// Use key + '/' as prefix for path-based aliases (like tsconfig aliases)
|
|
122
|
+
// but if the key already ends with special chars like ~, use as-is
|
|
123
|
+
const prefix = key.endsWith('/') ? key : `${key}/`;
|
|
124
|
+
aliases.set(prefix, [resolvedDir]);
|
|
125
|
+
// Also set exact match (for bare imports like 'sentry' → 'sentry/')
|
|
126
|
+
if (!aliases.has(key) && key !== prefix) {
|
|
127
|
+
aliases.set(key, [resolvedDir]);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
keyMatch = keyPattern.exec(block);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Try to resolve an alias value expression to an absolute directory.
|
|
135
|
+
*/
|
|
136
|
+
function resolveAliasValue(expr, repoRoot, varDefs) {
|
|
137
|
+
// path.join(__dirname, 'a', 'b') or path.resolve(__dirname, 'a', 'b')
|
|
138
|
+
const pathDirnameRegex = /^path\.(?:join|resolve)\s*\(\s*__dirname\s*,\s*([^)]+)\)/;
|
|
139
|
+
const dirnameMatch = pathDirnameRegex.exec(expr);
|
|
140
|
+
if (dirnameMatch) {
|
|
141
|
+
const segments = extractStringArgs(dirnameMatch[1]);
|
|
142
|
+
if (segments.length > 0) {
|
|
143
|
+
return join(repoRoot, ...segments);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// path.join(varName, 'a', 'b') or path.resolve(varName, 'a')
|
|
147
|
+
const pathVarRegex = /^path\.(?:join|resolve)\s*\(\s*(\w+)\s*(?:,\s*([^)]+))?\)/;
|
|
148
|
+
const varMatch = pathVarRegex.exec(expr);
|
|
149
|
+
if (varMatch) {
|
|
150
|
+
const varName = varMatch[1];
|
|
151
|
+
if (varName !== '__dirname' && varDefs.has(varName)) {
|
|
152
|
+
const baseDir = varDefs.get(varName);
|
|
153
|
+
if (varMatch[2]) {
|
|
154
|
+
const segments = extractStringArgs(varMatch[2]);
|
|
155
|
+
if (segments.length > 0) {
|
|
156
|
+
return join(baseDir, ...segments);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return baseDir;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Simple string literal: 'path/to/dir' or "path/to/dir"
|
|
163
|
+
const stringLiteralRegex = /^['"]([^'"]+)['"]/;
|
|
164
|
+
const strMatch = stringLiteralRegex.exec(expr);
|
|
165
|
+
if (strMatch) {
|
|
166
|
+
return join(repoRoot, strMatch[1]);
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Extract string literal arguments from a comma-separated argument list.
|
|
172
|
+
* e.g. "'static', 'app'" → ['static', 'app']
|
|
173
|
+
*/
|
|
174
|
+
function extractStringArgs(argsStr) {
|
|
175
|
+
const segments = [];
|
|
176
|
+
const argRegex = /['"]([^'"]+)['"]/g;
|
|
177
|
+
let m = argRegex.exec(argsStr);
|
|
178
|
+
while (m !== null) {
|
|
179
|
+
segments.push(m[1]);
|
|
180
|
+
m = argRegex.exec(argsStr);
|
|
181
|
+
}
|
|
182
|
+
return segments;
|
|
183
|
+
}
|
|
35
184
|
function loadTsconfigCompilerOptions(repoRoot) {
|
|
36
185
|
const cached = tsconfigCache.get(repoRoot);
|
|
37
186
|
if (cached !== undefined) {
|
|
@@ -39,7 +188,7 @@ function loadTsconfigCompilerOptions(repoRoot) {
|
|
|
39
188
|
}
|
|
40
189
|
const tsconfigPath = join(repoRoot, 'tsconfig.json');
|
|
41
190
|
let result = {};
|
|
42
|
-
if (
|
|
191
|
+
if (cachedExists(tsconfigPath)) {
|
|
43
192
|
try {
|
|
44
193
|
const content = readFileSync(tsconfigPath, 'utf-8');
|
|
45
194
|
const cleaned = stripJsonComments(content);
|
|
@@ -72,7 +221,7 @@ export function resolve(fromAbsFile, modulePath, repoRoot) {
|
|
|
72
221
|
let base = join(dirname(fromAbsFile), modulePath);
|
|
73
222
|
// If the path has a non-TS/JS extension (e.g. .txt, .svg), try exact match
|
|
74
223
|
if (/\.\w+$/.test(modulePath) && !TS_EXTENSIONS.some((ext) => modulePath.endsWith(ext))) {
|
|
75
|
-
if (
|
|
224
|
+
if (cachedExists(base)) {
|
|
76
225
|
return resolvePath(base);
|
|
77
226
|
}
|
|
78
227
|
}
|
|
@@ -177,9 +326,23 @@ function stripJsonComments(str) {
|
|
|
177
326
|
*/
|
|
178
327
|
export function loadTsconfigAliases(repoRoot) {
|
|
179
328
|
const aliases = new Map();
|
|
329
|
+
loadTsconfigPathsInto(repoRoot, aliases);
|
|
330
|
+
return aliases;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Parse a tsconfig.json (and tsconfig.base.json) in the given directory
|
|
334
|
+
* and add its path aliases to the provided map.
|
|
335
|
+
*/
|
|
336
|
+
function loadTsconfigPathsInto(dir, aliases, visited) {
|
|
337
|
+
const seen = visited ?? new Set();
|
|
338
|
+
const absDir = resolvePath(dir);
|
|
339
|
+
if (seen.has(absDir)) {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
seen.add(absDir);
|
|
180
343
|
for (const filename of ['tsconfig.json', 'tsconfig.base.json']) {
|
|
181
|
-
const tsconfigPath = join(
|
|
182
|
-
if (!
|
|
344
|
+
const tsconfigPath = join(dir, filename);
|
|
345
|
+
if (!cachedExists(tsconfigPath)) {
|
|
183
346
|
continue;
|
|
184
347
|
}
|
|
185
348
|
try {
|
|
@@ -188,16 +351,30 @@ export function loadTsconfigAliases(repoRoot) {
|
|
|
188
351
|
const config = JSON.parse(cleaned);
|
|
189
352
|
const paths = config?.compilerOptions?.paths;
|
|
190
353
|
const baseUrl = config?.compilerOptions?.baseUrl || '.';
|
|
191
|
-
const baseDir = join(
|
|
354
|
+
const baseDir = join(dir, baseUrl);
|
|
192
355
|
if (paths) {
|
|
193
356
|
for (const [alias, targets] of Object.entries(paths)) {
|
|
194
357
|
// Convert alias pattern: "@libs/*" -> prefix "@libs/"
|
|
195
358
|
const prefix = alias.replace('/*', '/').replace('*', '');
|
|
196
|
-
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
359
|
+
if (!aliases.has(prefix)) {
|
|
360
|
+
const resolvedTargets = targets.map((t) => {
|
|
361
|
+
const targetPath = t.replace('/*', '').replace('*', '');
|
|
362
|
+
return join(baseDir, targetPath);
|
|
363
|
+
});
|
|
364
|
+
aliases.set(prefix, resolvedTargets);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
// Follow project references to discover aliases from referenced projects
|
|
369
|
+
const references = config?.references;
|
|
370
|
+
if (Array.isArray(references)) {
|
|
371
|
+
for (const ref of references) {
|
|
372
|
+
if (ref && typeof ref.path === 'string') {
|
|
373
|
+
const refDir = resolvePath(dir, ref.path);
|
|
374
|
+
if (cachedExists(refDir)) {
|
|
375
|
+
loadTsconfigPathsInto(refDir, aliases, seen);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
201
378
|
}
|
|
202
379
|
}
|
|
203
380
|
}
|
|
@@ -205,7 +382,6 @@ export function loadTsconfigAliases(repoRoot) {
|
|
|
205
382
|
log.warn('Failed to parse tsconfig', { file: tsconfigPath, error: String(err) });
|
|
206
383
|
}
|
|
207
384
|
}
|
|
208
|
-
return aliases;
|
|
209
385
|
}
|
|
210
386
|
/**
|
|
211
387
|
* Resolve an import path using tsconfig aliases.
|
|
@@ -219,18 +395,18 @@ export function resolveWithAliases(modulePath, aliases, _repoRoot) {
|
|
|
219
395
|
for (const targetBase of targets) {
|
|
220
396
|
const base = join(targetBase, rest);
|
|
221
397
|
for (const ext of TS_EXTENSIONS) {
|
|
222
|
-
if (
|
|
398
|
+
if (cachedExists(base + ext)) {
|
|
223
399
|
return resolvePath(base + ext);
|
|
224
400
|
}
|
|
225
401
|
}
|
|
226
402
|
for (const ext of TS_EXTENSIONS) {
|
|
227
403
|
const idx = join(base, `index${ext}`);
|
|
228
|
-
if (
|
|
404
|
+
if (cachedExists(idx)) {
|
|
229
405
|
return resolvePath(idx);
|
|
230
406
|
}
|
|
231
407
|
}
|
|
232
408
|
// Try exact match (for directories with index)
|
|
233
|
-
if (
|
|
409
|
+
if (cachedExists(base)) {
|
|
234
410
|
return resolvePath(base);
|
|
235
411
|
}
|
|
236
412
|
}
|
package/package.json
CHANGED