Skip to content
62 changes: 58 additions & 4 deletions libs/openant-core/parsers/javascript/dependency_resolver.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const path = require('path');
class DependencyResolver {
constructor(analyzerOutput, options = {}) {
this.functions = analyzerOutput.functions || {};
this.classes = analyzerOutput.classes || {}; // "filePath:className" -> { constructorDeps, fieldDeps, baseTypes }
this.callGraph = {}; // functionId -> [calledFunctionIds]
this.reverseCallGraph = {}; // functionId -> [callerFunctionIds]
this.maxDepth = options.maxDepth || 3;
Expand All @@ -29,6 +30,7 @@ class DependencyResolver {
this.functionsByName = Object.create(null); // simpleName -> [functionIds]
this.functionsByFile = Object.create(null); // filePath -> [functionIds]
this.imports = Object.create(null); // filePath -> { importedName -> { source, originalName } }
this.classesByBaseType = Object.create(null); // baseTypeName -> ["filePath:className", ...]

this._buildIndexes();
}
Expand All @@ -52,6 +54,13 @@ class DependencyResolver {
}
this.functionsByFile[filePath].push(funcId);
}

for (const [classKey, classData] of Object.entries(this.classes)) {
for (const baseType of (classData.baseTypes || [])) {
if (!this.classesByBaseType[baseType]) this.classesByBaseType[baseType] = [];
this.classesByBaseType[baseType].push(classKey);
}
}
}

/**
Expand Down Expand Up @@ -134,7 +143,7 @@ class DependencyResolver {
// Skip 'this' (handled above) and common built-ins
if (objectName === 'this' || this._isBuiltIn(objectName)) continue;

const resolved = this._resolveMethodCall(objectName, methodName, callerFile);
const resolved = this._resolveMethodCall(objectName, methodName, callerFile, callerFuncId);
if (resolved && !seenCalls.has(resolved)) {
seenCalls.add(resolved);
calls.push(resolved);
Expand Down Expand Up @@ -240,23 +249,68 @@ class DependencyResolver {

/**
* Resolve an object.method call
*
* Supports two resolution strategies:
* 1. Direct class name match: objectName === className
* 2. DI-aware resolution: objectName is a constructor-injected parameter,
* use its type annotation to find the target class
*/
_resolveMethodCall(objectName, methodName, callerFile) {
// Check if objectName matches a class name
const qualifiedName = `${objectName}.${methodName}`;
_resolveMethodCall(objectName, methodName, callerFile, callerFuncId = null) {
const candidates = this.functionsByName[methodName];

if (!candidates || !Array.isArray(candidates)) {
return null;
}

// 1. Exact class name match (existing behavior)
for (const funcId of candidates) {
const funcData = this.functions[funcId];
if (funcData && funcData.className === objectName) {
return funcId;
}
}

// 2. DI-aware resolution: look up objectName in caller's constructorDeps
// e.g., this.callService.getById() -> constructorDeps says callService: CallService
// -> resolve to CallService.getById
if (callerFuncId) {
const callerFunc = this.functions[callerFuncId];
const classEntry = callerFunc && callerFunc.className &&
this.classes[callerFile + ':' + callerFunc.className];
if (classEntry && (classEntry.constructorDeps || classEntry.fieldDeps)) {
const typeName = (classEntry.constructorDeps || {})[objectName]
?? (classEntry.fieldDeps || {})[objectName];
if (typeName) {
// 2a. Exact type match
for (const funcId of candidates) {
const funcData = this.functions[funcId];
if (funcData && funcData.className === typeName) {
return funcId;
}
}

// 2b. Nominal type match: prefer candidates whose class implements or extends typeName.
// If exactly one such candidate exists, the resolution is unambiguous.
const nominalClassKeys = this.classesByBaseType[typeName] || [];
const nominalMatches = candidates.filter(funcId => {
const funcData = this.functions[funcId];
if (!funcData || !funcData.className) return false;
const funcClassKey = funcId.split(':')[0] + ':' + funcData.className;
return nominalClassKeys.includes(funcClassKey);
});
if (nominalMatches.length === 1) return nominalMatches[0];

// 2c. Prefix match: last resort for versioned names (e.g., CallService -> CallServiceV1).
// Skip if multiple candidates match to preserve no-false-positive property.
const prefixMatches = candidates.filter(funcId => {
const funcData = this.functions[funcId];
return funcData && funcData.className && funcData.className.startsWith(typeName);
});
if (prefixMatches.length === 1) return prefixMatches[0];
}
}
}

return null;
}

Expand Down
83 changes: 83 additions & 0 deletions libs/openant-core/parsers/javascript/typescript_analyzer.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ class TypeScriptAnalyzer {
compilerOptions: PERMISSIVE_COMPILER_OPTIONS,
});
this.functions = {}; // functionId -> function metadata
this.classes = {}; // "filePath:className" -> { constructorDeps, fieldDeps, baseTypes }
this.callGraph = {}; // callerId -> array of call info
}

Expand Down Expand Up @@ -155,6 +156,7 @@ class TypeScriptAnalyzer {

return {
functions: this.functions,
classes: this.classes,
callGraph: this.callGraph,
};
}
Expand Down Expand Up @@ -227,6 +229,87 @@ class TypeScriptAnalyzer {
className: className,
};
}

// Build class-level metadata: constructorDeps and baseTypes
const classEntry = {};

// Extract base types (implements + extends) for nominal DI resolution.
// Strips generics: implements Repository<User> -> Repository
const baseTypes = [];
const extendsExpr = classDecl.getExtends();
if (extendsExpr) {
const name = extendsExpr.getExpression().getText().replace(/<.*$/, '');
if (/^[A-Z][a-zA-Z0-9_$]*$/.test(name)) baseTypes.push(name);
}
for (const impl of classDecl.getImplements()) {
const name = impl.getExpression().getText().replace(/<.*$/, '');
if (/^[A-Z][a-zA-Z0-9_$]*$/.test(name)) baseTypes.push(name);
}
if (baseTypes.length > 0) classEntry.baseTypes = baseTypes;

// Extract constructor DI metadata.
// DI classes have a single primary constructor; overloads are unusual in NestJS/Angular.
const constructors = classDecl.getConstructors();
if (constructors.length > 0) {
const ctor = constructors[0];
Comment thread
joshbouncesecurity marked this conversation as resolved.
const injections = {}; // paramName -> typeName

for (const param of ctor.getParameters()) {
const paramName = param.getName();
const typeNode = param.getTypeNode();
if (typeNode) {
// Strip generic parameters so Repository<User> resolves as Repository
const typeName = typeNode.getText().replace(/<.*$/, '');
// Only store simple PascalCase type names (skip union types, primitives)
if (/^[A-Z][a-zA-Z0-9_$]*$/.test(typeName)) {
Comment thread
joshbouncesecurity marked this conversation as resolved.
injections[paramName] = typeName;
}
}
}

if (Object.keys(injections).length > 0) classEntry.constructorDeps = injections;
}

// Extract field/property injection metadata.
// Covers decorator-based (@Inject, @InjectRepository, etc.) and Angular's inject() function.
const fieldDeps = {};
for (const prop of classDecl.getProperties()) {
const propName = prop.getName();
let typeName = null;

// Decorator-based: any @Inject* decorator signals an injection point;
// the injected type comes from the TypeScript type annotation.
const hasInjectDecorator = prop.getDecorators().some(d => /^Inject/.test(d.getName()));
if (hasInjectDecorator) {
const typeNode = prop.getTypeNode();
if (typeNode) {
const t = typeNode.getText().replace(/<.*$/, '');
if (/^[A-Z][a-zA-Z0-9_$]*$/.test(t)) typeName = t;
}
}

// Functional: private svc = inject(SvcType) (Angular inject() API)
if (!typeName) {
const init = prop.getInitializer();
if (init && init.getKindName() === 'CallExpression') {
const expr = init.getExpression();
if (expr && expr.getText() === 'inject') {
const args = init.getArguments();
if (args.length > 0) {
const t = args[0].getText().replace(/<.*$/, '');
if (/^[A-Z][a-zA-Z0-9_$]*$/.test(t)) typeName = t;
}
}
}
}

if (typeName) fieldDeps[propName] = typeName;
}
if (Object.keys(fieldDeps).length > 0) classEntry.fieldDeps = fieldDeps;

if (Object.keys(classEntry).length > 0) {
this.classes[`${relativePath}:${className}`] = classEntry;
}
}

// Extract methods from object literals in export default
Expand Down
Loading
Loading