@@ -44,6 +44,8 @@ import {
4444import { computeConfidence } from '../../resolve.js' ;
4545import type { CallNodeLookup } from '../call-resolver.js' ;
4646import { findCaller , resolveByMethodOrGlobal } from '../call-resolver.js' ;
47+ import type { ChaContext } from '../cha.js' ;
48+ import { resolveThisDispatch } from '../cha.js' ;
4749import type { PipelineContext } from '../context.js' ;
4850import {
4951 batchInsertEdges ,
@@ -397,9 +399,8 @@ async function runPostNativeAnalysis(
397399 * each call to an interface/abstract method to ALL RTA-filtered concrete
398400 * implementations.
399401 *
400- * Note: `this`/`super` dispatch requires the raw unresolved call sites which are
401- * not persisted to the DB by the Rust pipeline. That case is handled by the WASM
402- * path (`buildFileCallEdges`) and is a known gap for the native orchestrator.
402+ * Note: `this`/`super` dispatch is handled separately by `runPostNativeThisDispatch`,
403+ * which WASM-re-parses JS/TS files to obtain raw call site receiver info.
403404 *
404405 * Returns the set of target node IDs for newly inserted CHA edges so the caller
405406 * can re-classify roles for the affected implementation files. An empty set
@@ -561,16 +562,19 @@ function runPostNativeCha(db: BetterSqlite3Database): Set<number> {
561562}
562563
563564/**
564- * Post-pass: backfill prototype-based method definitions and their call edges.
565+ * Post-pass: backfill function-as-object-property method definitions and their call edges.
565566 *
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
567+ * The Rust engine does not recognise `fn.method = function(){}` patterns as
568+ * method definitions , so those nodes are absent from the DB after the native
568569 * orchestrator completes. This pass:
569570 * 1. Re-parses JS/TS files via WASM to obtain the full ExtractorOutput
570- * (including definitions emitted by extractPrototypeMethodsWalk ).
571+ * (including definitions emitted by extractFuncPropMethodsWalk ).
571572 * 2. Inserts any method nodes that are missing from the DB.
572573 * 3. Resolves call edges to those newly-inserted nodes using the WASM typeMap
573574 * and the existing DB node table as a lookup.
575+ *
576+ * Note: `Foo.prototype.bar = function(){}` patterns are handled natively by
577+ * the Rust extractor and do not need a WASM re-parse here.
574578 */
575579async function runPostNativePrototypeMethods (
576580 db : BetterSqlite3Database ,
@@ -606,8 +610,8 @@ async function runPostNativePrototypeMethods(
606610
607611 if ( protoFiles . length === 0 ) return ;
608612
609- // WASM-parse only the files that have prototype patterns to get full
610- // ExtractorOutput including prototype method definitions and typeMap entries.
613+ // WASM-parse only the files that have func-prop patterns to get full
614+ // ExtractorOutput including method definitions and typeMap entries.
611615 const absPaths = protoFiles . map ( ( f ) => path . join ( rootDir , f ) ) ;
612616 let wasmResults : Map < string , ExtractorOutput > ;
613617 try {
@@ -692,7 +696,7 @@ async function runPostNativePrototypeMethods(
692696 } ;
693697
694698 // Build a fast set of newly-inserted node IDs so we only emit edges to
695- // prototype nodes that the Rust engine missed (avoids duplicating existing edges).
699+ // func-prop nodes that the Rust engine missed (avoids duplicating existing edges).
696700 const newNodeIds = new Set < number > (
697701 newDefs . flatMap ( ( d ) => {
698702 const nodes = byNameFileMap . get ( `${ d . name } ::${ d . file } ` ) ;
@@ -707,8 +711,8 @@ async function runPostNativePrototypeMethods(
707711 // zero benefit and could OOM on large repositories.
708712 const seenByPair = new Set < string > ( ) ;
709713
710- // Resolve call edges in every file — not just those that define new prototype
711- // methods. A caller in app.js calling a prototype method defined in lib.js
714+ // Resolve call edges in every file — not just those that define new func-prop
715+ // methods. A caller in app.js calling a method defined in lib.js
712716 // would be silently missed if we only scanned definition files.
713717 // The newNodeIds guard inside the loop already prevents duplicate edges.
714718 const newEdgeRows : unknown [ ] [ ] = [ ] ;
@@ -721,7 +725,7 @@ async function runPostNativePrototypeMethods(
721725 const typeMap = symbols . typeMap instanceof Map ? symbols . typeMap : new Map ( ) ;
722726
723727 for ( const call of symbols . calls ?? [ ] ) {
724- if ( ! call . receiver ) continue ; // prototype resolution only applies to receiver calls
728+ if ( ! call . receiver ) continue ; // receiver calls only
725729
726730 const caller = findCaller ( lookup , call , symbols . definitions ?? [ ] , relPath , fileNodeRow ) ;
727731
@@ -734,7 +738,7 @@ async function runPostNativePrototypeMethods(
734738 ) ;
735739
736740 for ( const t of targets ) {
737- // Only emit edges to newly-inserted prototype nodes to avoid
741+ // Only emit edges to newly-inserted func-prop nodes to avoid
738742 // duplicating edges the Rust engine already built.
739743 if ( ! newNodeIds . has ( t . id ) ) continue ;
740744 const key = `${ caller . id } |${ t . id } ` ;
@@ -752,11 +756,204 @@ async function runPostNativePrototypeMethods(
752756 }
753757}
754758
759+ // Extensions where `this`/`super` dispatch can occur (JS/TS family)
760+ const THIS_DISPATCH_EXTS = new Set ( [ '.js' , '.ts' , '.tsx' , '.jsx' , '.mjs' , '.cjs' , '.mts' , '.cts' ] ) ;
761+
762+ /**
763+ * Phase 8.5: this/super dispatch post-pass for the native orchestrator path.
764+ *
765+ * The Rust build pipeline resolves typed receiver calls but does NOT persist raw
766+ * unresolved call site receiver info (e.g. `this`, `super`) to the DB. This
767+ * hybrid post-pass re-parses JS/TS/TSX files via WASM to collect call sites with
768+ * `this`/`super` receivers, then resolves them through the class hierarchy stored
769+ * in DB `extends` edges — mirroring what `buildChaPostPass` does on the WASM path.
770+ *
771+ * Only runs when `extends` edges exist in the DB; if there is no inheritance
772+ * hierarchy there is nothing to resolve via `this`/`super` dispatch.
773+ */
774+ async function runPostNativeThisDispatch (
775+ db : BetterSqlite3Database ,
776+ rootDir : string ,
777+ changedFiles : string [ ] | undefined ,
778+ isFullBuild : boolean ,
779+ ) : Promise < { elapsedMs : number ; targetIds : Set < number > } > {
780+ const t0 = Date . now ( ) ;
781+ const targetIds = new Set < number > ( ) ;
782+ // Fast guard: need at least one extends edge for this/super to have meaning
783+ const hasExtends = db . prepare ( `SELECT 1 FROM edges WHERE kind = 'extends' LIMIT 1` ) . get ( ) ;
784+ if ( ! hasExtends ) return { elapsedMs : 0 , targetIds } ;
785+
786+ // Build parents map: child class → direct parent class (from `extends` edges)
787+ const parentRows = db
788+ . prepare ( `
789+ SELECT src.name AS child_name, tgt.name AS parent_name
790+ FROM edges e
791+ JOIN nodes src ON e.source_id = src.id
792+ JOIN nodes tgt ON e.target_id = tgt.id
793+ WHERE e.kind = 'extends'
794+ ` )
795+ . all ( ) as Array < { child_name : string ; parent_name : string } > ;
796+
797+ const parents = new Map < string , string > ( ) ;
798+ for ( const row of parentRows ) {
799+ if ( ! parents . has ( row . child_name ) ) parents . set ( row . child_name , row . parent_name ) ;
800+ }
801+ if ( parents . size === 0 ) return { elapsedMs : 0 , targetIds } ;
802+
803+ const chaCtx : ChaContext = {
804+ implementors : new Map ( ) , // not needed for this/super resolution
805+ parents,
806+ instantiatedTypes : new Set ( ) , // not needed for this/super resolution
807+ } ;
808+
809+ // Determine which files to re-parse.
810+ //
811+ // On a full build we do NOT re-parse every JS/TS file — that would WASM-parse
812+ // the entire project on top of the native pass, causing a massive regression
813+ // (measured: +358% ms/file on codegraph itself). Instead we restrict to files
814+ // that are part of the class inheritance hierarchy: both subclass files (which
815+ // contain `super.X()` calls dispatching to a parent) and parent-class files
816+ // (whose method bodies contain `this.X()` calls that CHA must resolve). Any
817+ // file not in the hierarchy has no `extends` relationship, so `this`/`super`
818+ // calls in it either resolve locally (same-class dispatch, already handled by
819+ // the direct-call edge) or have no class context — and will be skipped by
820+ // `resolveThisDispatch` anyway.
821+ let relFiles : string [ ] ;
822+ if ( isFullBuild || ! changedFiles ) {
823+ const rows = db
824+ . prepare ( `
825+ SELECT DISTINCT file FROM (
826+ SELECT src.file AS file
827+ FROM edges e
828+ JOIN nodes src ON e.source_id = src.id
829+ WHERE e.kind = 'extends' AND src.file IS NOT NULL
830+ UNION
831+ SELECT tgt.file AS file
832+ FROM edges e
833+ JOIN nodes tgt ON e.target_id = tgt.id
834+ WHERE e.kind = 'extends' AND tgt.file IS NOT NULL
835+ )
836+ ` )
837+ . all ( ) as Array < { file : string } > ;
838+ relFiles = rows
839+ . map ( ( r ) => r . file )
840+ . filter ( ( f ) => THIS_DISPATCH_EXTS . has ( path . extname ( f ) . toLowerCase ( ) ) ) ;
841+ } else {
842+ // NOTE: Only files explicitly listed in changedFiles are re-parsed.
843+ // If a parent-class method is replaced (new node ID) but the child file is
844+ // unchanged, the stale super.method() edge is not refreshed here. A full
845+ // rebuild (isFullBuild=true) is required to recover in that scenario.
846+ relFiles = changedFiles . filter ( ( f ) => THIS_DISPATCH_EXTS . has ( path . extname ( f ) . toLowerCase ( ) ) ) ;
847+ }
848+ if ( relFiles . length === 0 ) return { elapsedMs : 0 , targetIds } ;
849+
850+ // DB-backed CallNodeLookup — resolveThisDispatch only calls byName()
851+ const findByNameStmt = db . prepare ( `SELECT id, file, kind FROM nodes WHERE name = ?` ) ;
852+ const lookup : CallNodeLookup = {
853+ byName : ( name ) => findByNameStmt . all ( name ) as Array < { id : number ; file : string ; kind : string } > ,
854+ byNameAndFile : ( name , file ) =>
855+ ( findByNameStmt . all ( name ) as Array < { id : number ; file : string ; kind : string } > ) . filter (
856+ ( n ) => n . file === file ,
857+ ) ,
858+ isBarrel : ( ) => false ,
859+ resolveBarrel : ( ) => null ,
860+ nodeId : ( ) => undefined ,
861+ } ;
862+
863+ // Seed seen-pairs from existing call edges on source nodes in our file set
864+ const seen = new Set < string > ( ) ;
865+ const CHUNK = 500 ;
866+ for ( let i = 0 ; i < relFiles . length ; i += CHUNK ) {
867+ const chunk = relFiles . slice ( i , i + CHUNK ) ;
868+ const ph = chunk . map ( ( ) => '?' ) . join ( ',' ) ;
869+ const rows = db
870+ . prepare (
871+ `SELECT e.source_id, e.target_id
872+ FROM edges e
873+ JOIN nodes n ON e.source_id = n.id
874+ WHERE e.kind = 'calls' AND n.file IN (${ ph } )` ,
875+ )
876+ . all ( ...chunk ) as Array < { source_id : number ; target_id : number } > ;
877+ for ( const r of rows ) seen . add ( `${ r . source_id } |${ r . target_id } ` ) ;
878+ }
879+
880+ // Find the innermost containing method/function for a call at `line` in `file`.
881+ // COALESCE maps NULL end_line to a large sentinel so unbounded nodes sort last
882+ // (SQLite ASC orders NULLs first, so a raw `end_line - line` would pick them first).
883+ const findCallerByLineStmt = db . prepare ( `
884+ SELECT id, name FROM nodes
885+ WHERE file = ? AND kind IN ('method', 'function')
886+ AND line <= ? AND (end_line IS NULL OR end_line >= ?)
887+ ORDER BY COALESCE(end_line - line, 999999999) ASC
888+ LIMIT 1
889+ ` ) ;
890+
891+ // WASM-parse the files to obtain raw call sites with receiver info
892+ const absFiles = relFiles . map ( ( f ) => path . join ( rootDir , f ) ) ;
893+ const wasmResults = await parseFilesWasmForBackfill ( absFiles , rootDir ) ;
894+
895+ const newEdges : Array < [ number , number , string , number , number , string ] > = [ ] ;
896+
897+ for ( const [ relPath , symbols ] of wasmResults ) {
898+ for ( const call of symbols . calls ) {
899+ // Only 'this' and 'super' are class-instance receivers in JS/TS.
900+ // 'self' refers to WindowOrWorkerGlobalScope — not a class instance — so
901+ // filtering it here prevents spurious dispatch edges from Worker call sites.
902+ if ( call . receiver !== 'this' && call . receiver !== 'super' ) continue ;
903+
904+ const callerRow = findCallerByLineStmt . get ( relPath , call . line , call . line ) as
905+ | { id : number ; name : string }
906+ | undefined ;
907+ if ( ! callerRow ) continue ;
908+
909+ const targets = resolveThisDispatch (
910+ call . name ,
911+ callerRow . name ,
912+ call . receiver as 'this' | 'super' ,
913+ chaCtx ,
914+ lookup ,
915+ ) ;
916+
917+ for ( const t of targets ) {
918+ const key = `${ callerRow . id } |${ t . id } ` ;
919+ if ( seen . has ( key ) ) continue ;
920+ seen . add ( key ) ;
921+ const conf = computeConfidence ( relPath , t . file , null ) - CHA_DISPATCH_PENALTY ;
922+ if ( conf <= 0 ) continue ;
923+ newEdges . push ( [ callerRow . id , t . id , 'calls' , conf , 0 , 'cha' ] ) ;
924+ targetIds . add ( t . id ) ;
925+ }
926+ }
927+ }
928+
929+ if ( newEdges . length > 0 ) {
930+ db . transaction ( ( ) => batchInsertEdges ( db , newEdges ) ) ( ) ;
931+ debug ( `this/super dispatch post-pass: inserted ${ newEdges . length } edge(s)` ) ;
932+ }
933+
934+ // Free WASM parse trees — mirrors the cleanup in backfillNativeDroppedFiles
935+ for ( const [ , symbols ] of wasmResults ) {
936+ const tree = ( symbols as { _tree ?: { delete ?: ( ) => void } } ) . _tree ;
937+ if ( tree && typeof tree . delete === 'function' ) {
938+ try {
939+ tree . delete ( ) ;
940+ } catch {
941+ /* ignore cleanup errors */
942+ }
943+ }
944+ ( symbols as { _tree ?: unknown ; _langId ?: unknown } ) . _tree = undefined ;
945+ ( symbols as { _tree ?: unknown ; _langId ?: unknown } ) . _langId = undefined ;
946+ }
947+
948+ return { elapsedMs : Date . now ( ) - t0 , targetIds } ;
949+ }
950+
755951/** Format timing result from native orchestrator phases + JS post-processing. */
756952function formatNativeTimingResult (
757953 p : Record < string , number > ,
758954 structurePatchMs : number ,
759955 analysisTiming : { astMs : number ; complexityMs : number ; cfgMs : number ; dataflowMs : number } ,
956+ thisDispatchMs : number ,
760957) : BuildResult {
761958 return {
762959 phases : {
@@ -769,6 +966,7 @@ function formatNativeTimingResult(
769966 edgesMs : + ( p . edgesMs ?? 0 ) . toFixed ( 1 ) ,
770967 structureMs : + ( ( p . structureMs ?? 0 ) + structurePatchMs ) . toFixed ( 1 ) ,
771968 rolesMs : + ( p . rolesMs ?? 0 ) . toFixed ( 1 ) ,
969+ thisDispatchMs : + thisDispatchMs . toFixed ( 1 ) ,
772970 astMs : + ( analysisTiming . astMs ?? 0 ) . toFixed ( 1 ) ,
773971 complexityMs : + ( analysisTiming . complexityMs ?? 0 ) . toFixed ( 1 ) ,
774972 cfgMs : + ( analysisTiming . cfgMs ?? 0 ) . toFixed ( 1 ) ,
@@ -1308,7 +1506,7 @@ export async function tryNativeOrchestrator(
13081506 ctx . nativeFirstProxy = false ;
13091507 } else if ( ! ctx . nativeFirstProxy && ! handoffWalAfterNativeBuild ( ctx ) ) {
13101508 // DB reopen failed — return partial result
1311- return formatNativeTimingResult ( p , 0 , analysisTiming ) ;
1509+ return formatNativeTimingResult ( p , 0 , analysisTiming , 0 ) ;
13121510 }
13131511 }
13141512
@@ -1399,10 +1597,68 @@ export async function tryNativeOrchestrator(
13991597 debug ( `Function-prop methods post-pass failed: ${ toErrorMessage ( err ) } ` ) ;
14001598 }
14011599
1600+ // Phase 8.5: this/super dispatch — hybrid WASM re-parse to resolve call sites
1601+ // whose raw receiver info the Rust pipeline does not persist to DB.
1602+ const { elapsedMs : thisDispatchMs , targetIds : thisDispatchTargetIds } =
1603+ await runPostNativeThisDispatch (
1604+ ctx . db as unknown as BetterSqlite3Database ,
1605+ ctx . rootDir ,
1606+ result . changedFiles ,
1607+ ! ! result . isFullBuild ,
1608+ ) ;
1609+
1610+ // Re-classify roles for methods that gained incoming this/super dispatch edges.
1611+ // The Rust orchestrator classifies roles BEFORE this post-pass, so target methods
1612+ // (e.g. Animal.speak, ConcreteWorker.prepare) that had no callers at Rust time
1613+ // are classified `dead` or `dead-ffi`. Inserting the new call edges does not
1614+ // automatically update those role labels — without a re-run the stale labels
1615+ // propagate to dead-code detection and API boundary analysis.
1616+ if ( thisDispatchTargetIds . size > 0 ) {
1617+ try {
1618+ const db = ctx . db as unknown as BetterSqlite3Database ;
1619+ const idArray = Array . from ( thisDispatchTargetIds ) ;
1620+ const CHUNK_SIZE = 500 ;
1621+ const seenFiles = new Set < string > ( ) ;
1622+ const affectedFiles : Array < { file : string } > = [ ] ;
1623+ for ( let i = 0 ; i < idArray . length ; i += CHUNK_SIZE ) {
1624+ const chunk = idArray . slice ( i , i + CHUNK_SIZE ) ;
1625+ const placeholders = chunk . map ( ( ) => '?' ) . join ( ',' ) ;
1626+ const rows = db
1627+ . prepare (
1628+ `SELECT DISTINCT file FROM nodes WHERE id IN (${ placeholders } ) AND file IS NOT NULL` ,
1629+ )
1630+ . all ( ...chunk ) as Array < { file : string } > ;
1631+ for ( const row of rows ) {
1632+ if ( ! seenFiles . has ( row . file ) ) {
1633+ seenFiles . add ( row . file ) ;
1634+ affectedFiles . push ( row ) ;
1635+ }
1636+ }
1637+ }
1638+ if ( affectedFiles . length > 0 ) {
1639+ const { classifyNodeRoles } = ( await import ( '../../../../features/structure.js' ) ) as {
1640+ classifyNodeRoles : (
1641+ db : BetterSqlite3Database ,
1642+ changedFiles ?: string [ ] | null ,
1643+ ) => Record < string , number > ;
1644+ } ;
1645+ classifyNodeRoles (
1646+ db ,
1647+ affectedFiles . map ( ( r ) => r . file ) ,
1648+ ) ;
1649+ debug (
1650+ `this/super dispatch post-pass: re-classified roles for ${ affectedFiles . length } target file(s)` ,
1651+ ) ;
1652+ }
1653+ } catch ( err ) {
1654+ debug ( `this/super dispatch post-pass role re-classification failed: ${ toErrorMessage ( err ) } ` ) ;
1655+ }
1656+ }
1657+
14021658 // Backfill the `technique` column on `calls` edges written by the Rust
14031659 // orchestrator, which does not write the column. Runs after all edge-writing
1404- // phases (including the WASM dropped-language backfill and CHA post-pass) so
1405- // every new edge in this build cycle gets a technique label.
1660+ // phases (including the WASM dropped-language backfill, CHA post-pass, and
1661+ // this/super dispatch) so every new edge in this build cycle gets a label.
14061662 backfillEdgeTechniquesAfterNativeOrchestrator ( ctx . db , ! ! result . isFullBuild , result . changedFiles ) ;
14071663
14081664 // ── Structure and analysis fallback (run after edge-writing so roles see full graph) ──
@@ -1427,5 +1683,5 @@ export async function tryNativeOrchestrator(
14271683 }
14281684
14291685 closeDbPair ( { db : ctx . db , nativeDb : ctx . nativeDb } ) ;
1430- return formatNativeTimingResult ( p , structurePatchMs , analysisTiming ) ;
1686+ return formatNativeTimingResult ( p , structurePatchMs , analysisTiming , thisDispatchMs ) ;
14311687}
0 commit comments