@@ -406,12 +406,12 @@ async function runPostNativeAnalysis(
406406 * can re-classify roles for the affected implementation files. An empty set
407407 * means no edges were added and role re-classification is unnecessary.
408408 */
409- function runPostNativeCha ( db : BetterSqlite3Database ) : Set < number > {
409+ function runPostNativeCha ( db : BetterSqlite3Database ) : number {
410410 // Fast guard: no hierarchy edges → no CHA work
411411 const hasHierarchy = db
412412 . prepare ( `SELECT 1 FROM edges WHERE kind IN ('extends', 'implements') LIMIT 1` )
413413 . get ( ) ;
414- if ( ! hasHierarchy ) return new Set ( ) ;
414+ if ( ! hasHierarchy ) return 0 ;
415415
416416 // Build implementors map: parent/interface name → [child/implementing class names]
417417 const hierarchyRows = db
@@ -433,7 +433,7 @@ function runPostNativeCha(db: BetterSqlite3Database): Set<number> {
433433 }
434434 if ( ! list . includes ( row . child_name ) ) list . push ( row . child_name ) ;
435435 }
436- if ( implementors . size === 0 ) return new Set ( ) ;
436+ if ( implementors . size === 0 ) return 0 ;
437437
438438 // RTA: collect class names that are actually instantiated via `new X()`.
439439 // Primary query targets `class`-kind nodes (the canonical schema).
@@ -506,7 +506,7 @@ function runPostNativeCha(db: BetterSqlite3Database): Set<number> {
506506 `SELECT id, file AS method_file FROM nodes WHERE name = ? AND kind = 'method'` ,
507507 ) ;
508508 const newEdges : Array < [ number , number , string , number , number , string ] > = [ ] ;
509- const newTargetIds = new Set < number > ( ) ;
509+ let newEdgeCount = 0 ;
510510
511511 for ( const { source_id, method_name, caller_file } of callToMethods ) {
512512 const dotIdx = method_name . indexOf ( '.' ) ;
@@ -545,7 +545,7 @@ function runPostNativeCha(db: BetterSqlite3Database): Set<number> {
545545 CHA_DISPATCH_PENALTY ;
546546 if ( conf <= 0 ) continue ;
547547 newEdges . push ( [ source_id , methodNode . id , 'calls' , conf , 0 , 'cha' ] ) ;
548- newTargetIds . add ( methodNode . id ) ;
548+ newEdgeCount ++ ;
549549 }
550550 }
551551
@@ -558,7 +558,7 @@ function runPostNativeCha(db: BetterSqlite3Database): Set<number> {
558558 if ( newEdges . length > 0 ) {
559559 db . transaction ( ( ) => batchInsertEdges ( db , newEdges ) ) ( ) ;
560560 }
561- return newTargetIds ;
561+ return newEdgeCount ;
562562}
563563
564564/**
@@ -1607,48 +1607,28 @@ export async function tryNativeOrchestrator(
16071607 }
16081608
16091609 // Phase 8.5: expand CHA call edges (interface dispatch → concrete implementations).
1610- // `runPostNativeCha` returns the target node IDs of newly inserted edges so we
1611- // can re-classify roles for the implementation files. The Rust orchestrator ran
1612- // role classification BEFORE this post-pass, so without a re-run the newly-called
1613- // implementor methods stay classified as `dead-ffi` (no incoming edges at Rust time).
1614- const chaTargetIds = runPostNativeCha ( ctx . db as unknown as BetterSqlite3Database ) ;
1615- if ( chaTargetIds . size > 0 ) {
1610+ // The Rust orchestrator ran role classification BEFORE this post-pass, so without
1611+ // a re-run the newly-called implementor methods stay classified as `dead-ffi`.
1612+ //
1613+ // CHA also changes the global fan-out distribution (callee files gain fan_in, and
1614+ // new edges shift the median). A full re-classification is required — not just the
1615+ // callee files — because the median shift can change roles in unrelated files whose
1616+ // fan-out sits near the old median. (Example: a method that called two siblings
1617+ // pre-CHA might be near the median, but post-CHA the median is higher, changing
1618+ // its role from utility → core.) Using an incremental pass with a stale median
1619+ // cache would produce incorrect roles outside the CHA-affected file set.
1620+ const chaEdgeCount = runPostNativeCha ( ctx . db as unknown as BetterSqlite3Database ) ;
1621+ if ( chaEdgeCount > 0 ) {
16161622 try {
16171623 const db = ctx . db as unknown as BetterSqlite3Database ;
1618- const idArray = Array . from ( chaTargetIds ) ;
1619- const CHUNK_SIZE = 500 ;
1620- const seenFiles = new Set < string > ( ) ;
1621- const affectedFiles : Array < { file : string } > = [ ] ;
1622- for ( let i = 0 ; i < idArray . length ; i += CHUNK_SIZE ) {
1623- const chunk = idArray . slice ( i , i + CHUNK_SIZE ) ;
1624- const placeholders = chunk . map ( ( ) => '?' ) . join ( ',' ) ;
1625- const rows = db
1626- . prepare (
1627- `SELECT DISTINCT file FROM nodes WHERE id IN (${ placeholders } ) AND file IS NOT NULL` ,
1628- )
1629- . all ( ...chunk ) as Array < { file : string } > ;
1630- for ( const row of rows ) {
1631- if ( ! seenFiles . has ( row . file ) ) {
1632- seenFiles . add ( row . file ) ;
1633- affectedFiles . push ( row ) ;
1634- }
1635- }
1636- }
1637- if ( affectedFiles . length > 0 ) {
1638- const { classifyNodeRoles } = ( await import ( '../../../../features/structure.js' ) ) as {
1639- classifyNodeRoles : (
1640- db : BetterSqlite3Database ,
1641- changedFiles ?: string [ ] | null ,
1642- ) => Record < string , number > ;
1643- } ;
1644- classifyNodeRoles (
1645- db ,
1646- affectedFiles . map ( ( r ) => r . file ) ,
1647- ) ;
1648- debug (
1649- `CHA post-pass: re-classified roles for ${ affectedFiles . length } implementation file(s)` ,
1650- ) ;
1651- }
1624+ const { classifyNodeRoles } = ( await import ( '../../../../features/structure.js' ) ) as {
1625+ classifyNodeRoles : (
1626+ db : BetterSqlite3Database ,
1627+ changedFiles ?: string [ ] | null ,
1628+ ) => Record < string , number > ;
1629+ } ;
1630+ classifyNodeRoles ( db ) ;
1631+ debug ( `CHA post-pass: full role re-classification after ${ chaEdgeCount } new CHA edges` ) ;
16521632 } catch ( err ) {
16531633 debug ( `CHA post-pass role re-classification failed: ${ toErrorMessage ( err ) } ` ) ;
16541634 }
0 commit comments