@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
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Shared filesystem cache for resolvers.
3
+ * Caches existsSync and readdirSync results to avoid repeated disk I/O.
4
+ * Call clearFsCache() between analysis runs.
5
+ */
6
+ export declare function cachedExists(path: string): boolean;
7
+ export declare function cachedReaddir(path: string): string[];
8
+ export declare function clearFsCache(): void;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Shared filesystem cache for resolvers.
3
+ * Caches existsSync and readdirSync results to avoid repeated disk I/O.
4
+ * Call clearFsCache() between analysis runs.
5
+ */
6
+ import { existsSync, readdirSync } from 'fs';
7
+ const existsCache = new Map();
8
+ const readdirCache = new Map();
9
+ export function cachedExists(path) {
10
+ const cached = existsCache.get(path);
11
+ if (cached !== undefined) {
12
+ return cached;
13
+ }
14
+ const result = existsSync(path);
15
+ existsCache.set(path, result);
16
+ return result;
17
+ }
18
+ export function cachedReaddir(path) {
19
+ const cached = readdirCache.get(path);
20
+ if (cached !== undefined) {
21
+ return cached;
22
+ }
23
+ try {
24
+ const result = readdirSync(path).sort();
25
+ readdirCache.set(path, result);
26
+ return result;
27
+ }
28
+ catch {
29
+ readdirCache.set(path, []);
30
+ return [];
31
+ }
32
+ }
33
+ export function clearFsCache() {
34
+ existsCache.clear();
35
+ readdirCache.clear();
36
+ }
@@ -4,10 +4,12 @@
4
4
  * Routes import resolution to language-specific resolvers and
5
5
  * falls back to tsconfig aliases for TypeScript/JavaScript.
6
6
  */
7
- import { existsSync, readdirSync, readFileSync } from 'fs';
7
+ import { readdirSync, readFileSync } from 'fs';
8
8
  import { join, resolve as resolvePath } from 'path';
9
9
  import { log } from '../shared/logger';
10
10
  import { ensureWithinRoot } from '../shared/safe-path';
11
+ import { detectExternal } from './external-detector';
12
+ import { cachedExists } from './fs-cache';
11
13
  import { resolve as resolveCsImport } from './languages/csharp';
12
14
  import { resolve as resolveGoImport } from './languages/go';
13
15
  import { resolve as resolveJavaImport } from './languages/java';
@@ -15,7 +17,20 @@ import { resolve as resolvePhpImport } from './languages/php';
15
17
  import { resolve as resolvePyImport } from './languages/python';
16
18
  import { resolve as resolveRbImport } from './languages/ruby';
17
19
  import { resolve as resolveRustImport } from './languages/rust';
18
- import { loadTsconfigAliases, resolve as resolveTsImport, resolveWithAliases } from './languages/typescript';
20
+ import { loadBundlerAliases, loadTsconfigAliases, resolve as resolveTsImport, resolveWithAliases, } from './languages/typescript';
21
+ /**
22
+ * Registered import resolvers by language key.
23
+ *
24
+ * IMPORTANT: When adding a new language, you MUST:
25
+ * 1. Create a resolver in src/resolver/languages/<lang>.ts
26
+ * 2. Add it to this map
27
+ * 3. Add tests in tests/resolver/<lang>.test.ts
28
+ * 4. Add external detection in src/resolver/external-detector.ts
29
+ *
30
+ * If a language key from the parser is not in this map, resolveImport()
31
+ * will log a warning and return null. This is intentional — silent
32
+ * failures that default to another language's resolver cause wrong results.
33
+ */
19
34
  const RESOLVERS = {
20
35
  ts: resolveTsImport,
21
36
  javascript: resolveTsImport,
@@ -34,7 +49,7 @@ const RESOLVERS = {
34
49
  */
35
50
  function resolveHashImport(modulePath, repoRoot) {
36
51
  const pkgPath = join(repoRoot, 'package.json');
37
- if (!existsSync(pkgPath)) {
52
+ if (!cachedExists(pkgPath)) {
38
53
  return null;
39
54
  }
40
55
  try {
@@ -50,7 +65,7 @@ function resolveHashImport(modulePath, repoRoot) {
50
65
  if (pattern === modulePath) {
51
66
  // Exact match: "#utils" -> "./src/shared/utils.ts"
52
67
  const resolved = resolvePath(repoRoot, target);
53
- if (existsSync(resolved)) {
68
+ if (cachedExists(resolved)) {
54
69
  return resolved;
55
70
  }
56
71
  }
@@ -60,7 +75,7 @@ function resolveHashImport(modulePath, repoRoot) {
60
75
  if (modulePath.startsWith(prefix)) {
61
76
  const rest = modulePath.slice(prefix.length); // "connection"
62
77
  const resolved = resolvePath(repoRoot, target.replace('*', rest));
63
- if (existsSync(resolved)) {
78
+ if (cachedExists(resolved)) {
64
79
  return resolved;
65
80
  }
66
81
  }
@@ -72,28 +87,59 @@ function resolveHashImport(modulePath, repoRoot) {
72
87
  }
73
88
  return null;
74
89
  }
90
+ /**
91
+ * Resolve a conditional export value to a single string path.
92
+ * When the value is a plain string, return it directly.
93
+ * When it's an object with condition keys, prefer: types > import > default > first value.
94
+ */
95
+ function resolveExportValue(value) {
96
+ if (typeof value === 'string') {
97
+ return value;
98
+ }
99
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
100
+ const obj = value;
101
+ for (const key of ['types', 'import', 'default']) {
102
+ if (typeof obj[key] === 'string') {
103
+ return obj[key];
104
+ }
105
+ }
106
+ // Fallback: first value that is a string
107
+ for (const v of Object.values(obj)) {
108
+ if (typeof v === 'string') {
109
+ return v;
110
+ }
111
+ }
112
+ }
113
+ return null;
114
+ }
75
115
  /**
76
116
  * Resolve monorepo workspace package exports.
77
117
  * Scans workspace directories to find packages matching the import specifier.
78
118
  */
79
119
  function resolveWorkspaceExport(modulePath, repoRoot) {
80
120
  const rootPkgPath = join(repoRoot, 'package.json');
81
- if (!existsSync(rootPkgPath)) {
121
+ if (!cachedExists(rootPkgPath)) {
82
122
  return null;
83
123
  }
84
124
  try {
85
125
  const rootPkg = JSON.parse(readFileSync(rootPkgPath, 'utf-8'));
86
- const workspaces = rootPkg?.workspaces;
87
- if (!Array.isArray(workspaces)) {
126
+ let workspaceGlobs;
127
+ if (Array.isArray(rootPkg?.workspaces)) {
128
+ workspaceGlobs = rootPkg.workspaces;
129
+ }
130
+ else if (rootPkg?.workspaces?.packages && Array.isArray(rootPkg.workspaces.packages)) {
131
+ workspaceGlobs = rootPkg.workspaces.packages;
132
+ }
133
+ if (!workspaceGlobs) {
88
134
  return null;
89
135
  }
90
136
  // Collect all workspace package directories
91
137
  const pkgDirs = [];
92
- for (const ws of workspaces) {
138
+ for (const ws of workspaceGlobs) {
93
139
  if (ws.endsWith('/*')) {
94
140
  // Glob pattern like "packages/*"
95
141
  const parentDir = join(repoRoot, ws.slice(0, -2));
96
- if (existsSync(parentDir)) {
142
+ if (cachedExists(parentDir)) {
97
143
  const entries = readdirSync(parentDir, { withFileTypes: true });
98
144
  for (const entry of entries) {
99
145
  if (entry.isDirectory()) {
@@ -106,10 +152,10 @@ function resolveWorkspaceExport(modulePath, repoRoot) {
106
152
  pkgDirs.push(join(repoRoot, ws));
107
153
  }
108
154
  }
109
- // Search each workspace package for a matching name + exports
155
+ // Search each workspace package for a matching name + exports/main/module
110
156
  for (const pkgDir of pkgDirs) {
111
157
  const pkgJsonPath = join(pkgDir, 'package.json');
112
- if (!existsSync(pkgJsonPath)) {
158
+ if (!cachedExists(pkgJsonPath)) {
113
159
  continue;
114
160
  }
115
161
  const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
@@ -118,28 +164,53 @@ function resolveWorkspaceExport(modulePath, repoRoot) {
118
164
  continue;
119
165
  }
120
166
  const exports = pkg?.exports;
121
- if (!exports || typeof exports !== 'object') {
122
- continue;
123
- }
124
167
  // Check if modulePath matches this package (exact or subpath)
125
168
  if (modulePath === pkgName) {
126
- // Root export: "." entry
127
- const target = exports['.'];
128
- if (typeof target === 'string') {
129
- const resolved = resolvePath(pkgDir, target);
130
- if (existsSync(resolved)) {
131
- return resolved;
169
+ if (exports && typeof exports === 'object') {
170
+ // Root export: "." entry
171
+ const target = resolveExportValue(exports['.']);
172
+ if (target) {
173
+ const resolved = resolvePath(pkgDir, target);
174
+ if (cachedExists(resolved)) {
175
+ return resolved;
176
+ }
177
+ }
178
+ }
179
+ else if (!exports) {
180
+ // Fallback to main or module fields
181
+ const fallback = pkg.main ?? pkg.module;
182
+ if (typeof fallback === 'string') {
183
+ const resolved = resolvePath(pkgDir, fallback);
184
+ if (cachedExists(resolved)) {
185
+ return resolved;
186
+ }
132
187
  }
133
188
  }
134
189
  }
135
190
  else if (modulePath.startsWith(`${pkgName}/`)) {
136
- // Subpath export: "./button" entry
137
- const subpath = `./${modulePath.slice(pkgName.length + 1)}`;
138
- const target = exports[subpath];
139
- if (typeof target === 'string') {
140
- const resolved = resolvePath(pkgDir, target);
141
- if (existsSync(resolved)) {
142
- return resolved;
191
+ const subpath = modulePath.slice(pkgName.length + 1);
192
+ // 1. Try exports field first
193
+ if (exports && typeof exports === 'object') {
194
+ const exportKey = `./${subpath}`;
195
+ const target = resolveExportValue(exports[exportKey]);
196
+ if (target) {
197
+ const resolved = resolvePath(pkgDir, target);
198
+ if (cachedExists(resolved)) {
199
+ return resolved;
200
+ }
201
+ }
202
+ }
203
+ // 2. No exports or no match? Resolve subpath directly in package directory
204
+ const directBase = join(pkgDir, subpath);
205
+ for (const ext of ['.ts', '.tsx', '.js', '.jsx']) {
206
+ if (cachedExists(directBase + ext)) {
207
+ return resolvePath(directBase + ext);
208
+ }
209
+ }
210
+ for (const ext of ['.ts', '.tsx', '.js', '.jsx']) {
211
+ const idx = join(directBase, `index${ext}`);
212
+ if (cachedExists(idx)) {
213
+ return resolvePath(idx);
143
214
  }
144
215
  }
145
216
  }
@@ -163,11 +234,21 @@ function resolveWorkspaceExport(modulePath, repoRoot) {
163
234
  export function resolveImport(fromAbsFile, modulePath, lang, repoRoot, tsconfigAliases) {
164
235
  const resolver = RESOLVERS[lang];
165
236
  if (!resolver) {
237
+ log.warn('No import resolver registered for language', { lang, module: modulePath, from: fromAbsFile });
166
238
  return null;
167
239
  }
168
- const isTs = lang === 'ts' || lang === 'javascript' || lang === 'typescript';
240
+ // Strip webpack/rollup loader syntax: !!loader1!loader2!actual/path
241
+ // The actual import path is always the last segment after the final '!'
242
+ if (modulePath.includes('!')) {
243
+ modulePath = modulePath.split('!').pop() || modulePath;
244
+ }
245
+ // TS/JS-specific fallbacks: tsconfig aliases, bundler aliases, #imports, workspace exports.
246
+ // These are Node.js/npm ecosystem features that don't apply to other languages.
247
+ // Other languages handle their own workspace/monorepo patterns inside their resolvers
248
+ // (Go: go.work, Rust: Cargo workspace, Java: Maven/Gradle modules).
249
+ const isTsOrJs = lang === 'ts' || lang === 'javascript' || lang === 'typescript';
169
250
  // Handle package.json #imports (TS/JS only)
170
- if (isTs && modulePath.startsWith('#')) {
251
+ if (isTsOrJs && modulePath.startsWith('#')) {
171
252
  const result = resolveHashImport(modulePath, repoRoot);
172
253
  if (result) {
173
254
  try {
@@ -186,11 +267,18 @@ export function resolveImport(fromAbsFile, modulePath, lang, repoRoot, tsconfigA
186
267
  }
187
268
  let result = resolver(fromAbsFile, modulePath, repoRoot);
188
269
  // Fallback: tsconfig aliases for TS/JS
189
- if (!result && isTs && tsconfigAliases?.size) {
270
+ if (!result && isTsOrJs && tsconfigAliases?.size) {
190
271
  result = resolveWithAliases(modulePath, tsconfigAliases, repoRoot);
191
272
  }
273
+ // Fallback: bundler aliases (webpack/vite) for TS/JS bare specifiers
274
+ if (!result && isTsOrJs && !modulePath.startsWith('.')) {
275
+ const bundlerAliases = loadBundlerAliases(repoRoot);
276
+ if (bundlerAliases.size > 0) {
277
+ result = resolveWithAliases(modulePath, bundlerAliases, repoRoot);
278
+ }
279
+ }
192
280
  // Fallback: monorepo workspace exports for TS/JS bare specifiers
193
- if (!result && isTs && !modulePath.startsWith('.')) {
281
+ if (!result && isTsOrJs && !modulePath.startsWith('.')) {
194
282
  result = resolveWorkspaceExport(modulePath, repoRoot);
195
283
  }
196
284
  // Validate resolved path is within repo root
@@ -207,6 +295,16 @@ export function resolveImport(fromAbsFile, modulePath, lang, repoRoot, tsconfigA
207
295
  return null;
208
296
  }
209
297
  }
298
+ // If still unresolved, check if it's an external package (for logging/debugging)
299
+ if (!result) {
300
+ const externalPkg = detectExternal(modulePath, lang, repoRoot);
301
+ if (externalPkg) {
302
+ // External package — expected null, don't log as warning
303
+ return null;
304
+ }
305
+ // Truly unresolved local import
306
+ log.debug('Unresolved local import', { from: fromAbsFile, module: modulePath });
307
+ }
210
308
  return result;
211
309
  }
212
310
  export { loadTsconfigAliases };
@@ -1 +1,3 @@
1
+ /** Clear cached .sln data. Call between analysis runs or when switching repos. */
2
+ export declare function clearCache(): void;
1
3
  export declare function resolve(_fromAbsFile: string, modulePath: string, repoRoot: string): string | null;
@@ -1,18 +1,74 @@
1
- import { existsSync, statSync } from 'fs';
2
- import { join, resolve as resolvePath } from 'path';
1
+ import { readdirSync, readFileSync, statSync } from 'fs';
2
+ import { dirname, join, resolve as resolvePath } from 'path';
3
+ import { cachedExists } from '../fs-cache';
3
4
  const STDLIB_PREFIXES = ['System.', 'System', 'Microsoft.', 'Newtonsoft.'];
5
+ const slnProjectsCache = new Map();
6
+ /** Clear cached .sln data. Call between analysis runs or when switching repos. */
7
+ export function clearCache() {
8
+ slnProjectsCache.clear();
9
+ }
10
+ /**
11
+ * Parse .sln files at repoRoot to discover project directories.
12
+ * Lines like: Project("{FAE04EC0}") = "Name", "path/to/Project.csproj", "{GUID}"
13
+ */
14
+ function getSlnProjectDirs(repoRoot) {
15
+ const cached = slnProjectsCache.get(repoRoot);
16
+ if (cached) {
17
+ return cached;
18
+ }
19
+ const dirs = [];
20
+ try {
21
+ const entries = readdirSync(repoRoot);
22
+ for (const entry of entries) {
23
+ if (entry.endsWith('.sln')) {
24
+ const slnPath = join(repoRoot, entry);
25
+ try {
26
+ const content = readFileSync(slnPath, 'utf-8');
27
+ const projectRe = /^Project\("[^"]*"\)\s*=\s*"[^"]*",\s*"([^"]+\.csproj)"/gm;
28
+ let m = projectRe.exec(content);
29
+ while (m !== null) {
30
+ const csprojRelPath = m[1].replace(/\\/g, '/');
31
+ const projectDir = dirname(join(repoRoot, csprojRelPath));
32
+ if (cachedExists(projectDir)) {
33
+ dirs.push(projectDir);
34
+ }
35
+ m = projectRe.exec(content);
36
+ }
37
+ }
38
+ catch {
39
+ /* ignore unreadable sln */
40
+ }
41
+ }
42
+ }
43
+ }
44
+ catch {
45
+ /* ignore */
46
+ }
47
+ slnProjectsCache.set(repoRoot, dirs);
48
+ return dirs;
49
+ }
4
50
  export function resolve(_fromAbsFile, modulePath, repoRoot) {
5
51
  if (STDLIB_PREFIXES.some((p) => modulePath.startsWith(p))) {
6
52
  return null;
7
53
  }
8
54
  const segments = modulePath.split('.');
55
+ // Collect search base directories: standard ones + .sln-discovered project dirs
56
+ const standardBases = ['', 'src', 'lib', 'Source'];
57
+ const slnDirs = getSlnProjectDirs(repoRoot);
9
58
  // Try resolving as a .cs file first
10
59
  for (let i = segments.length - 1; i >= 0; i--) {
11
60
  const pathPart = segments.slice(i).join('/');
12
61
  const candidate = `${pathPart}.cs`;
13
- for (const base of ['', 'src', 'lib', 'Source']) {
62
+ for (const base of standardBases) {
14
63
  const full = join(repoRoot, base, candidate);
15
- if (existsSync(full)) {
64
+ if (cachedExists(full)) {
65
+ return resolvePath(full);
66
+ }
67
+ }
68
+ // Also search in .sln-discovered project directories
69
+ for (const projDir of slnDirs) {
70
+ const full = join(projDir, candidate);
71
+ if (cachedExists(full)) {
16
72
  return resolvePath(full);
17
73
  }
18
74
  }
@@ -20,9 +76,16 @@ export function resolve(_fromAbsFile, modulePath, repoRoot) {
20
76
  // Try resolving as a directory (namespace → folder mapping)
21
77
  for (let i = segments.length - 1; i >= 0; i--) {
22
78
  const pathPart = segments.slice(i).join('/');
23
- for (const base of ['', 'src', 'lib', 'Source']) {
79
+ for (const base of standardBases) {
24
80
  const full = join(repoRoot, base, pathPart);
25
- if (existsSync(full) && statSync(full).isDirectory()) {
81
+ if (cachedExists(full) && statSync(full).isDirectory()) {
82
+ return resolvePath(full);
83
+ }
84
+ }
85
+ // Also search in .sln-discovered project directories
86
+ for (const projDir of slnDirs) {
87
+ const full = join(projDir, pathPart);
88
+ if (cachedExists(full) && statSync(full).isDirectory()) {
26
89
  return resolvePath(full);
27
90
  }
28
91
  }
@@ -1,5 +1,6 @@
1
- import { existsSync, readdirSync, readFileSync } from 'fs';
1
+ import { readFileSync } from 'fs';
2
2
  import { join, resolve as resolvePath } from 'path';
3
+ import { cachedExists, cachedReaddir } from '../fs-cache';
3
4
  const moduleCache = new Map();
4
5
  const replaceCache = new Map();
5
6
  const workspaceCache = new Map();
@@ -15,7 +16,7 @@ function getModuleName(repoRoot) {
15
16
  return cached || null;
16
17
  }
17
18
  const goModPath = join(repoRoot, 'go.mod');
18
- if (!existsSync(goModPath)) {
19
+ if (!cachedExists(goModPath)) {
19
20
  moduleCache.set(repoRoot, '');
20
21
  return null;
21
22
  }
@@ -41,7 +42,7 @@ function getReplaceMap(repoRoot) {
41
42
  }
42
43
  const result = new Map();
43
44
  const goModPath = join(repoRoot, 'go.mod');
44
- if (!existsSync(goModPath)) {
45
+ if (!cachedExists(goModPath)) {
45
46
  replaceCache.set(repoRoot, result);
46
47
  return result;
47
48
  }
@@ -73,7 +74,7 @@ function getWorkspaceModules(repoRoot) {
73
74
  }
74
75
  const result = new Map();
75
76
  const goWorkPath = join(repoRoot, 'go.work');
76
- if (!existsSync(goWorkPath)) {
77
+ if (!cachedExists(goWorkPath)) {
77
78
  workspaceCache.set(repoRoot, result);
78
79
  return result;
79
80
  }
@@ -125,9 +126,9 @@ function isStdlib(modulePath) {
125
126
  }
126
127
  /** Find the first .go file (non-test) in a directory, or check for a .go file at the path. */
127
128
  function findGoFile(absDir) {
128
- if (existsSync(absDir)) {
129
+ if (cachedExists(absDir)) {
129
130
  try {
130
- const files = readdirSync(absDir).sort();
131
+ const files = cachedReaddir(absDir).sort();
131
132
  const goFile = files.find((f) => f.endsWith('.go') && !f.endsWith('_test.go'));
132
133
  if (goFile) {
133
134
  return resolvePath(join(absDir, goFile));
@@ -137,7 +138,7 @@ function findGoFile(absDir) {
137
138
  /* not a directory */
138
139
  }
139
140
  }
140
- if (existsSync(`${absDir}.go`)) {
141
+ if (cachedExists(`${absDir}.go`)) {
141
142
  return resolvePath(`${absDir}.go`);
142
143
  }
143
144
  return null;
@@ -1,5 +1,6 @@
1
- import { existsSync, readdirSync, readFileSync } from 'fs';
1
+ import { readFileSync } from 'fs';
2
2
  import { join, resolve as resolvePath } from 'path';
3
+ import { cachedExists, cachedReaddir } from '../fs-cache';
3
4
  const STDLIB_PREFIXES = ['java.', 'javax.', 'sun.', 'com.sun.', 'jdk.'];
4
5
  const SOURCE_ROOTS = ['src/main/java', 'src/main/kotlin', 'src', ''];
5
6
  const EXTENSIONS = ['.java', '.kt'];
@@ -11,7 +12,7 @@ function collectSourceRoots(repoRoot) {
11
12
  // Discover Gradle subprojects from settings.gradle / settings.gradle.kts
12
13
  for (const settingsFile of ['settings.gradle', 'settings.gradle.kts']) {
13
14
  const settingsPath = join(repoRoot, settingsFile);
14
- if (!existsSync(settingsPath)) {
15
+ if (!cachedExists(settingsPath)) {
15
16
  continue;
16
17
  }
17
18
  const content = readFileSync(settingsPath, 'utf-8');
@@ -29,24 +30,69 @@ function collectSourceRoots(repoRoot) {
29
30
  }
30
31
  break; // only read first settings file found
31
32
  }
32
- // Discover Maven subprojects from pom.xml
33
- const pomPath = join(repoRoot, 'pom.xml');
34
- if (existsSync(pomPath)) {
33
+ // Discover custom sourceSets from build.gradle / build.gradle.kts in subprojects
34
+ const gradleFiles = [];
35
+ // Collect build.gradle files: root + discovered subproject dirs
36
+ for (const buildFile of ['build.gradle', 'build.gradle.kts']) {
37
+ if (cachedExists(join(repoRoot, buildFile))) {
38
+ gradleFiles.push({ dir: '', file: buildFile });
39
+ }
40
+ }
41
+ // Also check subproject directories already discovered above
42
+ const subDirs = new Set();
43
+ for (const r of roots) {
44
+ // Extract the subproject directory (everything before src/...)
45
+ const srcIdx = r.indexOf('/src');
46
+ if (srcIdx > 0) {
47
+ subDirs.add(r.slice(0, srcIdx));
48
+ }
49
+ }
50
+ for (const sub of subDirs) {
51
+ for (const buildFile of ['build.gradle', 'build.gradle.kts']) {
52
+ const buildPath = join(repoRoot, sub, buildFile);
53
+ if (cachedExists(buildPath)) {
54
+ gradleFiles.push({ dir: sub, file: join(sub, buildFile) });
55
+ }
56
+ }
57
+ }
58
+ for (const { dir, file } of gradleFiles) {
35
59
  try {
36
- const content = readFileSync(pomPath, 'utf-8');
37
- const moduleRegex = /<module>([^<]+)<\/module>/g;
38
- let mvnMatch = moduleRegex.exec(content);
39
- while (mvnMatch !== null) {
40
- const subDir = mvnMatch[1];
41
- roots.push(join(subDir, 'src/main/java'));
42
- roots.push(join(subDir, 'src/main/kotlin'));
43
- mvnMatch = moduleRegex.exec(content);
60
+ const content = readFileSync(join(repoRoot, file), 'utf-8');
61
+ // Match: srcDirs = ['path1', 'path2']
62
+ const srcDirsArrayRegex = /srcDirs\s*=\s*\[([^\]]+)\]/g;
63
+ let sdMatch = srcDirsArrayRegex.exec(content);
64
+ while (sdMatch !== null) {
65
+ const entries = sdMatch[1];
66
+ const pathRegex = /['"]([^'"]+)['"]/g;
67
+ let pathMatch = pathRegex.exec(entries);
68
+ while (pathMatch !== null) {
69
+ const srcDir = pathMatch[1];
70
+ const root = dir ? join(dir, srcDir) : srcDir;
71
+ if (!roots.includes(root)) {
72
+ roots.push(root);
73
+ }
74
+ pathMatch = pathRegex.exec(entries);
75
+ }
76
+ sdMatch = srcDirsArrayRegex.exec(content);
77
+ }
78
+ // Match: srcDir 'path' or srcDir "path"
79
+ const srcDirSingleRegex = /srcDir\s+['"]([^'"]+)['"]/g;
80
+ let singleMatch = srcDirSingleRegex.exec(content);
81
+ while (singleMatch !== null) {
82
+ const srcDir = singleMatch[1];
83
+ const root = dir ? join(dir, srcDir) : srcDir;
84
+ if (!roots.includes(root)) {
85
+ roots.push(root);
86
+ }
87
+ singleMatch = srcDirSingleRegex.exec(content);
44
88
  }
45
89
  }
46
90
  catch {
47
- // pom.xml read failed, continue
91
+ // build.gradle read failed, continue
48
92
  }
49
93
  }
94
+ // Discover Maven subprojects from pom.xml (recursive)
95
+ discoverMavenModules(repoRoot, '', roots, 0);
50
96
  return roots;
51
97
  }
52
98
  /**
@@ -57,13 +103,52 @@ function findFile(repoRoot, relPathNoExt, sourceRoots) {
57
103
  for (const srcRoot of sourceRoots) {
58
104
  for (const ext of EXTENSIONS) {
59
105
  const candidate = join(repoRoot, srcRoot, relPathNoExt + ext);
60
- if (existsSync(candidate)) {
106
+ if (cachedExists(candidate)) {
61
107
  return resolvePath(candidate);
62
108
  }
63
109
  }
64
110
  }
65
111
  return null;
66
112
  }
113
+ const MAX_MAVEN_DEPTH = 5;
114
+ /**
115
+ * Recursively discover Maven modules from pom.xml files.
116
+ * Each module's pom.xml may declare its own <module> elements,
117
+ * forming a tree (e.g. Keycloak: root → services → sub-service).
118
+ */
119
+ function discoverMavenModules(repoRoot, relDir, roots, depth) {
120
+ if (depth > MAX_MAVEN_DEPTH) {
121
+ return;
122
+ }
123
+ const pomPath = join(repoRoot, relDir, 'pom.xml');
124
+ if (!cachedExists(pomPath)) {
125
+ return;
126
+ }
127
+ try {
128
+ const content = readFileSync(pomPath, 'utf-8');
129
+ const moduleRegex = /<module>([^<]+)<\/module>/g;
130
+ let mvnMatch = moduleRegex.exec(content);
131
+ while (mvnMatch !== null) {
132
+ const moduleName = mvnMatch[1];
133
+ const moduleDir = relDir ? join(relDir, moduleName) : moduleName;
134
+ // Add source roots for this module
135
+ const javaRoot = join(moduleDir, 'src/main/java');
136
+ if (!roots.includes(javaRoot)) {
137
+ roots.push(javaRoot);
138
+ }
139
+ const kotlinRoot = join(moduleDir, 'src/main/kotlin');
140
+ if (!roots.includes(kotlinRoot)) {
141
+ roots.push(kotlinRoot);
142
+ }
143
+ // Recurse into the module's own pom.xml
144
+ discoverMavenModules(repoRoot, moduleDir, roots, depth + 1);
145
+ mvnMatch = moduleRegex.exec(content);
146
+ }
147
+ }
148
+ catch {
149
+ // pom.xml read failed, continue
150
+ }
151
+ }
67
152
  export function resolve(_fromAbsFile, modulePath, repoRoot) {
68
153
  if (STDLIB_PREFIXES.some((p) => modulePath.startsWith(p))) {
69
154
  return null;
@@ -74,9 +159,9 @@ export function resolve(_fromAbsFile, modulePath, repoRoot) {
74
159
  const packagePath = modulePath.slice(0, -2).replace(/\./g, '/');
75
160
  for (const srcRoot of sourceRoots) {
76
161
  const dirPath = join(repoRoot, srcRoot, packagePath);
77
- if (existsSync(dirPath)) {
162
+ if (cachedExists(dirPath)) {
78
163
  try {
79
- const files = readdirSync(dirPath).filter((f) => EXTENSIONS.some((ext) => f.endsWith(ext)));
164
+ const files = cachedReaddir(dirPath).filter((f) => EXTENSIONS.some((ext) => f.endsWith(ext)));
80
165
  if (files.length > 0) {
81
166
  return resolvePath(join(dirPath, files[0]));
82
167
  }