@@ -14,7 +14,7 @@ interface AstStoreRow {
1414 kind : string ;
1515 name : string | null | undefined ;
1616 text : string | null ;
17- receiver : null ;
17+ receiver : string | null ;
1818 parentNodeId : number | null ;
1919}
2020
@@ -44,6 +44,22 @@ function extractExpressionText(node: TreeSitterNode): string | null {
4444 return truncate ( node . text ) ;
4545}
4646
47+ function extractCallName ( node : TreeSitterNode ) : string {
48+ for ( const field of [ 'function' , 'method' , 'name' ] ) {
49+ const fn = node . childForFieldName ( field ) ;
50+ if ( fn ) return fn . text ;
51+ }
52+ return node . text ?. split ( '(' ) [ 0 ] || '?' ;
53+ }
54+
55+ /** Extract receiver for call expressions (e.g. "obj" in "obj.method()"). */
56+ function extractCallReceiver ( node : TreeSitterNode ) : string | null {
57+ const fn = node . childForFieldName ( 'function' ) ;
58+ if ( ! fn || fn . type !== 'member_expression' ) return null ;
59+ const obj = fn . childForFieldName ( 'object' ) ;
60+ return obj ? obj . text : null ;
61+ }
62+
4763function extractName ( kind : string , node : TreeSitterNode ) : string | null {
4864 if ( kind === 'throw' ) {
4965 for ( let i = 0 ; i < node . childCount ; i ++ ) {
@@ -82,6 +98,7 @@ export function createAstStoreVisitor(
8298 nodeIdMap : Map < string , number > ,
8399) : Visitor {
84100 const rows : AstStoreRow [ ] = [ ] ;
101+ const matched = new Set < number > ( ) ;
85102
86103 function findParentDef ( line : number ) : Definition | null {
87104 let best : Definition | null = null ;
@@ -101,45 +118,115 @@ export function createAstStoreVisitor(
101118 return nodeIdMap . get ( `${ parentDef . name } |${ parentDef . kind } |${ parentDef . line } ` ) || null ;
102119 }
103120
121+ /** Recursively walk a subtree collecting AST nodes — used for arguments-only traversal. */
122+ function walkSubtree ( node : TreeSitterNode | null ) : void {
123+ if ( ! node ) return ;
124+ if ( matched . has ( node . id ) ) return ;
125+
126+ const kind = astTypeMap [ node . type ] ;
127+ if ( kind === 'call' ) {
128+ // Capture this call and recurse only into its arguments
129+ collectNode ( node , kind ) ;
130+ walkCallArguments ( node ) ;
131+ return ;
132+ }
133+ if ( kind ) {
134+ collectNode ( node , kind ) ;
135+ if ( kind !== 'string' && kind !== 'regex' ) return ; // skipChildren for non-leaf kinds
136+ }
137+ for ( let i = 0 ; i < node . childCount ; i ++ ) {
138+ walkSubtree ( node . child ( i ) ) ;
139+ }
140+ }
141+
142+ /**
143+ * Recurse into only the arguments of a call node — mirrors the native engine's
144+ * strategy that prevents double-counting nested calls in the function field
145+ * (e.g. chained calls like `a().b()`).
146+ */
147+ function walkCallArguments ( callNode : TreeSitterNode ) : void {
148+ // Try field-based lookup first, fall back to kind-based matching
149+ const argsNode =
150+ callNode . childForFieldName ( 'arguments' ) ??
151+ findChildByKind ( callNode , [ 'arguments' , 'argument_list' , 'method_arguments' ] ) ;
152+ if ( ! argsNode ) return ;
153+ for ( let i = 0 ; i < argsNode . childCount ; i ++ ) {
154+ walkSubtree ( argsNode . child ( i ) ) ;
155+ }
156+ }
157+
158+ function findChildByKind ( node : TreeSitterNode , kinds : string [ ] ) : TreeSitterNode | null {
159+ for ( let i = 0 ; i < node . childCount ; i ++ ) {
160+ const child = node . child ( i ) ;
161+ if ( child && kinds . includes ( child . type ) ) return child ;
162+ }
163+ return null ;
164+ }
165+
166+ function collectNode ( node : TreeSitterNode , kind : string ) : void {
167+ if ( matched . has ( node . id ) ) return ;
168+
169+ const line = node . startPosition . row + 1 ;
170+ let name : string | null | undefined ;
171+ let text : string | null = null ;
172+ let receiver : string | null = null ;
173+
174+ if ( kind === 'call' ) {
175+ name = extractCallName ( node ) ;
176+ text = truncate ( node . text ) ;
177+ receiver = extractCallReceiver ( node ) ;
178+ } else if ( kind === 'new' ) {
179+ name = extractNewName ( node ) ;
180+ text = truncate ( node . text ) ;
181+ } else if ( kind === 'throw' ) {
182+ name = extractName ( 'throw' , node ) ;
183+ text = extractExpressionText ( node ) ;
184+ } else if ( kind === 'await' ) {
185+ name = extractName ( 'await' , node ) ;
186+ text = extractExpressionText ( node ) ;
187+ } else if ( kind === 'string' ) {
188+ const content = node . text ?. replace ( / ^ [ ' " ` ] | [ ' " ` ] $ / g, '' ) || '' ;
189+ if ( content . length < 2 ) return ;
190+ name = truncate ( content , 100 ) ;
191+ text = truncate ( node . text ) ;
192+ } else if ( kind === 'regex' ) {
193+ name = node . text || '?' ;
194+ text = truncate ( node . text ) ;
195+ }
196+
197+ rows . push ( {
198+ file : relPath ,
199+ line,
200+ kind,
201+ name,
202+ text,
203+ receiver,
204+ parentNodeId : resolveParentNodeId ( line ) ,
205+ } ) ;
206+
207+ matched . add ( node . id ) ;
208+ }
209+
104210 return {
105211 name : 'ast-store' ,
106212
107213 enterNode ( node : TreeSitterNode , _context : VisitorContext ) : EnterNodeResult | undefined {
214+ // Guard: skip re-collection but do NOT skipChildren — node.id (memory address)
215+ // can be reused by tree-sitter, so a collision would incorrectly suppress an
216+ // unrelated subtree. The parent call's skipChildren handles the intended case.
217+ if ( matched . has ( node . id ) ) return ;
218+
108219 const kind = astTypeMap [ node . type ] ;
109220 if ( ! kind ) return ;
110221
111- const line = node . startPosition . row + 1 ;
112- let name : string | null | undefined ;
113- let text : string | null = null ;
114-
115- if ( kind === 'new' ) {
116- name = extractNewName ( node ) ;
117- text = truncate ( node . text ) ;
118- } else if ( kind === 'throw' ) {
119- name = extractName ( 'throw' , node ) ;
120- text = extractExpressionText ( node ) ;
121- } else if ( kind === 'await' ) {
122- name = extractName ( 'await' , node ) ;
123- text = extractExpressionText ( node ) ;
124- } else if ( kind === 'string' ) {
125- const content = node . text ?. replace ( / ^ [ ' " ` ] | [ ' " ` ] $ / g, '' ) || '' ;
126- if ( content . length < 2 ) return ;
127- name = truncate ( content , 100 ) ;
128- text = truncate ( node . text ) ;
129- } else if ( kind === 'regex' ) {
130- name = node . text || '?' ;
131- text = truncate ( node . text ) ;
132- }
222+ collectNode ( node , kind ) ;
133223
134- rows . push ( {
135- file : relPath ,
136- line,
137- kind,
138- name,
139- text,
140- receiver : null ,
141- parentNodeId : resolveParentNodeId ( line ) ,
142- } ) ;
224+ if ( kind === 'call' ) {
225+ // Mirror native: skip full subtree, recurse only into arguments.
226+ // Prevents double-counting chained calls like service.getUser().getName().
227+ walkCallArguments ( node ) ;
228+ return { skipChildren : true } ;
229+ }
143230
144231 if ( kind !== 'string' && kind !== 'regex' ) {
145232 return { skipChildren : true } ;
0 commit comments