@kodus/kodus-graph 0.2.9 → 0.2.11

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 (40) hide show
  1. package/dist/analysis/blast-radius.d.ts +1 -1
  2. package/dist/analysis/blast-radius.js +19 -21
  3. package/dist/analysis/context-builder.js +13 -4
  4. package/dist/analysis/diff.d.ts +6 -0
  5. package/dist/analysis/diff.js +16 -1
  6. package/dist/analysis/enrich.d.ts +1 -1
  7. package/dist/analysis/enrich.js +37 -9
  8. package/dist/analysis/prompt-formatter.d.ts +11 -0
  9. package/dist/analysis/prompt-formatter.js +147 -112
  10. package/dist/cli.js +2 -0
  11. package/dist/commands/analyze.js +5 -3
  12. package/dist/commands/diff.js +2 -2
  13. package/dist/commands/parse.d.ts +1 -0
  14. package/dist/commands/parse.js +3 -3
  15. package/dist/commands/update.js +2 -2
  16. package/dist/graph/builder.d.ts +5 -1
  17. package/dist/graph/builder.js +39 -4
  18. package/dist/graph/edges.d.ts +5 -1
  19. package/dist/graph/edges.js +61 -7
  20. package/dist/graph/types.d.ts +3 -0
  21. package/dist/parser/batch.d.ts +1 -0
  22. package/dist/parser/batch.js +18 -3
  23. package/dist/parser/languages.js +1 -0
  24. package/dist/resolver/external-detector.d.ts +11 -0
  25. package/dist/resolver/external-detector.js +820 -0
  26. package/dist/resolver/fs-cache.d.ts +8 -0
  27. package/dist/resolver/fs-cache.js +36 -0
  28. package/dist/resolver/import-resolver.js +130 -32
  29. package/dist/resolver/languages/csharp.d.ts +2 -0
  30. package/dist/resolver/languages/csharp.js +69 -6
  31. package/dist/resolver/languages/go.js +8 -7
  32. package/dist/resolver/languages/java.js +102 -17
  33. package/dist/resolver/languages/php.js +26 -5
  34. package/dist/resolver/languages/python.js +79 -3
  35. package/dist/resolver/languages/ruby.d.ts +16 -1
  36. package/dist/resolver/languages/ruby.js +58 -7
  37. package/dist/resolver/languages/rust.js +8 -7
  38. package/dist/resolver/languages/typescript.d.ts +8 -0
  39. package/dist/resolver/languages/typescript.js +193 -17
  40. package/package.json +1 -1
@@ -1,10 +1,15 @@
1
- import { existsSync, readFileSync } from 'fs';
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 (existsSync(composerPath)) {
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 (existsSync(candidate)) {
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 (existsSync(candidate)) {
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 { existsSync } from 'fs';
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 (existsSync(full)) {
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 (existsSync(full)) {
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 and Gemfile path: gems.
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 and Gemfile path: gems.
4
+ * Handles require_relative paths, Gemfile path: gems, and Zeitwerk autoload.
5
5
  */
6
- import { existsSync, readFileSync } from 'fs';
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 (existsSync(gemfilePath)) {
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 (existsSync(`${base}.rb`)) {
43
+ if (cachedExists(`${base}.rb`)) {
43
44
  return resolvePath(`${base}.rb`);
44
45
  }
45
- if (existsSync(base)) {
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 (existsSync(`${candidate}.rb`)) {
52
+ if (cachedExists(`${candidate}.rb`)) {
52
53
  return resolvePath(`${candidate}.rb`);
53
54
  }
54
- if (existsSync(candidate)) {
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 { existsSync, readFileSync } from 'fs';
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 (existsSync(asFile)) {
6
+ if (cachedExists(asFile)) {
6
7
  return resolvePath(asFile);
7
8
  }
8
9
  const asMod = join(baseDir, relPath, 'mod.rs');
9
- if (existsSync(asMod)) {
10
+ if (cachedExists(asMod)) {
10
11
  return resolvePath(asMod);
11
12
  }
12
13
  const asLib = join(baseDir, relPath, 'lib.rs');
13
- if (existsSync(asLib)) {
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 (existsSync(join(dir, 'Cargo.toml'))) {
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 (!existsSync(cargoPath)) {
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 (!existsSync(cargoPath)) {
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 { existsSync, readFileSync } from 'fs';
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 (existsSync(candidate)) {
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 (existsSync(candidate)) {
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 (existsSync(tsconfigPath)) {
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 (existsSync(base)) {
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(repoRoot, filename);
182
- if (!existsSync(tsconfigPath)) {
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(repoRoot, baseUrl);
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
- const resolvedTargets = targets.map((t) => {
197
- const targetPath = t.replace('/*', '').replace('*', '');
198
- return join(baseDir, targetPath);
199
- });
200
- aliases.set(prefix, resolvedTargets);
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 (existsSync(base + ext)) {
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 (existsSync(idx)) {
404
+ if (cachedExists(idx)) {
229
405
  return resolvePath(idx);
230
406
  }
231
407
  }
232
408
  // Try exact match (for directories with index)
233
- if (existsSync(base)) {
409
+ if (cachedExists(base)) {
234
410
  return resolvePath(base);
235
411
  }
236
412
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kodus/kodus-graph",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
4
  "description": "Code graph builder for Kodus code review — parses source code into structural graphs with nodes, edges, and analysis",
5
5
  "type": "module",
6
6
  "main": "./dist/cli.js",