Skip to content
134 changes: 82 additions & 52 deletions src/ast-analysis/visitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,24 +152,18 @@ function collectResults(visitors: Visitor[]): WalkResults {
return results;
}

/**
* Walk an AST root with multiple visitors in a single DFS pass.
*
* @param {object} rootNode - tree-sitter root node to walk
* @param {Visitor[]} visitors - array of visitor objects
* @param {string} langId - language identifier
* @param {object} [options]
* @param {Set} [options.functionNodeTypes] - set of node types that are function boundaries
* @param {Set} [options.nestingNodeTypes] - set of node types that increase nesting depth
* @param {function} [options.getFunctionName] - (funcNode) => string|null
* @returns {object} Map of visitor.name → finish() result
*/
export function walkWithVisitors(
rootNode: TreeSitterNode,
visitors: Visitor[],
langId: string,
options: WalkOptions = {},
): WalkResults {
interface WalkState {
visitors: Visitor[];
allFuncTypes: Set<string>;
nestingNodeTypes: Set<string>;
getFunctionName: (node: TreeSitterNode) => string | null;
context: VisitorContext;
scopeStack: ScopeEntry[];
skipDepths: Map<number, number>;
}

/** Build walk state from options: resolve function types, init visitors, create shared context. */
function buildWalkState(visitors: Visitor[], langId: string, options: WalkOptions): WalkState {
const {
functionNodeTypes = new Set<string>(),
nestingNodeTypes = new Set<string>(),
Expand All @@ -179,7 +173,6 @@ export function walkWithVisitors(
const allFuncTypes = mergeFunctionNodeTypes(visitors, functionNodeTypes);
initVisitors(visitors, langId);

// Shared context object (mutated during walk)
const scopeStack: ScopeEntry[] = [];
const context: VisitorContext = {
nestingLevel: 0,
Expand All @@ -188,49 +181,86 @@ export function walkWithVisitors(
scopeStack,
};

const skipDepths = new Map<number, number>();
return {
visitors,
allFuncTypes,
nestingNodeTypes,
getFunctionName,
context,
scopeStack,
skipDepths: new Map<number, number>(),
};
}

function walk(node: TreeSitterNode | null, depth: number): void {
if (!node) return;
if (depth > MAX_WALK_DEPTH) {
debug(`walkWithVisitors: AST depth limit (${MAX_WALK_DEPTH}) hit — subtree truncated`);
return;
}
/** Single DFS step: dispatch enter/exit hooks and recurse into children. */
function walkNode(state: WalkState, node: TreeSitterNode | null, depth: number): void {
if (!node) return;
if (depth > MAX_WALK_DEPTH) {
debug(`walkWithVisitors: AST depth limit (${MAX_WALK_DEPTH}) hit — subtree truncated`);
return;
}

const type = node.type;
const isFuncBoundary = allFuncTypes.has(type);
let funcName: string | null = null;
const {
visitors,
allFuncTypes,
nestingNodeTypes,
getFunctionName,
context,
scopeStack,
skipDepths,
} = state;
const type = node.type;
const isFuncBoundary = allFuncTypes.has(type);
let funcName: string | null = null;

if (isFuncBoundary) {
funcName = getFunctionName(node);
context.currentFunction = node;
scopeStack.push({ funcName, funcNode: node, params: new Map(), locals: new Map() });
dispatchEnterFunction(visitors, skipDepths, node, funcName, context, depth);
}
if (isFuncBoundary) {
funcName = getFunctionName(node);
context.currentFunction = node;
scopeStack.push({ funcName, funcNode: node, params: new Map(), locals: new Map() });
dispatchEnterFunction(visitors, skipDepths, node, funcName, context, depth);
}

dispatchEnterNode(visitors, skipDepths, node, context, depth);
dispatchEnterNode(visitors, skipDepths, node, context, depth);

const addsNesting = nestingNodeTypes.has(type);
if (addsNesting) context.nestingLevel++;
const addsNesting = nestingNodeTypes.has(type);
if (addsNesting) context.nestingLevel++;

for (let i = 0; i < node.childCount; i++) {
walk(node.child(i), depth + 1);
}
for (let i = 0; i < node.childCount; i++) {
walkNode(state, node.child(i), depth + 1);
}

if (addsNesting) context.nestingLevel--;
if (addsNesting) context.nestingLevel--;

dispatchExitNode(visitors, skipDepths, node, context, depth);
clearSkipFlags(skipDepths, visitors.length, depth);
dispatchExitNode(visitors, skipDepths, node, context, depth);
clearSkipFlags(skipDepths, visitors.length, depth);

if (isFuncBoundary) {
dispatchExitFunction(visitors, skipDepths, node, funcName, context, depth);
scopeStack.pop();
context.currentFunction =
scopeStack.length > 0 ? scopeStack[scopeStack.length - 1]!.funcNode : null;
}
if (isFuncBoundary) {
dispatchExitFunction(visitors, skipDepths, node, funcName, context, depth);
scopeStack.pop();
context.currentFunction =
scopeStack.length > 0 ? scopeStack[scopeStack.length - 1]!.funcNode : null;
}
}

walk(rootNode, 0);

/**
* Walk an AST root with multiple visitors in a single DFS pass.
*
* @param {object} rootNode - tree-sitter root node to walk
* @param {Visitor[]} visitors - array of visitor objects
* @param {string} langId - language identifier
* @param {object} [options]
* @param {Set} [options.functionNodeTypes] - set of node types that are function boundaries
* @param {Set} [options.nestingNodeTypes] - set of node types that increase nesting depth
* @param {function} [options.getFunctionName] - (funcNode) => string|null
* @returns {object} Map of visitor.name → finish() result
*/
export function walkWithVisitors(
rootNode: TreeSitterNode,
visitors: Visitor[],
langId: string,
options: WalkOptions = {},
): WalkResults {
const state = buildWalkState(visitors, langId, options);
walkNode(state, rootNode, 0);
return collectResults(visitors);
}
60 changes: 44 additions & 16 deletions src/ast-analysis/visitors/complexity-visitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,43 @@ function classifyNode(
if (cRules.caseNodes.has(type) && node.childCount > 0) acc.cyclomatic++;
}

/**
* Compute the effective nesting level for complexity classification.
*
* In file-level mode, funcDepth starts at 0 for the active function.
* In function-level mode, funcDepth starts at 1 for the root function
* (since enterFunction always increments it). Subtract 1 so the root
* function contributes 0 nesting and each nested level adds +1, matching
* the Rust engine's behavior.
*/
function computeEffectiveNesting(
contextNesting: number,
funcDepth: number,
nestingAdjust: number,
fileLevelWalk: boolean,
): number {
const funcNesting = fileLevelWalk ? funcDepth : Math.max(0, funcDepth - 1);
return contextNesting + funcNesting - nestingAdjust;
}

/**
* If this node is an else-if that the walker treats as a nesting node but
* the DFS engine would NOT increment nesting for, track it so children see
* the correct (non-inflated) nesting level.
*/
function trackElseIfNestingAdjust(
node: TreeSitterNode,
cRules: AnyRules,
nestingAdjust: number,
adjustNodeIds: Set<number>,
): number {
if (cRules.nestingNodes.has(node.type) && isElseIfNonNesting(node, node.type, cRules)) {
adjustNodeIds.add(node.id);
return nestingAdjust + 1;
}
return nestingAdjust;
}

export function createComplexityVisitor(
cRules: AnyRules,
hRules?: AnyRules | null,
Expand Down Expand Up @@ -265,23 +302,14 @@ export function createComplexityVisitor(
enterNode(node: TreeSitterNode, context: VisitorContext): EnterNodeResult | undefined {
if (fileLevelWalk && !activeFuncNode) return;

// In file-level mode, funcDepth starts at 0 for the active function.
// In function-level mode, funcDepth starts at 1 for the root function
// (since enterFunction always increments it). Nested functions add +1
// each level — subtract 1 so the root function contributes 0 nesting
// and each nested level adds +1, matching the Rust engine's behavior.
const funcNesting = fileLevelWalk ? funcDepth : Math.max(0, funcDepth - 1);
const nestingLevel = context.nestingLevel + funcNesting - nestingAdjust;
const nestingLevel = computeEffectiveNesting(
context.nestingLevel,
funcDepth,
nestingAdjust,
fileLevelWalk,
);
classifyNode(node, nestingLevel, cRules, hRules, acc);

// If this is an else-if if_statement that the walker will treat as a
// nesting node (incrementing context.nestingLevel for children), but
// the DFS walk would NOT increment nesting for, compensate by bumping
// nestingAdjust so children see the correct level.
if (cRules.nestingNodes.has(node.type) && isElseIfNonNesting(node, node.type, cRules)) {
nestingAdjust++;
adjustNodeIds.add(node.id);
}
nestingAdjust = trackElseIfNestingAdjust(node, cRules, nestingAdjust, adjustNodeIds);
},

exitNode(node: TreeSitterNode): void {
Expand Down
69 changes: 45 additions & 24 deletions src/ast-analysis/visitors/dataflow-visitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,28 @@ function handleAssignment(
}
}

/** Unwrap argument wrapper and spread nodes to get the core argument node. */
function unwrapArg(
arg: TreeSitterNode,
rules: AnyRules,
): { unwrapped: TreeSitterNode; raw: TreeSitterNode } {
let raw = arg;
if (rules.argumentWrapperType && arg.type === rules.argumentWrapperType) {
raw = arg.namedChildren[0] || arg;
}
const unwrapped =
rules.spreadType && raw.type === rules.spreadType ? raw.namedChildren[0] || raw : raw;
return { unwrapped, raw };
}

/** Resolve the tracked name for a call argument (identifier or member receiver). */
function resolveArgTrackedName(node: TreeSitterNode, rules: AnyRules): string | null {
const argName = isIdent(node.type, rules) ? node.text : null;
const argMember =
rules.memberNode && node.type === rules.memberNode ? memberReceiver(node, rules) : null;
return argName || argMember;
}

function handleCallExpr(
node: TreeSitterNode,
rules: AnyRules,
Expand All @@ -299,24 +321,14 @@ function handleCallExpr(
if (!callee || !argsNode || !scope?.funcName) return;

let argIndex = 0;
for (let arg of argsNode.namedChildren) {
if (rules.argumentWrapperType && arg.type === rules.argumentWrapperType) {
arg = arg.namedChildren[0] || arg;
}
const unwrapped =
rules.spreadType && arg.type === rules.spreadType ? arg.namedChildren[0] || arg : arg;
for (const arg of argsNode.namedChildren) {
const { unwrapped, raw } = unwrapArg(arg, rules);
if (!unwrapped) {
argIndex++;
continue;
}

const argName = isIdent(unwrapped.type, rules) ? unwrapped.text : null;
const argMember =
rules.memberNode && unwrapped.type === rules.memberNode
? memberReceiver(unwrapped, rules)
: null;
const trackedName = argName || argMember;

const trackedName = resolveArgTrackedName(unwrapped, rules);
if (trackedName) {
const binding = findBinding(trackedName, scopeStack);
if (binding) {
Expand All @@ -327,7 +339,7 @@ function handleCallExpr(
argName: trackedName,
binding,
confidence: bindingConfidence(binding),
expression: truncate(arg.text),
expression: truncate(raw.text),
line: node.startPosition.row + 1,
});
}
Expand All @@ -336,17 +348,11 @@ function handleCallExpr(
}
}

function handleExprStmtMutation(
node: TreeSitterNode,
/** Resolve the method name and receiver from a call expression node. */
function resolveMutationCallParts(
expr: TreeSitterNode,
rules: AnyRules,
scopeStack: ScopeEntry[],
mutations: DataflowMutation[],
isCallNode: (t: string) => boolean,
): void {
if (rules.mutatingMethods.size === 0) return;
const expr = node.namedChildren[0];
if (!expr || !isCall(expr, isCallNode)) return;

): { methodName: string | null; receiver: string | null } {
let methodName: string | null = null;
let receiver: string | null = null;

Expand All @@ -366,6 +372,21 @@ function handleExprStmtMutation(
}
}

return { methodName, receiver };
}

function handleExprStmtMutation(
node: TreeSitterNode,
rules: AnyRules,
scopeStack: ScopeEntry[],
mutations: DataflowMutation[],
isCallNode: (t: string) => boolean,
): void {
if (rules.mutatingMethods.size === 0) return;
const expr = node.namedChildren[0];
if (!expr || !isCall(expr, isCallNode)) return;

const { methodName, receiver } = resolveMutationCallParts(expr, rules);
if (!methodName || !rules.mutatingMethods.has(methodName)) return;

const scope = currentScope(scopeStack);
Expand Down
Loading
Loading