@@ -42,6 +42,8 @@ import {
4242 parseFilesWasmForBackfill ,
4343} from '../../../parser.js' ;
4444import { computeConfidence } from '../../resolve.js' ;
45+ import type { CallNodeLookup } from '../call-resolver.js' ;
46+ import { findCaller , resolveByMethodOrGlobal } from '../call-resolver.js' ;
4547import type { PipelineContext } from '../context.js' ;
4648import {
4749 batchInsertEdges ,
@@ -558,6 +560,199 @@ function runPostNativeCha(db: BetterSqlite3Database): Set<number> {
558560 return newTargetIds ;
559561}
560562
563+ /**
564+ * Post-pass: backfill prototype-based method definitions and their call edges.
565+ *
566+ * The Rust engine does not recognise `Foo.prototype.bar = function(){}` as a
567+ * method definition, so those nodes are absent from the DB after the native
568+ * orchestrator completes. This pass:
569+ * 1. Re-parses JS/TS files via WASM to obtain the full ExtractorOutput
570+ * (including definitions emitted by extractPrototypeMethodsWalk).
571+ * 2. Inserts any method nodes that are missing from the DB.
572+ * 3. Resolves call edges to those newly-inserted nodes using the WASM typeMap
573+ * and the existing DB node table as a lookup.
574+ */
575+ async function runPostNativePrototypeMethods (
576+ db : BetterSqlite3Database ,
577+ rootDir : string ,
578+ ) : Promise < void > {
579+ // Collect JS/TS file paths from the DB — only extensions where prototype
580+ // patterns can appear.
581+ const jsExts = new Set ( [ '.js' , '.mjs' , '.cjs' , '.ts' , '.tsx' ] ) ;
582+ const fileRows = db
583+ . prepare (
584+ `SELECT DISTINCT file FROM nodes WHERE kind = 'file' AND file IS NOT NULL ORDER BY file` ,
585+ )
586+ . all ( ) as Array < { file : string } > ;
587+
588+ const jsFiles = fileRows
589+ . map ( ( r ) => r . file )
590+ . filter ( ( f ) => jsExts . has ( path . extname ( f ) . toLowerCase ( ) ) ) ;
591+
592+ if ( jsFiles . length === 0 ) return ;
593+
594+ // Quick pre-filter: only re-parse files that actually contain prototype or
595+ // function-as-object-property patterns to avoid an expensive WASM re-parse of
596+ // every JS/TS file in large repos. Covers:
597+ // - `.prototype.` — classical prototype method assignment
598+ // - `\b\w+\.\w+\s*=\s*function` — function-as-object property (`f.g = function(){}`)
599+ const protoFiles = jsFiles . filter ( ( relPath ) => {
600+ try {
601+ const content = readFileSafe ( path . join ( rootDir , relPath ) ) ;
602+ return content . includes ( '.prototype.' ) || / \b \w + \. \w + \s * = \s * f u n c t i o n / . test ( content ) ;
603+ } catch {
604+ return false ;
605+ }
606+ } ) ;
607+
608+ if ( protoFiles . length === 0 ) return ;
609+
610+ // WASM-parse only the files that have prototype patterns to get full
611+ // ExtractorOutput including prototype method definitions and typeMap entries.
612+ const absPaths = protoFiles . map ( ( f ) => path . join ( rootDir , f ) ) ;
613+ let wasmResults : Map < string , ExtractorOutput > ;
614+ try {
615+ wasmResults = await parseFilesWasmForBackfill ( absPaths , rootDir ) ;
616+ } catch ( e ) {
617+ debug ( `runPostNativePrototypeMethods: WASM parse failed: ${ toErrorMessage ( e ) } ` ) ;
618+ return ;
619+ }
620+
621+ if ( wasmResults . size === 0 ) return ;
622+
623+ // Check which nodes already exist — INSERT OR IGNORE handles races but
624+ // we need the IDs of newly inserted rows, so we check first.
625+ const existsStmt = db . prepare (
626+ `SELECT id FROM nodes WHERE name = ? AND kind = 'method' AND file = ?` ,
627+ ) ;
628+
629+ // Insert rows: [name, kind, file, line, end_line, parent_id, qualified_name, scope, visibility]
630+ const newNodeRows : unknown [ ] [ ] = [ ] ;
631+ // Track which file+definition combos are new so we can insert edges for them.
632+ const newDefs : Array < { name : string ; file : string ; line : number } > = [ ] ;
633+
634+ for ( const [ relPath , symbols ] of wasmResults ) {
635+ for ( const def of symbols . definitions ?? [ ] ) {
636+ if ( def . kind !== 'method' ) continue ;
637+ const dotIdx = def . name . indexOf ( '.' ) ;
638+ if ( dotIdx === - 1 ) continue ; // skip bare method names (shouldn't happen, but guard)
639+
640+ // Only insert if the node is not already in the DB.
641+ const existing = existsStmt . get ( def . name , relPath ) as { id : number } | undefined ;
642+ if ( existing ) continue ;
643+
644+ const scope = def . name . slice ( 0 , dotIdx ) ;
645+ newNodeRows . push ( [
646+ def . name ,
647+ 'method' ,
648+ relPath ,
649+ def . line ,
650+ def . endLine ?? null ,
651+ null ,
652+ def . name ,
653+ scope ,
654+ null ,
655+ ] ) ;
656+ newDefs . push ( { name : def . name , file : relPath , line : def . line } ) ;
657+ }
658+ }
659+
660+ if ( newNodeRows . length === 0 ) return ;
661+
662+ db . transaction ( ( ) => batchInsertNodes ( db , newNodeRows ) ) ( ) ;
663+
664+ // Build a name → node lookup from all DB nodes (including newly inserted ones).
665+ type NodeEntry = { id : number ; file : string ; kind : string } ;
666+ const byNameMap = new Map < string , NodeEntry [ ] > ( ) ;
667+ const byNameFileMap = new Map < string , NodeEntry [ ] > ( ) ;
668+ const byIdKey = new Map < string , { id : number } > ( ) ;
669+
670+ const allNodes = db
671+ . prepare ( `SELECT id, name, kind, file, line FROM nodes WHERE kind != 'file'` )
672+ . all ( ) as Array < { id : number ; name : string ; kind : string ; file : string ; line : number } > ;
673+
674+ for ( const n of allNodes ) {
675+ const list = byNameMap . get ( n . name ) ;
676+ if ( list ) list . push ( n ) ;
677+ else byNameMap . set ( n . name , [ n ] ) ;
678+
679+ const fk = `${ n . name } ::${ n . file } ` ;
680+ const flist = byNameFileMap . get ( fk ) ;
681+ if ( flist ) flist . push ( n ) ;
682+ else byNameFileMap . set ( fk , [ n ] ) ;
683+
684+ byIdKey . set ( `${ n . name } |${ n . kind } |${ n . file } |${ n . line } ` , { id : n . id } ) ;
685+ }
686+
687+ const lookup : CallNodeLookup = {
688+ byName : ( name ) => byNameMap . get ( name ) ?? [ ] ,
689+ byNameAndFile : ( name , file ) => byNameFileMap . get ( `${ name } ::${ file } ` ) ?? [ ] ,
690+ isBarrel : ( ) => false ,
691+ resolveBarrel : ( ) => null ,
692+ nodeId : ( name , kind , file , line ) => byIdKey . get ( `${ name } |${ kind } |${ file } |${ line } ` ) ,
693+ } ;
694+
695+ // Build a fast set of newly-inserted node IDs so we only emit edges to
696+ // prototype nodes that the Rust engine missed (avoids duplicating existing edges).
697+ const newNodeIds = new Set < number > (
698+ newDefs . flatMap ( ( d ) => {
699+ const nodes = byNameFileMap . get ( `${ d . name } ::${ d . file } ` ) ;
700+ return nodes ? nodes . map ( ( n ) => n . id ) : [ ] ;
701+ } ) ,
702+ ) ;
703+
704+ // seenByPair deduplicates edges we emit within this function only.
705+ // No pre-existing edge can target a newly-inserted node ID (SQLite
706+ // auto-increment guarantees the new IDs are unique), so there is no need
707+ // to seed this set from the DB — doing so would load O(|edges|) data for
708+ // zero benefit and could OOM on large repositories.
709+ const seenByPair = new Set < string > ( ) ;
710+
711+ // Resolve call edges in every file — not just those that define new prototype
712+ // methods. A caller in app.js calling a prototype method defined in lib.js
713+ // would be silently missed if we only scanned definition files.
714+ // The newNodeIds guard inside the loop already prevents duplicate edges.
715+ const newEdgeRows : unknown [ ] [ ] = [ ] ;
716+ const fileNodeStmt = db . prepare ( `SELECT id FROM nodes WHERE kind = 'file' AND file = ?` ) ;
717+
718+ for ( const [ relPath , symbols ] of wasmResults ) {
719+ const fileNodeRow = fileNodeStmt . get ( relPath ) as { id : number } | undefined ;
720+ if ( ! fileNodeRow ) continue ;
721+
722+ const typeMap = symbols . typeMap instanceof Map ? symbols . typeMap : new Map ( ) ;
723+
724+ for ( const call of symbols . calls ?? [ ] ) {
725+ if ( ! call . receiver ) continue ; // prototype resolution only applies to receiver calls
726+
727+ const caller = findCaller ( lookup , call , symbols . definitions ?? [ ] , relPath , fileNodeRow ) ;
728+
729+ const targets = resolveByMethodOrGlobal (
730+ lookup ,
731+ call ,
732+ relPath ,
733+ typeMap as Map < string , unknown > ,
734+ caller . callerName ,
735+ ) ;
736+
737+ for ( const t of targets ) {
738+ // Only emit edges to newly-inserted prototype nodes to avoid
739+ // duplicating edges the Rust engine already built.
740+ if ( ! newNodeIds . has ( t . id ) ) continue ;
741+ const key = `${ caller . id } |${ t . id } ` ;
742+ if ( seenByPair . has ( key ) ) continue ;
743+ seenByPair . add ( key ) ;
744+ const conf = computeConfidence ( relPath , t . file , null ) ;
745+ if ( conf <= 0 ) continue ;
746+ newEdgeRows . push ( [ caller . id , t . id , 'calls' , conf , 0 , 'receiver-typed' ] ) ;
747+ }
748+ }
749+ }
750+
751+ if ( newEdgeRows . length > 0 ) {
752+ db . transaction ( ( ) => batchInsertEdges ( db , newEdgeRows ) ) ( ) ;
753+ }
754+ }
755+
561756/** Format timing result from native orchestrator phases + JS post-processing. */
562757function formatNativeTimingResult (
563758 p : Record < string , number > ,
@@ -1195,6 +1390,15 @@ export async function tryNativeOrchestrator(
11951390 }
11961391 }
11971392
1393+ // Prototype method post-pass: the Rust engine does not recognise pre-ES6
1394+ // `Foo.prototype.bar = function(){}` patterns. Re-parse JS/TS files via
1395+ // WASM to insert missing method nodes and their call edges.
1396+ try {
1397+ await runPostNativePrototypeMethods ( ctx . db as unknown as BetterSqlite3Database , ctx . rootDir ) ;
1398+ } catch ( err ) {
1399+ debug ( `Prototype methods post-pass failed: ${ toErrorMessage ( err ) } ` ) ;
1400+ }
1401+
11981402 // Backfill the `technique` column on `calls` edges written by the Rust
11991403 // orchestrator, which does not write the column. Runs after all edge-writing
12001404 // phases (including the WASM dropped-language backfill and CHA post-pass) so
0 commit comments