@@ -90,7 +90,8 @@ func searchTSRoot(dir string, depth, maxDepth int) (string, bool) {
9090 return "" , false
9191}
9292
93- // hasTSMarkers returns true if the directory looks like a TypeScript project root.
93+ // hasTSMarkers returns true if the directory looks like a project root this
94+ // extractor should handle (TypeScript, or a JS framework it also parses).
9495func hasTSMarkers (dir string ) bool {
9596 // tsconfig.json (standard) or tsconfig.base.json (Nx monorepo)
9697 for _ , name := range []string {"tsconfig.json" , "tsconfig.base.json" } {
@@ -99,20 +100,9 @@ func hasTSMarkers(dir string) bool {
99100 }
100101 }
101102
102- // package.json with a typescript dependency
103- data , err := os .ReadFile (filepath .Join (dir , "package.json" ))
104- if err != nil {
105- return false
106- }
107- var pkg map [string ]any
108- if err := json .Unmarshal (data , & pkg ); err != nil {
109- return false
110- }
111- for _ , key := range []string {"dependencies" , "devDependencies" } {
112- if deps , ok := pkg [key ].(map [string ]any ); ok {
113- if _ , ok := deps ["typescript" ]; ok {
114- return true
115- }
103+ for _ , pkg := range []string {"typescript" , "vue" , "react" , "svelte" , "next" , "nuxt" } {
104+ if hasPkgDependency (dir , pkg ) {
105+ return true
116106 }
117107 }
118108 return false
@@ -128,14 +118,12 @@ func (e *TSExtractor) Extract(ctx context.Context, repoPath string, files []stri
128118 isNuxt := detectNuxt (repoPath )
129119 isSvelteKit := detectSvelteKit (repoPath )
130120
131- // Parse tsconfig.json for path alias mappings (e.g., "@/*" → "src/*")
132- aliases := parseTSPathAliases (repoPath )
121+ // Parse tsconfig.json path aliases, one root per package for monorepos.
122+ aliasRoots := collectTSAliasRoots (repoPath )
133123
134124 // SvelteKit maps $lib → src/lib by convention.
135125 if isSvelteKit {
136- if _ , ok := aliases ["$lib/" ]; ! ok {
137- aliases ["$lib/" ] = "src/lib/"
138- }
126+ aliasRoots = withSvelteKitLibDefault (aliasRoots )
139127 }
140128
141129 // Restrict to TypeScript files once, then parse them in parallel. The
@@ -155,6 +143,7 @@ func (e *TSExtractor) Extract(ctx context.Context, repoPath string, files []stri
155143 log .Printf ("[ts-extractor] error reading %s: %v" , relFile , err )
156144 return nil
157145 }
146+ aliases := aliasesForDir (aliasRoots , filepath .Dir (relFile ))
158147 return e .extractFile (src , relFile , isNextJS , isVue , isNuxt , isSvelteKit , aliases )
159148 })
160149
@@ -291,12 +280,20 @@ func (e *TSExtractor) extractImports(root *sitter.Node, src []byte, relFile stri
291280
292281 for i := range root .ChildCount () {
293282 child := root .Child (i )
294- if child .Kind () != "import_statement" {
283+
284+ // export_statement only has a "source" field for re-exports
285+ // (export * from / export { X } from), not local declarations.
286+ var source * sitter.Node
287+ isReexport := false
288+ switch child .Kind () {
289+ case "import_statement" :
290+ source = findChildByKind (child , "string" )
291+ case "export_statement" :
292+ source = child .ChildByFieldName ("source" )
293+ isReexport = true
294+ default :
295295 continue
296296 }
297-
298- // Find the import source (string)
299- source := findChildByKind (child , "string" )
300297 if source == nil {
301298 continue
302299 }
@@ -311,15 +308,20 @@ func (e *TSExtractor) extractImports(root *sitter.Node, src []byte, relFile stri
311308 importSource = "external"
312309 }
313310
311+ props := map [string ]any {
312+ "language" : "typescript" ,
313+ "source" : importSource ,
314+ }
315+ if isReexport {
316+ props ["reexport" ] = true
317+ }
318+
314319 result = append (result , facts.Fact {
315- Kind : facts .KindDependency ,
316- Name : dir + " -> " + resolved ,
317- File : relFile ,
318- Line : int (child .StartPosition ().Row ) + 1 ,
319- Props : map [string ]any {
320- "language" : "typescript" ,
321- "source" : importSource ,
322- },
320+ Kind : facts .KindDependency ,
321+ Name : dir + " -> " + resolved ,
322+ File : relFile ,
323+ Line : int (child .StartPosition ().Row ) + 1 ,
324+ Props : props ,
323325 Relations : []facts.Relation {
324326 {Kind : facts .RelImports , Target : resolved },
325327 },
@@ -974,30 +976,115 @@ func nodeText(node *sitter.Node, src []byte) string {
974976 return string (src [node .StartByte ():node .EndByte ()])
975977}
976978
977- // parseTSPathAliases reads tsconfig.json (or tsconfig.base.json for Nx monorepos)
978- // and extracts path alias mappings. For example "@/*": ["./src/*"] maps prefix
979- // "@/" to replacement "src/". It searches the TypeScript root directory first
980- // to support monorepos where the tsconfig lives in a subdirectory.
981- func parseTSPathAliases (repoPath string ) map [string ]string {
982- tsRoot , _ := findTSRoot (repoPath )
979+ // tsAliasRoot is a directory (repoPath-relative, "" = root) and the alias
980+ // map its tsconfig declares, already qualified with dir as a prefix.
981+ type tsAliasRoot struct {
982+ dir string
983+ aliases map [string ]string
984+ }
985+
986+ // collectTSAliasRoots finds every directory whose tsconfig.json (or
987+ // tsconfig.base.json) declares path aliases — unlike findTSRoot, which stops
988+ // at the first match, this covers monorepos with one tsconfig per package.
989+ func collectTSAliasRoots (repoPath string ) []tsAliasRoot {
990+ maxDepth := 2
991+ if isDeepNestedProject (repoPath ) {
992+ maxDepth = 8
993+ }
994+ var roots []tsAliasRoot
995+ walkTSAliasRoots (repoPath , repoPath , 0 , maxDepth , & roots )
996+ return roots
997+ }
983998
984- // Prefer tsconfig.json; fall back to tsconfig.base.json (Nx monorepo pattern).
999+ func walkTSAliasRoots (repoPath , dir string , depth , maxDepth int , out * []tsAliasRoot ) {
1000+ if aliases , ok := aliasesAtDir (dir ); ok {
1001+ rel , err := filepath .Rel (repoPath , dir )
1002+ if err != nil || rel == "." {
1003+ rel = ""
1004+ }
1005+ rel = filepath .ToSlash (rel )
1006+
1007+ // Concatenation, not filepath.Join, to preserve the trailing slash
1008+ // resolveImportPath's `replacement + rest` depends on.
1009+ qualified := make (map [string ]string , len (aliases ))
1010+ for prefix , replacement := range aliases {
1011+ if rel != "" {
1012+ replacement = rel + "/" + replacement
1013+ }
1014+ qualified [prefix ] = replacement
1015+ }
1016+ * out = append (* out , tsAliasRoot {dir : rel , aliases : qualified })
1017+ }
1018+ if depth >= maxDepth {
1019+ return
1020+ }
1021+ entries , err := os .ReadDir (dir )
1022+ if err != nil {
1023+ return
1024+ }
1025+ for _ , entry := range entries {
1026+ if ! entry .IsDir () || strings .HasPrefix (entry .Name (), "." ) || tsSkipDirs [entry .Name ()] {
1027+ continue
1028+ }
1029+ walkTSAliasRoots (repoPath , filepath .Join (dir , entry .Name ()), depth + 1 , maxDepth , out )
1030+ }
1031+ }
1032+
1033+ // aliasesAtDir tries tsconfig.json then tsconfig.base.json at dir, returning
1034+ // the first one that declares a non-empty paths map.
1035+ func aliasesAtDir (dir string ) (map [string ]string , bool ) {
9851036 for _ , name := range []string {"tsconfig.json" , "tsconfig.base.json" } {
986- if aliases , ok := tryParseTSConfigAliases (filepath .Join (tsRoot , name )); ok {
987- return aliases
1037+ if aliases , ok := tryParseTSConfigAliases (filepath .Join (dir , name )); ok {
1038+ return aliases , true
9881039 }
9891040 }
990- // Also try the original repoPath if tsRoot is different.
991- if tsRoot != repoPath {
992- for _ , name := range []string {"tsconfig.json" , "tsconfig.base.json" } {
993- if aliases , ok := tryParseTSConfigAliases (filepath .Join (repoPath , name )); ok {
994- return aliases
995- }
1041+ return nil , false
1042+ }
1043+
1044+ // aliasesForDir returns the alias map of the root whose dir is the longest
1045+ // matching ancestor-or-equal prefix of dir, or nil if none match.
1046+ func aliasesForDir (roots []tsAliasRoot , dir string ) map [string ]string {
1047+ dir = filepath .ToSlash (dir )
1048+ var best * tsAliasRoot
1049+ bestLen := - 1
1050+ for i := range roots {
1051+ r := & roots [i ]
1052+ if r .dir != "" && dir != r .dir && ! strings .HasPrefix (dir , r .dir + "/" ) {
1053+ continue
1054+ }
1055+ if len (r .dir ) > bestLen {
1056+ best = r
1057+ bestLen = len (r .dir )
1058+ }
1059+ }
1060+ if best == nil {
1061+ return nil
1062+ }
1063+ return best .aliases
1064+ }
1065+
1066+ // withSvelteKitLibDefault adds the "$lib/" -> "<root>/src/lib/" convention
1067+ // to every root that doesn't already define it.
1068+ func withSvelteKitLibDefault (roots []tsAliasRoot ) []tsAliasRoot {
1069+ if len (roots ) == 0 {
1070+ roots = []tsAliasRoot {{dir : "" , aliases : map [string ]string {}}}
1071+ }
1072+ for i := range roots {
1073+ if _ , ok := roots [i ].aliases ["$lib/" ]; ok {
1074+ continue
1075+ }
1076+ target := "src/lib/"
1077+ if roots [i ].dir != "" {
1078+ target = roots [i ].dir + "/src/lib/"
9961079 }
1080+ roots [i ].aliases ["$lib/" ] = target
9971081 }
998- return make ( map [ string ] string )
1082+ return roots
9991083}
10001084
1085+ // tryParseTSConfigAliases reads path alias mappings from a tsconfig.json,
1086+ // e.g. "@/*": ["./src/*"] maps prefix "@/" to replacement "src/". ok is
1087+ // false if the file is missing/invalid or declares no usable paths.
10011088func tryParseTSConfigAliases (tsconfigPath string ) (map [string ]string , bool ) {
10021089 data , err := os .ReadFile (tsconfigPath )
10031090 if err != nil {
@@ -1026,7 +1113,7 @@ func tryParseTSConfigAliases(tsconfigPath string) (map[string]string, bool) {
10261113 aliases [prefix ] = replacement
10271114 }
10281115 }
1029- return aliases , true
1116+ return aliases , len ( aliases ) > 0
10301117}
10311118
10321119// resolveImportPath normalizes a TypeScript import path to a filesystem-relative path.
0 commit comments