Skip to content

Commit 3435cb7

Browse files
authored
Fix: ts extractor monorepo aliases and barrel reexports (#61)
* Enhance TSExtractor for monorepo support with path aliases and barrel re-exports * Enhance TS marker detection to support plain JS frameworks and improve test coverage
1 parent c67197c commit 3435cb7

2 files changed

Lines changed: 261 additions & 49 deletions

File tree

internal/extractors/tsextractor/ts.go

Lines changed: 136 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -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).
9495
func 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.
10011088
func 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

Comments
 (0)