diff --git a/internal/extractors/tsextractor/ts.go b/internal/extractors/tsextractor/ts.go index 1b4b94b..2cc3943 100644 --- a/internal/extractors/tsextractor/ts.go +++ b/internal/extractors/tsextractor/ts.go @@ -90,7 +90,8 @@ func searchTSRoot(dir string, depth, maxDepth int) (string, bool) { return "", false } -// hasTSMarkers returns true if the directory looks like a TypeScript project root. +// hasTSMarkers returns true if the directory looks like a project root this +// extractor should handle (TypeScript, or a JS framework it also parses). func hasTSMarkers(dir string) bool { // tsconfig.json (standard) or tsconfig.base.json (Nx monorepo) for _, name := range []string{"tsconfig.json", "tsconfig.base.json"} { @@ -99,20 +100,9 @@ func hasTSMarkers(dir string) bool { } } - // package.json with a typescript dependency - data, err := os.ReadFile(filepath.Join(dir, "package.json")) - if err != nil { - return false - } - var pkg map[string]any - if err := json.Unmarshal(data, &pkg); err != nil { - return false - } - for _, key := range []string{"dependencies", "devDependencies"} { - if deps, ok := pkg[key].(map[string]any); ok { - if _, ok := deps["typescript"]; ok { - return true - } + for _, pkg := range []string{"typescript", "vue", "react", "svelte", "next", "nuxt"} { + if hasPkgDependency(dir, pkg) { + return true } } return false @@ -128,14 +118,12 @@ func (e *TSExtractor) Extract(ctx context.Context, repoPath string, files []stri isNuxt := detectNuxt(repoPath) isSvelteKit := detectSvelteKit(repoPath) - // Parse tsconfig.json for path alias mappings (e.g., "@/*" → "src/*") - aliases := parseTSPathAliases(repoPath) + // Parse tsconfig.json path aliases, one root per package for monorepos. + aliasRoots := collectTSAliasRoots(repoPath) // SvelteKit maps $lib → src/lib by convention. if isSvelteKit { - if _, ok := aliases["$lib/"]; !ok { - aliases["$lib/"] = "src/lib/" - } + aliasRoots = withSvelteKitLibDefault(aliasRoots) } // 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 log.Printf("[ts-extractor] error reading %s: %v", relFile, err) return nil } + aliases := aliasesForDir(aliasRoots, filepath.Dir(relFile)) return e.extractFile(src, relFile, isNextJS, isVue, isNuxt, isSvelteKit, aliases) }) @@ -291,12 +280,20 @@ func (e *TSExtractor) extractImports(root *sitter.Node, src []byte, relFile stri for i := range root.ChildCount() { child := root.Child(i) - if child.Kind() != "import_statement" { + + // export_statement only has a "source" field for re-exports + // (export * from / export { X } from), not local declarations. + var source *sitter.Node + isReexport := false + switch child.Kind() { + case "import_statement": + source = findChildByKind(child, "string") + case "export_statement": + source = child.ChildByFieldName("source") + isReexport = true + default: continue } - - // Find the import source (string) - source := findChildByKind(child, "string") if source == nil { continue } @@ -311,15 +308,20 @@ func (e *TSExtractor) extractImports(root *sitter.Node, src []byte, relFile stri importSource = "external" } + props := map[string]any{ + "language": "typescript", + "source": importSource, + } + if isReexport { + props["reexport"] = true + } + result = append(result, facts.Fact{ - Kind: facts.KindDependency, - Name: dir + " -> " + resolved, - File: relFile, - Line: int(child.StartPosition().Row) + 1, - Props: map[string]any{ - "language": "typescript", - "source": importSource, - }, + Kind: facts.KindDependency, + Name: dir + " -> " + resolved, + File: relFile, + Line: int(child.StartPosition().Row) + 1, + Props: props, Relations: []facts.Relation{ {Kind: facts.RelImports, Target: resolved}, }, @@ -974,30 +976,115 @@ func nodeText(node *sitter.Node, src []byte) string { return string(src[node.StartByte():node.EndByte()]) } -// parseTSPathAliases reads tsconfig.json (or tsconfig.base.json for Nx monorepos) -// and extracts path alias mappings. For example "@/*": ["./src/*"] maps prefix -// "@/" to replacement "src/". It searches the TypeScript root directory first -// to support monorepos where the tsconfig lives in a subdirectory. -func parseTSPathAliases(repoPath string) map[string]string { - tsRoot, _ := findTSRoot(repoPath) +// tsAliasRoot is a directory (repoPath-relative, "" = root) and the alias +// map its tsconfig declares, already qualified with dir as a prefix. +type tsAliasRoot struct { + dir string + aliases map[string]string +} + +// collectTSAliasRoots finds every directory whose tsconfig.json (or +// tsconfig.base.json) declares path aliases — unlike findTSRoot, which stops +// at the first match, this covers monorepos with one tsconfig per package. +func collectTSAliasRoots(repoPath string) []tsAliasRoot { + maxDepth := 2 + if isDeepNestedProject(repoPath) { + maxDepth = 8 + } + var roots []tsAliasRoot + walkTSAliasRoots(repoPath, repoPath, 0, maxDepth, &roots) + return roots +} - // Prefer tsconfig.json; fall back to tsconfig.base.json (Nx monorepo pattern). +func walkTSAliasRoots(repoPath, dir string, depth, maxDepth int, out *[]tsAliasRoot) { + if aliases, ok := aliasesAtDir(dir); ok { + rel, err := filepath.Rel(repoPath, dir) + if err != nil || rel == "." { + rel = "" + } + rel = filepath.ToSlash(rel) + + // Concatenation, not filepath.Join, to preserve the trailing slash + // resolveImportPath's `replacement + rest` depends on. + qualified := make(map[string]string, len(aliases)) + for prefix, replacement := range aliases { + if rel != "" { + replacement = rel + "/" + replacement + } + qualified[prefix] = replacement + } + *out = append(*out, tsAliasRoot{dir: rel, aliases: qualified}) + } + if depth >= maxDepth { + return + } + entries, err := os.ReadDir(dir) + if err != nil { + return + } + for _, entry := range entries { + if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") || tsSkipDirs[entry.Name()] { + continue + } + walkTSAliasRoots(repoPath, filepath.Join(dir, entry.Name()), depth+1, maxDepth, out) + } +} + +// aliasesAtDir tries tsconfig.json then tsconfig.base.json at dir, returning +// the first one that declares a non-empty paths map. +func aliasesAtDir(dir string) (map[string]string, bool) { for _, name := range []string{"tsconfig.json", "tsconfig.base.json"} { - if aliases, ok := tryParseTSConfigAliases(filepath.Join(tsRoot, name)); ok { - return aliases + if aliases, ok := tryParseTSConfigAliases(filepath.Join(dir, name)); ok { + return aliases, true } } - // Also try the original repoPath if tsRoot is different. - if tsRoot != repoPath { - for _, name := range []string{"tsconfig.json", "tsconfig.base.json"} { - if aliases, ok := tryParseTSConfigAliases(filepath.Join(repoPath, name)); ok { - return aliases - } + return nil, false +} + +// aliasesForDir returns the alias map of the root whose dir is the longest +// matching ancestor-or-equal prefix of dir, or nil if none match. +func aliasesForDir(roots []tsAliasRoot, dir string) map[string]string { + dir = filepath.ToSlash(dir) + var best *tsAliasRoot + bestLen := -1 + for i := range roots { + r := &roots[i] + if r.dir != "" && dir != r.dir && !strings.HasPrefix(dir, r.dir+"/") { + continue + } + if len(r.dir) > bestLen { + best = r + bestLen = len(r.dir) + } + } + if best == nil { + return nil + } + return best.aliases +} + +// withSvelteKitLibDefault adds the "$lib/" -> "/src/lib/" convention +// to every root that doesn't already define it. +func withSvelteKitLibDefault(roots []tsAliasRoot) []tsAliasRoot { + if len(roots) == 0 { + roots = []tsAliasRoot{{dir: "", aliases: map[string]string{}}} + } + for i := range roots { + if _, ok := roots[i].aliases["$lib/"]; ok { + continue + } + target := "src/lib/" + if roots[i].dir != "" { + target = roots[i].dir + "/src/lib/" } + roots[i].aliases["$lib/"] = target } - return make(map[string]string) + return roots } +// tryParseTSConfigAliases reads path alias mappings from a tsconfig.json, +// e.g. "@/*": ["./src/*"] maps prefix "@/" to replacement "src/". ok is +// false if the file is missing/invalid or declares no usable paths. func tryParseTSConfigAliases(tsconfigPath string) (map[string]string, bool) { data, err := os.ReadFile(tsconfigPath) if err != nil { @@ -1026,7 +1113,7 @@ func tryParseTSConfigAliases(tsconfigPath string) (map[string]string, bool) { aliases[prefix] = replacement } } - return aliases, true + return aliases, len(aliases) > 0 } // resolveImportPath normalizes a TypeScript import path to a filesystem-relative path. diff --git a/internal/extractors/tsextractor/ts_test.go b/internal/extractors/tsextractor/ts_test.go index 91e8105..7810801 100644 --- a/internal/extractors/tsextractor/ts_test.go +++ b/internal/extractors/tsextractor/ts_test.go @@ -173,6 +173,28 @@ func TestDetectRoute_NonRoute(t *testing.T) { } } +func TestHasTSMarkers_PlainJSFramework(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "package.json"), + []byte(`{"dependencies":{"vue":"^2.0.0"}}`), 0o644); err != nil { + t.Fatal(err) + } + if !hasTSMarkers(dir) { + t.Error("expected a plain-JS Vue project (no tsconfig, no typescript dep) to be detected") + } +} + +func TestHasTSMarkers_PlainJSNoFramework(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "package.json"), + []byte(`{"dependencies":{"express":"^4.0.0"}}`), 0o644); err != nil { + t.Fatal(err) + } + if hasTSMarkers(dir) { + t.Error("expected a plain JS project with no recognized framework to stay undetected") + } +} + // --- Full extraction tests --- func TestExtract_FunctionDeclaration(t *testing.T) { @@ -268,6 +290,9 @@ import React from 'react' for _, r := range d.Relations { if r.Target == "src/utils" { hasUtils = true + if d.Props["reexport"] == true { + t.Error("plain import should not be tagged reexport") + } } if r.Target == "react" { hasReact = true @@ -282,6 +307,106 @@ import React from 'react' } } +func TestExtract_Monorepo_NestedTSConfigAlias(t *testing.T) { + ff := extractAll(t, map[string]string{ + "tsconfig.json": `{}`, + "app/ui/tsconfig.json": `{"compilerOptions":{"paths":{"~/*":["./src/*"]}}}`, + "app/ui/src/pages/Home.tsx": `import { Foo } from '~/components/Foo'`, + "app/ui/src/components/Foo.tsx": `export function Foo() { return null }`, + }, false) + + deps := findFactsByKind(ff, facts.KindDependency) + var found *facts.Fact + for i := range deps { + if hasRelation(deps[i], facts.RelImports, "app/ui/src/components/Foo") { + found = &deps[i] + } + } + if found == nil { + t.Fatal("expected ~/components/Foo to resolve to app/ui/src/components/Foo") + } + if found.Props["source"] != "internal" { + t.Errorf("source = %v, want internal (paths-less root tsconfig should not short-circuit nested package alias discovery)", found.Props["source"]) + } +} + +func TestExtract_Monorepo_SiblingPackagesSameAliasDifferentTarget(t *testing.T) { + ff := extractAll(t, map[string]string{ + "tsconfig.json": `{}`, + "packages/app-a/tsconfig.json": `{"compilerOptions":{"paths":{"~/*":["./src/*"]}}}`, + "packages/app-a/src/index.ts": `import { X } from '~/foo'`, + "packages/app-a/src/foo.ts": `export const X = 1`, + "packages/app-b/tsconfig.json": `{"compilerOptions":{"paths":{"~/*":["./lib/*"]}}}`, + "packages/app-b/index.ts": `import { Y } from '~/foo'`, + "packages/app-b/lib/foo.ts": `export const Y = 2`, + }, false) + + deps := findFactsByKind(ff, facts.KindDependency) + wantA, wantB := false, false + for _, d := range deps { + if hasRelation(d, facts.RelImports, "packages/app-a/src/foo") { + wantA = true + } + if hasRelation(d, facts.RelImports, "packages/app-b/lib/foo") { + wantB = true + } + // Neither package's ~/foo should ever resolve against the other's mapping. + if hasRelation(d, facts.RelImports, "packages/app-b/src/foo") || + hasRelation(d, facts.RelImports, "packages/app-a/lib/foo") { + t.Errorf("alias resolved against the wrong package's tsconfig: %+v", d) + } + } + if !wantA { + t.Error("expected app-a's ~/foo to resolve to packages/app-a/src/foo") + } + if !wantB { + t.Error("expected app-b's ~/foo to resolve to packages/app-b/lib/foo") + } +} + +func TestExtract_BarrelReexports(t *testing.T) { + ff := extractAll(t, map[string]string{ + "src/index.ts": ` +export * from './client' +export { HomePage } from './HomePage' +export type { Config } from './types' +export * from 'some-external-lib' +`, + "src/client.ts": `export function makeClient() {}`, + "src/HomePage.tsx": `export function HomePage() { return null }`, + "src/types.ts": `export type Config = { url: string }`, + }, false) + + deps := findFactsByKind(ff, facts.KindDependency) + + cases := []struct { + target string + wantSource string + }{ + {"src/client", "internal"}, + {"src/HomePage", "internal"}, + {"src/types", "internal"}, + {"some-external-lib", "external"}, + } + for _, tc := range cases { + var found *facts.Fact + for i := range deps { + if hasRelation(deps[i], facts.RelImports, tc.target) { + found = &deps[i] + } + } + if found == nil { + t.Fatalf("expected a Dependency fact re-exporting %s", tc.target) + } + if found.Props["source"] != tc.wantSource { + t.Errorf("%s: source = %v, want %s", tc.target, found.Props["source"], tc.wantSource) + } + if found.Props["reexport"] != true { + t.Errorf("%s: reexport = %v, want true", tc.target, found.Props["reexport"]) + } + } +} + func TestExtract_NonExportedDeclaration(t *testing.T) { ff := extractAll(t, map[string]string{ "src/internal.ts": `function helper() { return 42 }`,