@kodus/kodus-graph 0.1.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/package.json +62 -0
- package/src/analysis/blast-radius.ts +54 -0
- package/src/analysis/communities.ts +135 -0
- package/src/analysis/diff.ts +120 -0
- package/src/analysis/flows.ts +112 -0
- package/src/analysis/review-context.ts +141 -0
- package/src/analysis/risk-score.ts +62 -0
- package/src/analysis/search.ts +76 -0
- package/src/analysis/test-gaps.ts +21 -0
- package/src/cli.ts +192 -0
- package/src/commands/analyze.ts +66 -0
- package/src/commands/communities.ts +19 -0
- package/src/commands/context.ts +69 -0
- package/src/commands/diff.ts +96 -0
- package/src/commands/flows.ts +19 -0
- package/src/commands/parse.ts +100 -0
- package/src/commands/search.ts +41 -0
- package/src/commands/update.ts +166 -0
- package/src/graph/builder.ts +170 -0
- package/src/graph/edges.ts +101 -0
- package/src/graph/loader.ts +100 -0
- package/src/graph/merger.ts +25 -0
- package/src/graph/types.ts +218 -0
- package/src/parser/batch.ts +74 -0
- package/src/parser/discovery.ts +42 -0
- package/src/parser/extractor.ts +37 -0
- package/src/parser/extractors/generic.ts +87 -0
- package/src/parser/extractors/python.ts +127 -0
- package/src/parser/extractors/ruby.ts +142 -0
- package/src/parser/extractors/typescript.ts +329 -0
- package/src/parser/languages.ts +122 -0
- package/src/resolver/call-resolver.ts +179 -0
- package/src/resolver/import-map.ts +27 -0
- package/src/resolver/import-resolver.ts +72 -0
- package/src/resolver/languages/csharp.ts +7 -0
- package/src/resolver/languages/go.ts +7 -0
- package/src/resolver/languages/java.ts +7 -0
- package/src/resolver/languages/php.ts +7 -0
- package/src/resolver/languages/python.ts +35 -0
- package/src/resolver/languages/ruby.ts +21 -0
- package/src/resolver/languages/rust.ts +7 -0
- package/src/resolver/languages/typescript.ts +168 -0
- package/src/resolver/symbol-table.ts +53 -0
- package/src/shared/file-hash.ts +7 -0
- package/src/shared/filters.ts +243 -0
- package/src/shared/logger.ts +14 -0
- package/src/shared/qualified-name.ts +5 -0
- package/src/shared/safe-path.ts +31 -0
- package/src/shared/schemas.ts +31 -0
- package/src/shared/temp.ts +17 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Call resolution with 5-tier confidence cascade.
|
|
3
|
+
*
|
|
4
|
+
* Cascade: DI (0.90-0.95) → same-file (0.85) → import-resolved (0.70-0.90)
|
|
5
|
+
* → unique-name (0.50) → ambiguous (0.30)
|
|
6
|
+
*
|
|
7
|
+
* Pure resolution logic — no file I/O, no parsing.
|
|
8
|
+
* Raw call sites are provided by the batch parser.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { RawCallEdge, RawCallSite } from '../graph/types';
|
|
12
|
+
import { NOISE } from '../shared/filters';
|
|
13
|
+
import type { ImportMap } from './import-map';
|
|
14
|
+
import type { SymbolTable } from './symbol-table';
|
|
15
|
+
|
|
16
|
+
// ── Types ──
|
|
17
|
+
|
|
18
|
+
interface ResolveResult {
|
|
19
|
+
target: string;
|
|
20
|
+
confidence: number;
|
|
21
|
+
strategy: 'di' | 'same' | 'import' | 'unique' | 'ambiguous';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface CallResolverStats {
|
|
25
|
+
di: number;
|
|
26
|
+
same: number;
|
|
27
|
+
import: number;
|
|
28
|
+
unique: number;
|
|
29
|
+
ambiguous: number;
|
|
30
|
+
noise: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface ResolveAllResult {
|
|
34
|
+
callEdges: RawCallEdge[];
|
|
35
|
+
stats: CallResolverStats;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Batch resolution (pure, no I/O) ──
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve all raw call sites via the 5-tier cascade.
|
|
42
|
+
*
|
|
43
|
+
* Accepts pre-extracted RawCallSite[] from the batch parser.
|
|
44
|
+
* No file reads, no parseAsync — pure iteration + lookup.
|
|
45
|
+
*/
|
|
46
|
+
export function resolveAllCalls(
|
|
47
|
+
rawCalls: RawCallSite[],
|
|
48
|
+
diMaps: Map<string, Map<string, string>>,
|
|
49
|
+
symbolTable: SymbolTable,
|
|
50
|
+
importMap: ImportMap,
|
|
51
|
+
): ResolveAllResult {
|
|
52
|
+
const callEdges: RawCallEdge[] = [];
|
|
53
|
+
const stats: CallResolverStats = { di: 0, same: 0, import: 0, unique: 0, ambiguous: 0, noise: 0 };
|
|
54
|
+
|
|
55
|
+
for (const call of rawCalls) {
|
|
56
|
+
if (NOISE.has(call.callName)) {
|
|
57
|
+
stats.noise++;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const fp = call.source;
|
|
62
|
+
const diMap = diMaps.get(fp);
|
|
63
|
+
|
|
64
|
+
// Try DI resolution first if diField is present
|
|
65
|
+
if (call.diField) {
|
|
66
|
+
const resolved = resolveDICall(call.diField, call.callName, fp, diMap, symbolTable);
|
|
67
|
+
if (resolved) {
|
|
68
|
+
callEdges.push({
|
|
69
|
+
source: fp,
|
|
70
|
+
target: resolved.target,
|
|
71
|
+
callName: call.callName,
|
|
72
|
+
line: call.line,
|
|
73
|
+
confidence: resolved.confidence,
|
|
74
|
+
});
|
|
75
|
+
stats.di++;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Name-based cascade fallback
|
|
81
|
+
const resolved = resolveByName(call.callName, fp, symbolTable, importMap);
|
|
82
|
+
if (resolved) {
|
|
83
|
+
callEdges.push({
|
|
84
|
+
source: fp,
|
|
85
|
+
target: resolved.target,
|
|
86
|
+
callName: call.callName,
|
|
87
|
+
line: call.line,
|
|
88
|
+
confidence: resolved.confidence,
|
|
89
|
+
});
|
|
90
|
+
stats[resolved.strategy]++;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { callEdges, stats };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── DI resolution ──
|
|
98
|
+
|
|
99
|
+
function resolveDICall(
|
|
100
|
+
fieldName: string,
|
|
101
|
+
methodName: string,
|
|
102
|
+
_currentFile: string,
|
|
103
|
+
diMap: Map<string, string> | undefined,
|
|
104
|
+
symbolTable: SymbolTable,
|
|
105
|
+
): ResolveResult | null {
|
|
106
|
+
if (!diMap?.has(fieldName)) return null;
|
|
107
|
+
|
|
108
|
+
const typeName = diMap.get(fieldName)!;
|
|
109
|
+
|
|
110
|
+
// Direct class match
|
|
111
|
+
const candidates = symbolTable.lookupGlobal(typeName);
|
|
112
|
+
if (candidates.length >= 1) {
|
|
113
|
+
const typeFile = candidates[0].split('::')[0];
|
|
114
|
+
return { target: `${typeFile}::${typeName}.${methodName}`, confidence: 0.95, strategy: 'di' };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ISomething → Something heuristic for interface → implementation
|
|
118
|
+
if (typeName.startsWith('I') && typeName[1] === typeName[1]?.toUpperCase()) {
|
|
119
|
+
const implName = typeName.substring(1);
|
|
120
|
+
const implCandidates = symbolTable.lookupGlobal(implName);
|
|
121
|
+
if (implCandidates.length >= 1) {
|
|
122
|
+
const implFile = implCandidates[0].split('::')[0];
|
|
123
|
+
return { target: `${implFile}::${implName}.${methodName}`, confidence: 0.9, strategy: 'di' };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Name-based resolution (4-tier cascade) ──
|
|
131
|
+
|
|
132
|
+
function resolveByName(
|
|
133
|
+
callName: string,
|
|
134
|
+
currentFile: string,
|
|
135
|
+
symbolTable: SymbolTable,
|
|
136
|
+
importMap: ImportMap,
|
|
137
|
+
): ResolveResult | null {
|
|
138
|
+
// Strategy 1: Same file (0.85)
|
|
139
|
+
const sameFile = symbolTable.lookupExact(currentFile, callName);
|
|
140
|
+
if (sameFile) return { target: sameFile, confidence: 0.85, strategy: 'same' };
|
|
141
|
+
|
|
142
|
+
// Strategy 2: Import-resolved (0.70-0.90)
|
|
143
|
+
const importedFrom = importMap.lookup(currentFile, callName);
|
|
144
|
+
if (importedFrom) {
|
|
145
|
+
const targetSym = symbolTable.lookupExact(importedFrom, callName);
|
|
146
|
+
if (targetSym) return { target: targetSym, confidence: 0.9, strategy: 'import' };
|
|
147
|
+
return { target: `${importedFrom}::${callName}`, confidence: 0.7, strategy: 'import' };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Strategy 3: Unique global name (0.50)
|
|
151
|
+
if (symbolTable.isUnique(callName)) {
|
|
152
|
+
const candidates = symbolTable.lookupGlobal(callName);
|
|
153
|
+
return { target: candidates[0], confidence: 0.5, strategy: 'unique' };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Strategy 4: Ambiguous (0.30)
|
|
157
|
+
const candidates = symbolTable.lookupGlobal(callName);
|
|
158
|
+
if (candidates.length > 1) {
|
|
159
|
+
return { target: callName, confidence: 0.3, strategy: 'ambiguous' };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── Public wrapper for unit testing ──
|
|
166
|
+
|
|
167
|
+
export function resolveCall(
|
|
168
|
+
callName: string,
|
|
169
|
+
currentFile: string,
|
|
170
|
+
symbolTable: SymbolTable,
|
|
171
|
+
importMap: ImportMap,
|
|
172
|
+
): { target: string; confidence: number } | null {
|
|
173
|
+
if (NOISE.has(callName)) return null;
|
|
174
|
+
|
|
175
|
+
const result = resolveByName(callName, currentFile, symbolTable, importMap);
|
|
176
|
+
if (!result) return null;
|
|
177
|
+
|
|
178
|
+
return { target: result.target, confidence: result.confidence };
|
|
179
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Import map: tracks which symbols are imported from where per file.
|
|
3
|
+
*
|
|
4
|
+
* For each importing file, maps symbol names to the resolved file path
|
|
5
|
+
* they were imported from. Used by the call resolver to connect
|
|
6
|
+
* function calls to their definitions across files.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface ImportMap {
|
|
10
|
+
add(file: string, name: string, targetFile: string): void;
|
|
11
|
+
lookup(file: string, name: string): string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createImportMap(): ImportMap {
|
|
15
|
+
const map = new Map<string, Map<string, string>>();
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
add(file, name, targetFile) {
|
|
19
|
+
if (!map.has(file)) map.set(file, new Map());
|
|
20
|
+
map.get(file)!.set(name, targetFile);
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
lookup(file, name) {
|
|
24
|
+
return map.get(file)?.get(name) ?? null;
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Import resolver dispatcher.
|
|
3
|
+
*
|
|
4
|
+
* Routes import resolution to language-specific resolvers and
|
|
5
|
+
* falls back to tsconfig aliases for TypeScript/JavaScript.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { log } from '../shared/logger';
|
|
9
|
+
import { ensureWithinRoot } from '../shared/safe-path';
|
|
10
|
+
import { resolve as resolveCsImport } from './languages/csharp';
|
|
11
|
+
import { resolve as resolveGoImport } from './languages/go';
|
|
12
|
+
import { resolve as resolveJavaImport } from './languages/java';
|
|
13
|
+
import { resolve as resolvePhpImport } from './languages/php';
|
|
14
|
+
import { resolve as resolvePyImport } from './languages/python';
|
|
15
|
+
import { resolve as resolveRbImport } from './languages/ruby';
|
|
16
|
+
import { resolve as resolveRustImport } from './languages/rust';
|
|
17
|
+
import { loadTsconfigAliases, resolve as resolveTsImport, resolveWithAliases } from './languages/typescript';
|
|
18
|
+
|
|
19
|
+
const RESOLVERS: Record<string, (from: string, mod: string, root: string) => string | null> = {
|
|
20
|
+
ts: resolveTsImport,
|
|
21
|
+
javascript: resolveTsImport,
|
|
22
|
+
typescript: resolveTsImport,
|
|
23
|
+
python: resolvePyImport,
|
|
24
|
+
ruby: resolveRbImport,
|
|
25
|
+
go: resolveGoImport,
|
|
26
|
+
java: resolveJavaImport,
|
|
27
|
+
rust: resolveRustImport,
|
|
28
|
+
csharp: resolveCsImport,
|
|
29
|
+
php: resolvePhpImport,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve an import from one file to another.
|
|
34
|
+
*
|
|
35
|
+
* @param fromAbsFile - Absolute path of the importing file
|
|
36
|
+
* @param modulePath - The import specifier (e.g., './auth', 'express', '@/lib/db')
|
|
37
|
+
* @param lang - Language key (ts, javascript, typescript, python, ruby, etc.)
|
|
38
|
+
* @param repoRoot - Absolute path to the repository root
|
|
39
|
+
* @param tsconfigAliases - Optional pre-loaded tsconfig aliases for TS/JS
|
|
40
|
+
* @returns Absolute path to the resolved file, or null if unresolvable
|
|
41
|
+
*/
|
|
42
|
+
export function resolveImport(
|
|
43
|
+
fromAbsFile: string,
|
|
44
|
+
modulePath: string,
|
|
45
|
+
lang: string,
|
|
46
|
+
repoRoot: string,
|
|
47
|
+
tsconfigAliases?: Map<string, string[]>,
|
|
48
|
+
): string | null {
|
|
49
|
+
const resolver = RESOLVERS[lang];
|
|
50
|
+
if (!resolver) return null;
|
|
51
|
+
|
|
52
|
+
let result = resolver(fromAbsFile, modulePath, repoRoot);
|
|
53
|
+
|
|
54
|
+
// Fallback: tsconfig aliases for TS/JS
|
|
55
|
+
if (!result && (lang === 'ts' || lang === 'javascript' || lang === 'typescript') && tsconfigAliases?.size) {
|
|
56
|
+
result = resolveWithAliases(modulePath, tsconfigAliases, repoRoot);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Validate resolved path is within repo root
|
|
60
|
+
if (result) {
|
|
61
|
+
try {
|
|
62
|
+
ensureWithinRoot(result, repoRoot);
|
|
63
|
+
} catch {
|
|
64
|
+
log.warn('Import resolves outside repository root', { from: fromAbsFile, module: modulePath, resolved: result });
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export { loadTsconfigAliases };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Python import resolver.
|
|
3
|
+
*
|
|
4
|
+
* Handles dotted module paths (e.g., "from x.y import z").
|
|
5
|
+
* Walks up directories to find packages.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync } from 'fs';
|
|
9
|
+
import { dirname, join, resolve as resolvePath } from 'path';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Resolve a Python dotted import to a file path.
|
|
13
|
+
* Walks up from the importing file's directory to find the module.
|
|
14
|
+
*/
|
|
15
|
+
export function resolve(fromAbsFile: string, modulePath: string, _repoRoot: string): string | null {
|
|
16
|
+
if (!modulePath || modulePath.startsWith('.')) {
|
|
17
|
+
// Relative import -- not handled yet
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const parts = modulePath.replace(/\./g, '/');
|
|
22
|
+
let current = dirname(fromAbsFile);
|
|
23
|
+
|
|
24
|
+
for (let i = 0; i < 10; i++) {
|
|
25
|
+
for (const candidate of [`${parts}.py`, `${parts}/__init__.py`]) {
|
|
26
|
+
const full = join(current, candidate);
|
|
27
|
+
if (existsSync(full)) return resolvePath(full);
|
|
28
|
+
}
|
|
29
|
+
const parent = dirname(current);
|
|
30
|
+
if (parent === current) break;
|
|
31
|
+
current = parent;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ruby import resolver.
|
|
3
|
+
*
|
|
4
|
+
* Handles require_relative paths.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync } from 'fs';
|
|
8
|
+
import { dirname, join, resolve as resolvePath } from 'path';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Resolve a Ruby require/require_relative to a file path.
|
|
12
|
+
*/
|
|
13
|
+
export function resolve(fromAbsFile: string, modulePath: string, _repoRoot: string): string | null {
|
|
14
|
+
if (!modulePath) return null;
|
|
15
|
+
|
|
16
|
+
const base = join(dirname(fromAbsFile), modulePath);
|
|
17
|
+
if (existsSync(`${base}.rb`)) return resolvePath(`${base}.rb`);
|
|
18
|
+
if (existsSync(base)) return resolvePath(base);
|
|
19
|
+
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TypeScript/JavaScript import resolver.
|
|
3
|
+
*
|
|
4
|
+
* Handles:
|
|
5
|
+
* - Relative imports with extension probing (.ts, .tsx, .js, .jsx)
|
|
6
|
+
* - ESM .js → .ts remapping
|
|
7
|
+
* - Directory index files
|
|
8
|
+
* - tsconfig path aliases
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync } from 'fs';
|
|
12
|
+
import { dirname, join, resolve as resolvePath } from 'path';
|
|
13
|
+
import { log } from '../../shared/logger';
|
|
14
|
+
|
|
15
|
+
const TS_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx'];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolve a TypeScript/JavaScript relative import to an absolute file path.
|
|
19
|
+
* Returns null for non-relative (external package) imports.
|
|
20
|
+
*/
|
|
21
|
+
export function resolve(fromAbsFile: string, modulePath: string, _repoRoot: string): string | null {
|
|
22
|
+
if (!modulePath.startsWith('.')) return null;
|
|
23
|
+
|
|
24
|
+
let base = join(dirname(fromAbsFile), modulePath);
|
|
25
|
+
|
|
26
|
+
// ESM convention: .js in import -> .ts on disk
|
|
27
|
+
if (modulePath.endsWith('.js')) base = base.slice(0, -3);
|
|
28
|
+
|
|
29
|
+
// Try direct with extension
|
|
30
|
+
for (const ext of TS_EXTENSIONS) {
|
|
31
|
+
const candidate = base + ext;
|
|
32
|
+
if (existsSync(candidate)) return resolvePath(candidate);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Try index file in directory
|
|
36
|
+
for (const ext of TS_EXTENSIONS) {
|
|
37
|
+
const candidate = join(base, `index${ext}`);
|
|
38
|
+
if (existsSync(candidate)) return resolvePath(candidate);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Strip comments and trailing commas from JSON (tsconfig-compatible).
|
|
46
|
+
* Handles strings correctly -- won't strip // inside "url://..." etc.
|
|
47
|
+
*/
|
|
48
|
+
function stripJsonComments(str: string): string {
|
|
49
|
+
let result = '';
|
|
50
|
+
let i = 0;
|
|
51
|
+
const len = str.length;
|
|
52
|
+
|
|
53
|
+
while (i < len) {
|
|
54
|
+
// String literal -- copy as-is
|
|
55
|
+
if (str[i] === '"') {
|
|
56
|
+
let j = i + 1;
|
|
57
|
+
while (j < len && str[j] !== '"') {
|
|
58
|
+
if (str[j] === '\\') j++; // skip escaped char
|
|
59
|
+
j++;
|
|
60
|
+
}
|
|
61
|
+
result += str.substring(i, j + 1);
|
|
62
|
+
i = j + 1;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Single-line comment
|
|
67
|
+
if (str[i] === '/' && str[i + 1] === '/') {
|
|
68
|
+
while (i < len && str[i] !== '\n') i++;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Block comment
|
|
73
|
+
if (str[i] === '/' && str[i + 1] === '*') {
|
|
74
|
+
i += 2;
|
|
75
|
+
while (i < len && !(str[i] === '*' && str[i + 1] === '/')) i++;
|
|
76
|
+
i += 2;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Trailing comma: comma followed by optional whitespace + closing bracket
|
|
81
|
+
if (str[i] === ',') {
|
|
82
|
+
let j = i + 1;
|
|
83
|
+
while (j < len && (str[j] === ' ' || str[j] === '\t' || str[j] === '\n' || str[j] === '\r')) j++;
|
|
84
|
+
if (str[j] === '}' || str[j] === ']') {
|
|
85
|
+
i++;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
result += str[i];
|
|
91
|
+
i++;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Load and parse tsconfig.json path aliases.
|
|
99
|
+
*
|
|
100
|
+
* Tries tsconfig.json first, then tsconfig.base.json.
|
|
101
|
+
* Converts alias patterns like "@libs/*" into prefix → resolved dirs.
|
|
102
|
+
*/
|
|
103
|
+
export function loadTsconfigAliases(repoRoot: string): Map<string, string[]> {
|
|
104
|
+
const aliases = new Map<string, string[]>();
|
|
105
|
+
|
|
106
|
+
for (const filename of ['tsconfig.json', 'tsconfig.base.json']) {
|
|
107
|
+
const tsconfigPath = join(repoRoot, filename);
|
|
108
|
+
if (!existsSync(tsconfigPath)) continue;
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
const content = readFileSync(tsconfigPath, 'utf-8');
|
|
112
|
+
const cleaned = stripJsonComments(content);
|
|
113
|
+
const config = JSON.parse(cleaned);
|
|
114
|
+
const paths = config?.compilerOptions?.paths;
|
|
115
|
+
const baseUrl = config?.compilerOptions?.baseUrl || '.';
|
|
116
|
+
const baseDir = join(repoRoot, baseUrl);
|
|
117
|
+
|
|
118
|
+
if (paths) {
|
|
119
|
+
for (const [alias, targets] of Object.entries(paths)) {
|
|
120
|
+
// Convert alias pattern: "@libs/*" -> prefix "@libs/"
|
|
121
|
+
const prefix = alias.replace('/*', '/').replace('*', '');
|
|
122
|
+
const resolvedTargets = (targets as string[]).map((t) => {
|
|
123
|
+
const targetPath = t.replace('/*', '').replace('*', '');
|
|
124
|
+
return join(baseDir, targetPath);
|
|
125
|
+
});
|
|
126
|
+
aliases.set(prefix, resolvedTargets);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
} catch (err) {
|
|
130
|
+
log.warn('Failed to parse tsconfig', { file: tsconfigPath, error: String(err) });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return aliases;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Resolve an import path using tsconfig aliases.
|
|
139
|
+
*
|
|
140
|
+
* Tries each alias prefix, and for matches, probes extensions and index files.
|
|
141
|
+
*/
|
|
142
|
+
export function resolveWithAliases(
|
|
143
|
+
modulePath: string,
|
|
144
|
+
aliases: Map<string, string[]>,
|
|
145
|
+
_repoRoot: string,
|
|
146
|
+
): string | null {
|
|
147
|
+
for (const [prefix, targets] of aliases) {
|
|
148
|
+
if (modulePath.startsWith(prefix)) {
|
|
149
|
+
const rest = modulePath.slice(prefix.length);
|
|
150
|
+
|
|
151
|
+
for (const targetBase of targets) {
|
|
152
|
+
const base = join(targetBase, rest);
|
|
153
|
+
|
|
154
|
+
for (const ext of TS_EXTENSIONS) {
|
|
155
|
+
if (existsSync(base + ext)) return resolvePath(base + ext);
|
|
156
|
+
}
|
|
157
|
+
for (const ext of TS_EXTENSIONS) {
|
|
158
|
+
const idx = join(base, `index${ext}`);
|
|
159
|
+
if (existsSync(idx)) return resolvePath(idx);
|
|
160
|
+
}
|
|
161
|
+
// Try exact match (for directories with index)
|
|
162
|
+
if (existsSync(base)) return resolvePath(base);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Symbol table with dual-index lookup (GitNexus pattern).
|
|
3
|
+
*
|
|
4
|
+
* Provides both exact (file + name) and global (name-only) lookups.
|
|
5
|
+
* The exact lookup is high-confidence; global lookup is lower-confidence
|
|
6
|
+
* but useful when import resolution fails.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface SymbolTable {
|
|
10
|
+
add(file: string, name: string, qualified: string): void;
|
|
11
|
+
lookupExact(file: string, name: string): string | null;
|
|
12
|
+
isUnique(name: string): boolean;
|
|
13
|
+
lookupGlobal(name: string): string[];
|
|
14
|
+
readonly size: number;
|
|
15
|
+
readonly fileCount: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createSymbolTable(): SymbolTable {
|
|
19
|
+
const byFile = new Map<string, Map<string, string>>();
|
|
20
|
+
const byName = new Map<string, string[]>();
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
add(file, name, qualified) {
|
|
24
|
+
if (!byFile.has(file)) byFile.set(file, new Map());
|
|
25
|
+
byFile.get(file)!.set(name, qualified);
|
|
26
|
+
|
|
27
|
+
if (!byName.has(name)) byName.set(name, []);
|
|
28
|
+
byName.get(name)!.push(qualified);
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
lookupExact(file, name) {
|
|
32
|
+
return byFile.get(file)?.get(name) ?? null;
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
isUnique(name) {
|
|
36
|
+
return (byName.get(name)?.length ?? 0) === 1;
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
lookupGlobal(name) {
|
|
40
|
+
return byName.get(name) ?? [];
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
get size() {
|
|
44
|
+
let count = 0;
|
|
45
|
+
for (const m of byFile.values()) count += m.size;
|
|
46
|
+
return count;
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
get fileCount() {
|
|
50
|
+
return byFile.size;
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|