Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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