@@ -131,6 +131,7 @@ export function scanReact(options: ScanOptions): LineageGraph {
131131 edges . push ( edge ) ;
132132 }
133133 } ;
134+ const stores = detectStores ( project , root , nodes , addEdge , baseUrls , wrappers ) ;
134135
135136 for ( const sourceFile of project . getSourceFiles ( ) ) {
136137 const file = toPosix ( path . relative ( root , sourceFile . getFilePath ( ) ) ) ;
@@ -161,10 +162,10 @@ export function scanReact(options: ScanOptions): LineageGraph {
161162 nodes . set ( id , { id, kind : "hook" , name : decl . name , loc : decl . loc , exportName : decl . exportName } ) ;
162163 }
163164
164- extractBodyFacts ( decl . name , decl . fn , id , file , nodes , addEdge , baseUrls , wrappers ) ;
165+ extractBodyFacts ( decl . name , decl . fn , id , file , nodes , addEdge , baseUrls , wrappers , stores ) ;
165166 }
166167
167- scanClassComponents ( sourceFile , file , nodes , addEdge , baseUrls , wrappers , localeTable , pendingInstances ) ;
168+ scanClassComponents ( sourceFile , file , nodes , addEdge , baseUrls , wrappers , localeTable , pendingInstances , stores ) ;
168169 collectHocAliases ( sourceFile , hocAliases ) ;
169170 }
170171
@@ -384,6 +385,7 @@ function extractBodyFacts(
384385 addEdge : ( edge : LineageEdge ) => void ,
385386 baseUrls : string [ ] ,
386387 wrappers : WrapperRegistry ,
388+ stores : StoreRegistry ,
387389) : void {
388390 // A wrapper's own body is plumbing: its URL is a parameter placeholder, so a
389391 // data source emitted here would attribute ":path" to every consumer. Call
@@ -393,6 +395,29 @@ function extractBodyFacts(
393395 for ( const call of body . getDescendantsOfKind ( SyntaxKind . CallExpression ) ) {
394396 const callee = call . getExpression ( ) . getText ( ) ;
395397
398+ // Store readers/dispatchers first — useSelector would otherwise fall
399+ // through to the generic per-component state handling.
400+ if ( callee === "useSelector" ) {
401+ const sliceId = selectorSliceId ( call , stores ) ;
402+ if ( sliceId !== undefined ) {
403+ addEdge ( { from : ownerId , to : sliceId , kind : "reads-state" } ) ;
404+ continue ;
405+ }
406+ }
407+ const zustandId = stores . zustandHooks . get ( callee ) ;
408+ if ( zustandId !== undefined ) {
409+ addEdge ( { from : ownerId , to : zustandId , kind : "reads-state" } ) ;
410+ continue ;
411+ }
412+ const thunkSources = stores . thunkSources . get ( callee ) ;
413+ if ( thunkSources !== undefined ) {
414+ // dispatch(fetchUsers()) — this component initiates the fetch.
415+ for ( const dsId of thunkSources ) {
416+ addEdge ( { from : ownerId , to : dsId , kind : "fetches-from" } ) ;
417+ }
418+ continue ;
419+ }
420+
396421 const dataSource = declIsWrapper ? null : detectDataSource ( call , callee , baseUrls , wrappers ) ;
397422 if ( dataSource !== null ) {
398423 const dsId = nodeId ( "data-source" , file , `${ dataSource . sourceKind } :${ dataSource . endpoint } ` ) ;
@@ -467,6 +492,16 @@ function extractBodyFacts(
467492 }
468493}
469494
495+ /** `useSelector((s) => s.users.list)` → the "users" slice's StateNode id. */
496+ function selectorSliceId ( call : CallExpression , stores : StoreRegistry ) : string | undefined {
497+ const selector = call . getArguments ( ) [ 0 ] ;
498+ if ( selector === undefined || ! Node . isArrowFunction ( selector ) ) return undefined ;
499+ const param = selector . getParameters ( ) [ 0 ] ?. getName ( ) ;
500+ if ( param === undefined ) return undefined ;
501+ const match = new RegExp ( `\\b${ param } \\.([A-Za-z_$][\\w$]*)` ) . exec ( selector . getBody ( ) . getText ( ) ) ;
502+ return match ?. [ 1 ] !== undefined ? stores . reduxSlices . get ( match [ 1 ] ) : undefined ;
503+ }
504+
470505type DetectedSource = {
471506 sourceKind : DataSourceKind ;
472507 method : string | null ;
@@ -652,6 +687,142 @@ function stateVariableName(call: CallExpression): string | null {
652687 return null ;
653688}
654689
690+ /** Store registry (TRACKER step 2.4, failure mode C6). */
691+ interface StoreRegistry {
692+ /** Redux slice name → its global StateNode id. */
693+ reduxSlices : Map < string , string > ;
694+ /** Zustand hook name (useCartStore) → its global StateNode id. */
695+ zustandHooks : Map < string , string > ;
696+ /** createAsyncThunk name → the data-source node ids its payload creator hits. */
697+ thunkSources : Map < string , string [ ] > ;
698+ }
699+
700+ /**
701+ * Global stores decouple the reader from the fetch: a component renders
702+ * `useSelector(s => s.users.list)` while the API call that POPULATED the
703+ * slice happened at login, in another component. This pass finds the store
704+ * shapes and wires data-source --writes-state--> slice, so lineage flows
705+ * reader → slice → populating API.
706+ */
707+ function detectStores (
708+ project : Project ,
709+ root : string ,
710+ nodes : Map < string , LineageNode > ,
711+ addEdge : ( edge : LineageEdge ) => void ,
712+ baseUrls : string [ ] ,
713+ wrappers : WrapperRegistry ,
714+ ) : StoreRegistry {
715+ const registry : StoreRegistry = {
716+ reduxSlices : new Map ( ) ,
717+ zustandHooks : new Map ( ) ,
718+ thunkSources : new Map ( ) ,
719+ } ;
720+
721+ const ensureDataSource = ( call : Node , file : string ) : string | null => {
722+ if ( ! Node . isCallExpression ( call ) ) return null ;
723+ const detected = detectDataSource ( call , call . getExpression ( ) . getText ( ) , baseUrls , wrappers ) ;
724+ if ( detected === null || detected . sourceKind === "react-query" || detected . sourceKind === "swr" ) {
725+ return null ;
726+ }
727+ const dsId = nodeId ( "data-source" , file , `${ detected . sourceKind } :${ detected . endpoint } ` ) ;
728+ if ( ! nodes . has ( dsId ) ) {
729+ nodes . set ( dsId , {
730+ id : dsId ,
731+ kind : "data-source" ,
732+ name : detected . endpoint ,
733+ loc : locOf ( call , file ) ,
734+ sourceKind : detected . sourceKind ,
735+ method : detected . method ,
736+ endpoint : detected . endpoint ,
737+ raw : detected . raw ,
738+ resolved : detected . resolved ,
739+ } ) ;
740+ }
741+ return dsId ;
742+ } ;
743+
744+ // Pass 1: slices, zustand stores, thunks.
745+ for ( const sourceFile of project . getSourceFiles ( ) ) {
746+ const file = toPosix ( path . relative ( root , sourceFile . getFilePath ( ) ) ) ;
747+ for ( const variable of sourceFile . getVariableDeclarations ( ) ) {
748+ const init = variable . getInitializer ( ) ;
749+ if ( init === undefined || ! Node . isCallExpression ( init ) ) continue ;
750+ const callee = init . getExpression ( ) . getText ( ) ;
751+
752+ if ( callee === "createSlice" ) {
753+ const config = init . getArguments ( ) [ 0 ] ;
754+ const nameInit = config !== undefined ? propertyInitializer ( config , "name" ) : undefined ;
755+ const sliceName =
756+ nameInit !== undefined && Node . isStringLiteral ( nameInit )
757+ ? nameInit . getLiteralValue ( )
758+ : variable . getName ( ) ;
759+ const stateId = nodeId ( "state" , file , `slice:${ sliceName } ` ) ;
760+ if ( ! nodes . has ( stateId ) ) {
761+ nodes . set ( stateId , {
762+ id : stateId ,
763+ kind : "state" ,
764+ name : sliceName ,
765+ loc : locOf ( variable , file ) ,
766+ stateKind : "redux" ,
767+ } ) ;
768+ }
769+ registry . reduxSlices . set ( sliceName , stateId ) ;
770+ } else if ( callee === "createAsyncThunk" ) {
771+ const payloadCreator = init . getArguments ( ) [ 1 ] ;
772+ const sources : string [ ] = [ ] ;
773+ if ( payloadCreator !== undefined ) {
774+ for ( const call of payloadCreator . getDescendantsOfKind ( SyntaxKind . CallExpression ) ) {
775+ const dsId = ensureDataSource ( call , file ) ;
776+ if ( dsId !== null ) sources . push ( dsId ) ;
777+ }
778+ }
779+ registry . thunkSources . set ( variable . getName ( ) , sources ) ;
780+ } else if ( callee === "create" && HOOK_NAME . test ( variable . getName ( ) ) ) {
781+ // Zustand: const useCartStore = create((set) => ({ ..., load: async () => { fetch...; set(...) } }))
782+ const stateId = nodeId ( "state" , file , `store:${ variable . getName ( ) } ` ) ;
783+ if ( ! nodes . has ( stateId ) ) {
784+ nodes . set ( stateId , {
785+ id : stateId ,
786+ kind : "state" ,
787+ name : variable . getName ( ) ,
788+ loc : locOf ( variable , file ) ,
789+ stateKind : "zustand" ,
790+ } ) ;
791+ }
792+ registry . zustandHooks . set ( variable . getName ( ) , stateId ) ;
793+ for ( const call of init . getDescendantsOfKind ( SyntaxKind . CallExpression ) ) {
794+ const dsId = ensureDataSource ( call , file ) ;
795+ if ( dsId !== null ) addEdge ( { from : dsId , to : stateId , kind : "writes-state" } ) ;
796+ }
797+ }
798+ }
799+ }
800+
801+ // Pass 2: thunk → slice associations via extraReducers addCase(thunk.fulfilled).
802+ for ( const sourceFile of project . getSourceFiles ( ) ) {
803+ for ( const access of sourceFile . getDescendantsOfKind ( SyntaxKind . PropertyAccessExpression ) ) {
804+ if ( ! [ "fulfilled" , "rejected" , "pending" ] . includes ( access . getName ( ) ) ) continue ;
805+ const thunkName = access . getExpression ( ) . getText ( ) ;
806+ const sources = registry . thunkSources . get ( thunkName ) ;
807+ if ( sources === undefined || sources . length === 0 ) continue ;
808+ const sliceCall = access . getFirstAncestor (
809+ ( a ) => Node . isCallExpression ( a ) && a . getExpression ( ) . getText ( ) === "createSlice" ,
810+ ) ;
811+ if ( sliceCall === undefined || ! Node . isCallExpression ( sliceCall ) ) continue ;
812+ const config = sliceCall . getArguments ( ) [ 0 ] ;
813+ const nameInit = config !== undefined ? propertyInitializer ( config , "name" ) : undefined ;
814+ if ( nameInit === undefined || ! Node . isStringLiteral ( nameInit ) ) continue ;
815+ const stateId = registry . reduxSlices . get ( nameInit . getLiteralValue ( ) ) ;
816+ if ( stateId === undefined ) continue ;
817+ for ( const dsId of sources ) {
818+ addEdge ( { from : dsId , to : stateId , kind : "writes-state" } ) ;
819+ }
820+ }
821+ }
822+
823+ return registry ;
824+ }
825+
655826const CLASS_COMPONENT_BASE = / ( R e a c t \. ) ? ( P u r e ) ? C o m p o n e n t / ;
656827
657828/** Legacy class components: render() is the body, this.state is state, lifecycle fetches count. */
@@ -664,6 +835,7 @@ function scanClassComponents(
664835 wrappers : WrapperRegistry ,
665836 localeTable : LocaleTable | null ,
666837 pendingInstances : PendingInstance [ ] ,
838+ stores : StoreRegistry ,
667839) : void {
668840 for ( const cls of sourceFile . getClasses ( ) ) {
669841 const name = cls . getName ( ) ;
@@ -705,7 +877,7 @@ function scanClassComponents(
705877 } ) ;
706878 collectInstanceSites ( render , id , file , pendingInstances ) ;
707879 // Whole class body: lifecycle fetches (componentDidMount etc.) + render events.
708- extractBodyFacts ( name , cls , id , file , nodes , addEdge , baseUrls , wrappers ) ;
880+ extractBodyFacts ( name , cls , id , file , nodes , addEdge , baseUrls , wrappers , stores ) ;
709881 extractClassState ( cls , name , id , file , nodes , addEdge ) ;
710882 }
711883}
0 commit comments