Skip to content
Open
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
91 changes: 88 additions & 3 deletions src/checker/callgraph/callgraph-checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,36 @@ class CallgraphChecker extends CheckerCallgraph {
this.triggerAtFunctionCallBefore(analyzer, scope, node, state, info)
}

/**
* Returns whether this call target should be skipped (i.e., not emitted into the call graph).
*
* This is used to filter out internal/closure artifacts that show up as symbols with
* meaningless or implementation-specific paths.
*
* @param fclos - Callee closure/symbol.
* @param fclosName - Callee name (best-effort).
* @returns True if the call should be skipped.
*/
shouldSkipCall(fclos: any, fclosName: string): boolean {
// 1) Skip paths with internal markers (scopes/blocks/instances, etc.)
const fullPath = fclos?.qid || fclos?.sid || fclos?.id || ''
if (typeof fullPath === 'string') {
if (fullPath.includes('<global>') ||
fullPath.includes('_scope') ||
fullPath.includes('<block_') ||
fullPath.includes('<instance>')) {
return true
}
}

// 2) Skip malformed/anonymous path prefixes
if (fclosName.startsWith('...') || fclosName.startsWith(':')) {
return true
}

return false
}

/**
*
* @param analyzer
Expand All @@ -84,9 +114,24 @@ class CallgraphChecker extends CheckerCallgraph {
triggerAtFunctionCallBefore(analyzer: any, scope: any, node: any, state: any, info: any): void {
const { fclos, argvalues, ainfo } = info
const fdecl = fclos.fdef
if (fclos === undefined || fdecl?.type !== 'FunctionDefinition') {

if (fclos === undefined) {
return
}

// Best-effort callee name resolution.
const fclosName = fclos?.name || fclos?.id || fclos?.sid || ''
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fclosName may be object causing startsWith TypeError

Medium Severity

fclosName is assigned via fclos?.name || fclos?.id || fclos?.sid || '' but these properties can be objects, not strings. The prettyPrint method in the same file explicitly handles non-string fclos.id and fclos.sid. If any is an object, fclosName.startsWith('...') in shouldSkipCall throws a TypeError.

Additional Locations (1)

Fix in Cursor Fix in Web


// External call: no resolvable FunctionDefinition in the analysis scope.
const isExternalCall = fdecl?.type !== 'FunctionDefinition'

// For external calls, filter out noisy/internal symbol paths.
if (isExternalCall) {
if (this.shouldSkipCall(fclos, fclosName)) {
return
}
}

const stack = state.callstack
const to = fclos
const toAST = fclos && fclos.fdef
Expand All @@ -98,14 +143,54 @@ class CallgraphChecker extends CheckerCallgraph {
return
}
const callgraph = (ainfo.callgraph = ainfo.callgraph || new this.kit.Graph())
const fromNode = callgraph.addNode(this.prettyPrint(from, fromAST, call_site_node), {
const fromNodeLabel = this.prettyPrint(from, fromAST, call_site_node)

// Compute the callee node label. For external calls, use a simplified `<external>` label.
let toNodeLabel: string
if (isExternalCall) {
toNodeLabel = this.generateExternalLabel(fclos, fclosName)
} else {
toNodeLabel = this.prettyPrint(to, toAST, call_site_node)
}

const fromNode = callgraph.addNode(fromNodeLabel, {
funcDef: fromAST,
funcSymbol: from,
})
const toNode = callgraph.addNode(this.prettyPrint(to, toAST, call_site_node), { funcDef: toAST, funcSymbol: to })
const toNode = callgraph.addNode(toNodeLabel, { funcDef: toAST, funcSymbol: to })
callgraph.addEdge(fromNode, toNode, { callSite: call_site_node })
}

/**
* Builds a readable label for an external call target.
*
* We derive a best-effort module path from the parent chain and emit:
* `<external> module.submodule.function`.
*/
generateExternalLabel(fclos: any, fclosName: string): string {
// Build a best-effort module path from the parent chain.
const pathParts: string[] = []
let current = fclos.parent
while (current) {
const partName = current.name || current.id || current.sid
if (partName && typeof partName === 'string') {
// Filter out internal markers and local implementation details.
if (!partName.includes('<') && !partName.includes('>') &&
!partName.includes('./') && !partName.includes('_scope') &&
!partName.includes('<block_')) {
pathParts.unshift(partName)
}
}
current = current.parent
}

if (pathParts.length > 0) {
return `<external> ${pathParts.join('.')}.${fclosName}`
} else {
return `<external> ${fclosName}`
}
}

/**
*
* @param analyzer
Expand Down
42 changes: 29 additions & 13 deletions src/engine/analyzer/common/analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2023,6 +2023,12 @@ class Analyzer extends MemSpace {
*/
executeCall(node: any, fclos: any, argvalues: any, state: any, scope: any): any {
if (Config.makeAllCG && fclos?.fdef?.type === 'FunctionDefinition' && this.ainfo?.callgraph?.nodes) {
const funcKey = `${fclos.fdef?.loc?.sourcefile}:${fclos.fdef?.loc?.start?.line}-${fclos.fdef?.loc?.end?.line}`

if (!this.ainfo.analyzedFunctionBodies) {
this.ainfo.analyzedFunctionBodies = new Set()
}
Comment on lines +2028 to +2030
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This check and initialization of analyzedFunctionBodies occurs on every call to executeCall, which can be a hot path. For better performance, it would be more efficient to initialize this.ainfo.analyzedFunctionBodies = new Set() once at the beginning of the analysis, for instance, within the startAnalyze method. This would eliminate this redundant check.


for (const callgraphnode of this.ainfo?.callgraph?.nodes.values()) {
if (
callgraphnode.opts?.funcDef?.loc?.start?.line &&
Expand All @@ -2031,6 +2037,9 @@ class Analyzer extends MemSpace {
callgraphnode.opts?.funcDef?.loc?.start?.line === fclos.fdef?.loc?.start?.line &&
callgraphnode.opts?.funcDef?.loc?.end?.line === fclos.fdef?.loc?.end?.line
) {
const alreadyAnalyzed = this.ainfo.analyzedFunctionBodies.has(funcKey)

// add call edge
this.checkerManager.checkAtFunctionCallBefore(this, scope, node, state, {
argvalues,
fclos,
Expand All @@ -2041,12 +2050,18 @@ class Analyzer extends MemSpace {
analyzer: this,
ainfo: this.ainfo,
})
return SymbolValue({
type: 'FunctionCall',
expression: fclos,
arguments: argvalues,
ast: node,
})

if (alreadyAnalyzed) {
return SymbolValue({
type: 'FunctionCall',
expression: fclos,
arguments: argvalues,
ast: node,
})
} else {
this.ainfo.analyzedFunctionBodies.add(funcKey)
break
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Double call to checkAtFunctionCallBefore adds duplicate edges

High Severity

When alreadyAnalyzed is false, checkAtFunctionCallBefore is called at line 2043, then the code breaks and falls through to executeSingleCall, which calls checkAtFunctionCallBefore again. This results in the call graph edge being added twice for every function that hasn't been analyzed yet, causing duplicate edges in the call graph.

Additional Locations (1)

Fix in Cursor Fix in Web

}
}
}
}
Expand Down Expand Up @@ -2504,13 +2519,14 @@ class Analyzer extends MemSpace {

if (logger.isTraceEnabled()) logger.trace(`\nprocessCall: function: ${this.formatScope(fdecl?.id?.name)}`)

// avoid infinite loops,the re-entry should only less than 3
if (
fdecl &&
state.callstack.reduce((previousValue: any, currentValue: any) => {
return currentValue.fdef === fdecl ? previousValue + 1 : previousValue
}, 0) > 0
) {
// avoid infinite loops, the re-entry should only less than 3
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The comment is misleading. It states that re-entry should be less than 3, but the logic (recursionCount > 0) prevents any recursion at all. The comment should be updated to accurately reflect the code's behavior to avoid confusion for future maintainers.

Suggested change
// avoid infinite loops, the re-entry should only less than 3
// avoid infinite loops by disallowing recursion

const recursionCount = fdecl
? state.callstack.reduce((previousValue: any, currentValue: any) => {
return currentValue.fdef === fdecl ? previousValue + 1 : previousValue
}, 0)
: 0

if (fdecl && recursionCount > 0) {
return SymbolValue({
type: 'FunctionCall',
expression: fclos,
Expand Down