@@ -13,6 +13,7 @@ import {
1313 NodeKind ,
1414 EdgeKind ,
1515 Language ,
16+ NodeLookupFilters ,
1617 GraphStats ,
1718 SearchOptions ,
1819 SearchResult ,
@@ -49,6 +50,10 @@ function isLowValueFile(filePath: string): boolean {
4950
5051const SQLITE_PARAM_CHUNK_SIZE = 500 ;
5152
53+ function escapeLikePattern ( value : string ) : string {
54+ return value . replace ( / [ \\ % _ ] / g, ( ch ) => `\\${ ch } ` ) ;
55+ }
56+
5257/**
5358 * Database row types (snake_case from SQLite)
5459 */
@@ -210,9 +215,11 @@ export class QueryBuilder {
210215 deleteUnresolvedByNode ?: SqliteStatement ;
211216 getUnresolvedByName ?: SqliteStatement ;
212217 getNodesByName ?: SqliteStatement ;
218+ getNodeNameCount ?: SqliteStatement ;
213219 hasNodeName ?: SqliteStatement ;
214220 getNodesByQualifiedNameExact ?: SqliteStatement ;
215221 getNodesByLowerName ?: SqliteStatement ;
222+ getLowerNodeNameCount ?: SqliteStatement ;
216223 getUnresolvedCount ?: SqliteStatement ;
217224 getUnresolvedBatch ?: SqliteStatement ;
218225 getAllFilePaths ?: SqliteStatement ;
@@ -763,6 +770,109 @@ export class QueryBuilder {
763770 return rows . map ( rowToNode ) ;
764771 }
765772
773+ /**
774+ * Count exact-name candidates without materializing them. Resolver-internal
775+ * guardrails use this before legacy unfiltered name lookups.
776+ */
777+ getNodeNameCount ( name : string ) : number {
778+ if ( ! this . stmts . getNodeNameCount ) {
779+ this . stmts . getNodeNameCount = this . db . prepare ( 'SELECT COUNT(*) AS count FROM nodes WHERE name = ?' ) ;
780+ }
781+ const row = this . stmts . getNodeNameCount . get ( name ) as { count : number } ;
782+ return row . count ;
783+ }
784+
785+ /**
786+ * Get nodes by exact name with resolver-side filters applied in SQL.
787+ *
788+ * This is intentionally separate from public `getNodesByName()`, which must
789+ * keep returning the full set. Resolution often asks for common names like
790+ * `main`, `default`, or `clone`; on large repos those names can have tens of
791+ * thousands of rows, so filtering after `.all()` needlessly explodes the JS
792+ * heap. Push the obvious language/kind/file/qualified-name predicates into
793+ * SQLite and cap low-confidence global fallbacks.
794+ */
795+ getNodesByNameFiltered ( name : string , filters : NodeLookupFilters = { } ) : Node [ ] {
796+ const where : string [ ] = [ 'name = ?' ] ;
797+ const params : unknown [ ] = [ name ] ;
798+
799+ const languages = filters . languages ?. length
800+ ? [ ...new Set ( filters . languages ) ]
801+ : filters . language
802+ ? [ filters . language ]
803+ : [ ] ;
804+ if ( languages . length === 1 ) {
805+ where . push ( 'language = ?' ) ;
806+ params . push ( languages [ 0 ] ) ;
807+ } else if ( languages . length > 1 ) {
808+ where . push ( `language IN (${ languages . map ( ( ) => '?' ) . join ( ',' ) } )` ) ;
809+ params . push ( ...languages ) ;
810+ }
811+
812+ const kinds = filters . kinds ?. length ? [ ...new Set ( filters . kinds ) ] : [ ] ;
813+ if ( kinds . length === 1 ) {
814+ where . push ( 'kind = ?' ) ;
815+ params . push ( kinds [ 0 ] ) ;
816+ } else if ( kinds . length > 1 ) {
817+ where . push ( `kind IN (${ kinds . map ( ( ) => '?' ) . join ( ',' ) } )` ) ;
818+ params . push ( ...kinds ) ;
819+ }
820+
821+ if ( filters . filePath ) {
822+ where . push ( 'file_path = ?' ) ;
823+ params . push ( filters . filePath ) ;
824+ }
825+ if ( filters . filePathPrefix ) {
826+ where . push ( "file_path LIKE ? ESCAPE '\\'" ) ;
827+ params . push ( `${ escapeLikePattern ( filters . filePathPrefix ) } %` ) ;
828+ }
829+ if ( filters . filePathSuffix ) {
830+ where . push ( "file_path LIKE ? ESCAPE '\\'" ) ;
831+ params . push ( `%${ escapeLikePattern ( filters . filePathSuffix ) } ` ) ;
832+ }
833+ if ( filters . qualifiedName ) {
834+ where . push ( 'qualified_name = ?' ) ;
835+ params . push ( filters . qualifiedName ) ;
836+ }
837+ if ( filters . qualifiedNameSuffix ) {
838+ where . push ( "qualified_name LIKE ? ESCAPE '\\'" ) ;
839+ params . push ( `%${ escapeLikePattern ( filters . qualifiedNameSuffix ) } ` ) ;
840+ }
841+ if ( filters . hasReturnType ) {
842+ where . push ( "return_type IS NOT NULL AND return_type <> ''" ) ;
843+ }
844+ if ( filters . excludeId ) {
845+ where . push ( 'id <> ?' ) ;
846+ params . push ( filters . excludeId ) ;
847+ }
848+
849+ const orderBy : string [ ] = [ ] ;
850+ if ( filters . rankFilePath ) {
851+ orderBy . push ( 'CASE WHEN file_path = ? THEN 0 ELSE 1 END' ) ;
852+ params . push ( filters . rankFilePath ) ;
853+ }
854+ if ( filters . rankFilePathPrefix ) {
855+ orderBy . push ( "CASE WHEN file_path LIKE ? ESCAPE '\\' THEN 0 ELSE 1 END" ) ;
856+ params . push ( `${ escapeLikePattern ( filters . rankFilePathPrefix ) } %` ) ;
857+ }
858+ orderBy . push ( 'file_path' , 'start_line' , 'id' ) ;
859+
860+ let sql = `
861+ SELECT * FROM nodes
862+ WHERE ${ where . join ( ' AND ' ) }
863+ ORDER BY ${ orderBy . join ( ', ' ) }
864+ ` ;
865+
866+ if ( filters . limit !== undefined ) {
867+ const limit = Math . max ( 1 , Math . floor ( filters . limit ) ) ;
868+ sql += ' LIMIT ?' ;
869+ params . push ( limit ) ;
870+ }
871+
872+ const rows = this . db . prepare ( sql ) . all ( ...params ) as NodeRow [ ] ;
873+ return rows . map ( rowToNode ) ;
874+ }
875+
766876 /**
767877 * True when at least one node has this exact name. Uses idx_nodes_name and
768878 * returns a single row instead of materializing the distinct symbol-name set.
@@ -820,6 +930,95 @@ export class QueryBuilder {
820930 return rows . map ( rowToNode ) ;
821931 }
822932
933+ /**
934+ * Count lowercase-name candidates without materializing them. Used as a
935+ * safety check before legacy unfiltered fuzzy lookups.
936+ */
937+ getLowerNodeNameCount ( lowerName : string ) : number {
938+ if ( ! this . stmts . getLowerNodeNameCount ) {
939+ this . stmts . getLowerNodeNameCount = this . db . prepare (
940+ 'SELECT COUNT(*) AS count FROM nodes WHERE lower(name) = ?'
941+ ) ;
942+ }
943+ const row = this . stmts . getLowerNodeNameCount . get ( lowerName ) as { count : number } ;
944+ return row . count ;
945+ }
946+
947+ /**
948+ * Lowercase-name lookup with the same SQL-side filters as
949+ * getNodesByNameFiltered(). Used by low-confidence fuzzy resolution, where a
950+ * high-fanout lowercase match should never materialize the whole candidate
951+ * set.
952+ */
953+ getNodesByLowerNameFiltered ( lowerName : string , filters : NodeLookupFilters = { } ) : Node [ ] {
954+ const where : string [ ] = [ 'lower(name) = ?' ] ;
955+ const params : unknown [ ] = [ lowerName ] ;
956+
957+ const languages = filters . languages ?. length
958+ ? [ ...new Set ( filters . languages ) ]
959+ : filters . language
960+ ? [ filters . language ]
961+ : [ ] ;
962+ if ( languages . length === 1 ) {
963+ where . push ( 'language = ?' ) ;
964+ params . push ( languages [ 0 ] ) ;
965+ } else if ( languages . length > 1 ) {
966+ where . push ( `language IN (${ languages . map ( ( ) => '?' ) . join ( ',' ) } )` ) ;
967+ params . push ( ...languages ) ;
968+ }
969+
970+ const kinds = filters . kinds ?. length ? [ ...new Set ( filters . kinds ) ] : [ ] ;
971+ if ( kinds . length === 1 ) {
972+ where . push ( 'kind = ?' ) ;
973+ params . push ( kinds [ 0 ] ) ;
974+ } else if ( kinds . length > 1 ) {
975+ where . push ( `kind IN (${ kinds . map ( ( ) => '?' ) . join ( ',' ) } )` ) ;
976+ params . push ( ...kinds ) ;
977+ }
978+
979+ if ( filters . filePath ) {
980+ where . push ( 'file_path = ?' ) ;
981+ params . push ( filters . filePath ) ;
982+ }
983+ if ( filters . filePathPrefix ) {
984+ where . push ( "file_path LIKE ? ESCAPE '\\'" ) ;
985+ params . push ( `${ escapeLikePattern ( filters . filePathPrefix ) } %` ) ;
986+ }
987+ if ( filters . filePathSuffix ) {
988+ where . push ( "file_path LIKE ? ESCAPE '\\'" ) ;
989+ params . push ( `%${ escapeLikePattern ( filters . filePathSuffix ) } ` ) ;
990+ }
991+ if ( filters . excludeId ) {
992+ where . push ( 'id <> ?' ) ;
993+ params . push ( filters . excludeId ) ;
994+ }
995+
996+ const orderBy : string [ ] = [ ] ;
997+ if ( filters . rankFilePath ) {
998+ orderBy . push ( 'CASE WHEN file_path = ? THEN 0 ELSE 1 END' ) ;
999+ params . push ( filters . rankFilePath ) ;
1000+ }
1001+ if ( filters . rankFilePathPrefix ) {
1002+ orderBy . push ( "CASE WHEN file_path LIKE ? ESCAPE '\\' THEN 0 ELSE 1 END" ) ;
1003+ params . push ( `${ escapeLikePattern ( filters . rankFilePathPrefix ) } %` ) ;
1004+ }
1005+ orderBy . push ( 'file_path' , 'start_line' , 'id' ) ;
1006+
1007+ let sql = `
1008+ SELECT * FROM nodes
1009+ WHERE ${ where . join ( ' AND ' ) }
1010+ ORDER BY ${ orderBy . join ( ', ' ) }
1011+ ` ;
1012+ if ( filters . limit !== undefined ) {
1013+ const limit = Math . max ( 1 , Math . floor ( filters . limit ) ) ;
1014+ sql += ' LIMIT ?' ;
1015+ params . push ( limit ) ;
1016+ }
1017+
1018+ const rows = this . db . prepare ( sql ) . all ( ...params ) as NodeRow [ ] ;
1019+ return rows . map ( rowToNode ) ;
1020+ }
1021+
8231022 /**
8241023 * Search nodes by name using FTS with fallback to LIKE for better matching
8251024 *
0 commit comments