@@ -51,6 +51,13 @@ export interface ScanOptions {
5151 * in any language match.
5252 */
5353 i18n ?: I18nOptions ;
54+ /**
55+ * Package names whose components should still produce instances even
56+ * though their definitions live outside the repo (e.g. ["@acme/ui"]).
57+ * Such instances are flagged "external-definition" — the usage site is
58+ * ours even when the definition isn't (failure mode A5).
59+ */
60+ designSystemPackages ?: string [ ] ;
5461}
5562
5663type FunctionLike = FunctionDeclaration | ArrowFunction | FunctionExpression ;
@@ -72,6 +79,14 @@ interface PendingInstance {
7279 /** Node id of the enclosing component/hook declaration. */
7380 ownerId : string ;
7481 file : string ;
82+ /** The JSX element itself — used for import resolution and nesting. */
83+ element : JsxOpeningElement | JsxSelfClosingElement ;
84+ /**
85+ * Index (into the global pending list) of the nearest enclosing component
86+ * call site in the same body: <Card><Button/></Card> → Button's parent is
87+ * the Card site. Null at the body root (the renders edge covers the owner).
88+ */
89+ parentIndex : number | null ;
7590}
7691
7792const COMPONENT_NAME = / ^ [ A - Z ] / ;
@@ -153,7 +168,14 @@ export function scanReact(options: ScanOptions): LineageGraph {
153168 collectHocAliases ( sourceFile , hocAliases ) ;
154169 }
155170
156- materializeInstances ( pendingInstances , nodes , addEdge , hocAliases ) ;
171+ materializeInstances (
172+ pendingInstances ,
173+ nodes ,
174+ addEdge ,
175+ hocAliases ,
176+ options . designSystemPackages ?? [ ] ,
177+ root ,
178+ ) ;
157179
158180 return {
159181 version : 2 ,
@@ -772,6 +794,7 @@ function collectInstanceSites(
772794 file : string ,
773795 pendingInstances : PendingInstance [ ] ,
774796) : void {
797+ const bodyStart = pendingInstances . length ;
775798 const record = ( el : JsxOpeningElement | JsxSelfClosingElement ) : void => {
776799 const head = el . getTagNameNode ( ) . getText ( ) . split ( "." ) [ 0 ] ;
777800 if ( head === undefined || ! COMPONENT_NAME . test ( head ) ) return ;
@@ -783,10 +806,38 @@ function collectInstanceSites(
783806 staticProps [ attr . getNameNode ( ) . getText ( ) ] = init . getLiteralValue ( ) ;
784807 }
785808 }
786- pendingInstances . push ( { tagName : head , loc : locOf ( el , file ) , staticProps, ownerId, file } ) ;
809+ pendingInstances . push ( {
810+ tagName : head ,
811+ loc : locOf ( el , file ) ,
812+ staticProps,
813+ ownerId,
814+ file,
815+ element : el ,
816+ parentIndex : null ,
817+ } ) ;
787818 } ;
788819 for ( const el of body . getDescendantsOfKind ( SyntaxKind . JsxOpeningElement ) ) record ( el ) ;
789820 for ( const el of body . getDescendantsOfKind ( SyntaxKind . JsxSelfClosingElement ) ) record ( el ) ;
821+
822+ // Same-body nesting: <Card><Button/></Card> → Button's parent site is Card.
823+ const bodySites = pendingInstances . slice ( bodyStart ) ;
824+ for ( const site of bodySites ) {
825+ let ancestor : Node | undefined = site . element . getParent ( ) ;
826+ while ( ancestor !== undefined && ancestor !== body && site . parentIndex === null ) {
827+ for ( let i = 0 ; i < bodySites . length ; i += 1 ) {
828+ const candidate = bodySites [ i ] ;
829+ if ( candidate === undefined || candidate === site ) continue ;
830+ const candidateElement : Node | undefined = Node . isJsxOpeningElement ( candidate . element )
831+ ? candidate . element . getParent ( ) // the enclosing JsxElement
832+ : candidate . element ;
833+ if ( candidateElement !== undefined && candidateElement === ancestor ) {
834+ site . parentIndex = bodyStart + i ;
835+ break ;
836+ }
837+ }
838+ ancestor = ancestor . getParent ( ) ;
839+ }
840+ }
790841}
791842
792843/**
@@ -802,6 +853,8 @@ function materializeInstances(
802853 nodes : Map < string , LineageNode > ,
803854 addEdge : ( edge : LineageEdge ) => void ,
804855 hocAliases : ReadonlyMap < string , string > ,
856+ designSystemPackages : string [ ] ,
857+ root : string ,
805858) : void {
806859 const definitionsByName = new Map < string , string > ( ) ;
807860 for ( const node of nodes . values ( ) ) {
@@ -810,19 +863,73 @@ function materializeInstances(
810863
811864 // <Panel/> where Panel = connect(...)(PanelInner): resolve through the alias
812865 // chain (bounded — alias graphs are tiny and could in principle cycle).
813- const resolveAlias = ( tagName : string ) : string => {
814- let name = tagName ;
866+ const resolveAlias = ( name : string ) : string => {
867+ let current = name ;
815868 for ( let hop = 0 ; hop < 3 ; hop += 1 ) {
816- const target = hocAliases . get ( name ) ;
869+ const target = hocAliases . get ( current ) ;
817870 if ( target === undefined ) break ;
818- name = target ;
871+ current = target ;
872+ }
873+ return current ;
874+ } ;
875+
876+ /**
877+ * Resolve a tag to its definition. Priority: the file's imports (barrels,
878+ * renames, and default exports resolve via getExportedDeclarations), then
879+ * same-file/global name lookup, then configured design-system packages.
880+ */
881+ const resolveTag = (
882+ pending : PendingInstance ,
883+ ) : { definitionId : string ; external : boolean } | null => {
884+ const sourceFile = pending . element . getSourceFile ( ) ;
885+ const importDecl = sourceFile . getImportDeclarations ( ) . find ( ( decl ) => {
886+ if ( decl . getDefaultImport ( ) ?. getText ( ) === pending . tagName ) return true ;
887+ return decl . getNamedImports ( ) . some ( ( named ) => ( named . getAliasNode ( ) ?. getText ( ) ?? named . getName ( ) ) === pending . tagName ) ;
888+ } ) ;
889+
890+ if ( importDecl !== undefined ) {
891+ const target = importDecl . getModuleSpecifierSourceFile ( ) ;
892+ const specifier = importDecl . getModuleSpecifierValue ( ) ;
893+ const inNodeModules = target ?. getFilePath ( ) . includes ( "node_modules" ) ?? false ;
894+ if ( target !== undefined && ! inNodeModules ) {
895+ const named = importDecl
896+ . getNamedImports ( )
897+ . find ( ( n ) => ( n . getAliasNode ( ) ?. getText ( ) ?? n . getName ( ) ) === pending . tagName ) ;
898+ const importedName = named !== undefined ? named . getName ( ) : "default" ;
899+ for ( const declaration of target . getExportedDeclarations ( ) . get ( importedName ) ?? [ ] ) {
900+ const declName = Node . hasName ( declaration ) ? declaration . getName ( ) : undefined ;
901+ if ( declName === undefined ) continue ;
902+ const declFile = toPosix ( path . relative ( root , declaration . getSourceFile ( ) . getFilePath ( ) ) ) ;
903+ const resolvedName = resolveAlias ( declName ) ;
904+ const candidate = nodeId ( "component" , declFile , resolvedName ) ;
905+ if ( nodes . has ( candidate ) ) return { definitionId : candidate , external : false } ;
906+ }
907+ }
908+ const isDesignSystem = designSystemPackages . some (
909+ ( pkg ) => specifier === pkg || specifier . startsWith ( `${ pkg } /` ) ,
910+ ) ;
911+ if ( isDesignSystem || inNodeModules ) {
912+ return { definitionId : `external:${ specifier } #${ pending . tagName } ` , external : true } ;
913+ }
914+ return null ; // imported from an unknown, unconfigured module — not ours
819915 }
820- return name ;
916+
917+ // Same file first, then unique-name fallback across the project.
918+ const resolvedName = resolveAlias ( pending . tagName ) ;
919+ const sameFile = nodeId ( "component" , pending . file , resolvedName ) ;
920+ if ( nodes . has ( sameFile ) ) return { definitionId : sameFile , external : false } ;
921+ const global = definitionsByName . get ( resolvedName ) ;
922+ return global !== undefined ? { definitionId : global , external : false } : null ;
821923 } ;
822924
823- for ( const pending of pendingInstances ) {
824- const definitionId = definitionsByName . get ( resolveAlias ( pending . tagName ) ) ;
825- if ( definitionId === undefined || definitionId === pending . ownerId ) continue ;
925+ const idByIndex : Array < string | null > = [ ] ;
926+ const created : InstanceNode [ ] = [ ] ;
927+ for ( const [ index , pending ] of pendingInstances . entries ( ) ) {
928+ const resolved = resolveTag ( pending ) ;
929+ if ( resolved === null || resolved . definitionId === pending . ownerId ) {
930+ idByIndex [ index ] = null ;
931+ continue ;
932+ }
826933
827934 let id = instanceId ( pending . file , pending . loc . line , pending . tagName ) ;
828935 let suffix = 1 ;
@@ -835,13 +942,29 @@ function materializeInstances(
835942 kind : "instance" ,
836943 name : pending . tagName ,
837944 loc : pending . loc ,
838- definitionId,
945+ definitionId : resolved . definitionId ,
839946 parentInstanceId : null ,
840947 staticProps : pending . staticProps ,
948+ ...( resolved . external ? { flags : [ "external-definition" ] } : { } ) ,
841949 } ;
842950 nodes . set ( id , instance ) ;
951+ idByIndex [ index ] = id ;
952+ created . push ( instance ) ;
843953 addEdge ( { from : pending . ownerId , to : id , kind : "renders" } ) ;
844- addEdge ( { from : id , to : definitionId , kind : "instance-of" } ) ;
954+ if ( ! resolved . external ) {
955+ addEdge ( { from : id , to : resolved . definitionId , kind : "instance-of" } ) ;
956+ }
957+ }
958+
959+ // Second pass: same-body nesting resolved now that every id exists.
960+ for ( const [ index , pending ] of pendingInstances . entries ( ) ) {
961+ const id = idByIndex [ index ] ;
962+ if ( id === null || id === undefined || pending . parentIndex === null ) continue ;
963+ const parentId = idByIndex [ pending . parentIndex ] ;
964+ const instance = created . find ( ( n ) => n . id === id ) ;
965+ if ( instance !== undefined && parentId !== null && parentId !== undefined ) {
966+ instance . parentInstanceId = parentId ;
967+ }
845968 }
846969}
847970
0 commit comments