diff --git a/internal/actions/actions.go b/internal/actions/actions.go index 281db337..b6c4f617 100644 --- a/internal/actions/actions.go +++ b/internal/actions/actions.go @@ -227,12 +227,41 @@ func referenceChanges(project scanner.Project, item scanner.AssetItem, targetPat File: ref.File, Line: ref.Line, OldSpecifier: ref.Specifier, - NewSpecifier: relativeSpecifier(ref.File, targetPath), + NewSpecifier: rewriteSpecifier(ref.Specifier, ref.File, targetPath), }) } return changes, blockers } +func rewriteSpecifier(oldSpec, importerRepoPath, targetRepoPath string) string { + spec := strings.Split(oldSpec, "?")[0] + query := "" + if i := strings.IndexByte(oldSpec, '?'); i >= 0 { + query = oldSpec[i:] + } + for _, prefix := range []string{"@/", "~/"} { + if strings.HasPrefix(spec, prefix) { + newSpec := prefix + strings.TrimPrefix(targetRepoPath, findSrcBase(importerRepoPath)+"/") + return newSpec + query + } + } + if strings.HasPrefix(spec, "/") { + return "/" + targetRepoPath + query + } + return relativeSpecifier(importerRepoPath, targetRepoPath) + query +} + +func findSrcBase(importerRepoPath string) string { + dir := filepath.ToSlash(filepath.Dir(importerRepoPath)) + parts := strings.Split(dir, "/") + for i := len(parts) - 1; i >= 0; i-- { + if parts[i] == "src" { + return strings.Join(parts[:i+1], "/") + } + } + return "src" +} + func relativeSpecifier(importerRepoPath, targetRepoPath string) string { from := filepath.Dir(filepath.FromSlash(importerRepoPath)) rel, err := filepath.Rel(from, filepath.FromSlash(targetRepoPath)) diff --git a/internal/actions/actions_test.go b/internal/actions/actions_test.go index 9e6ea762..b29fcaa1 100644 --- a/internal/actions/actions_test.go +++ b/internal/actions/actions_test.go @@ -275,6 +275,22 @@ func TestPathAndSpecifierHelpers(t *testing.T) { if got := relativeSpecifier("src/components/App.tsx", "src/assets/icon.png"); got != "../assets/icon.png" { t.Fatalf("relativeSpecifier nested = %q", got) } + // rewriteSpecifier preserves @/ alias + if got := rewriteSpecifier("@/assets/logo.png", "apps/web/src/views/Home.vue", "apps/web/src/assets/logo.avif"); got != "@/assets/logo.avif" { + t.Fatalf("rewriteSpecifier @/ = %q", got) + } + // rewriteSpecifier preserves ~/ alias + if got := rewriteSpecifier("~/assets/icon.svg", "packages/ui/src/components/Card.tsx", "packages/ui/src/assets/icon.avif"); got != "~/assets/icon.avif" { + t.Fatalf("rewriteSpecifier ~/ = %q", got) + } + // rewriteSpecifier preserves query string + if got := rewriteSpecifier("@/assets/bg.png?raw", "src/App.tsx", "src/assets/bg.avif"); got != "@/assets/bg.avif?raw" { + t.Fatalf("rewriteSpecifier query = %q", got) + } + // rewriteSpecifier falls back to relative for non-alias paths + if got := rewriteSpecifier("./assets/logo.png", "src/components/App.tsx", "src/assets/logo.avif"); got != "../assets/logo.avif" { + t.Fatalf("rewriteSpecifier relative = %q", got) + } for _, invalid := range []string{"", "../escape.png", "/absolute.png", "."} { if got := cleanRepoPath(invalid); got != "" { t.Fatalf("cleanRepoPath(%q) = %q", invalid, got) diff --git a/internal/references/references.go b/internal/references/references.go index eb9732cd..3bc0e2cc 100644 --- a/internal/references/references.go +++ b/internal/references/references.go @@ -307,7 +307,8 @@ func Resolve(projectRoot, importerRepoPath, specifier string) string { return "" } if strings.HasPrefix(spec, "@/") { - return cleanRepoPath(filepath.ToSlash(filepath.Join("src", strings.TrimPrefix(spec, "@/")))) + srcBase := findSrcAncestor(importerRepoPath) + return cleanRepoPath(filepath.ToSlash(filepath.Join(srcBase, strings.TrimPrefix(spec, "@/")))) } if strings.HasPrefix(spec, "/") { assetPath := strings.TrimPrefix(spec, "/") @@ -323,6 +324,17 @@ func Resolve(projectRoot, importerRepoPath, specifier string) string { return cleanRepoPath(spec) } +func findSrcAncestor(importerRepoPath string) string { + dir := pathpkg.Dir(importerRepoPath) + for dir != "." && dir != "/" { + if pathpkg.Base(dir) == "src" { + return dir + } + dir = pathpkg.Dir(dir) + } + return "src" +} + func resolvePublicAsset(projectRoot, importerRepoPath, assetPath string) string { assetPath = cleanRepoPath(assetPath) if assetPath == "" { @@ -366,7 +378,17 @@ func key(projectID, repoPath string) string { func referenceMayPointTo(repoPath, specifier string) bool { clean := stripQuery(filepath.ToSlash(specifier)) clean = strings.TrimPrefix(clean, "./") - return clean == repoPath || strings.HasSuffix(clean, "/"+repoPath) || strings.HasSuffix(repoPath, "/"+clean) + clean = strings.TrimPrefix(clean, "/") + if clean == repoPath || strings.HasSuffix(clean, "/"+repoPath) || strings.HasSuffix(repoPath, "/"+clean) { + return true + } + if strings.HasPrefix(clean, "@/") || strings.HasPrefix(clean, "~/") { + stripped := clean[2:] + if stripped == repoPath || strings.HasSuffix(repoPath, "/"+stripped) { + return true + } + } + return false } func isPattern(spec string) bool { diff --git a/internal/references/references_test.go b/internal/references/references_test.go index 629831f5..8e9dbd6e 100644 --- a/internal/references/references_test.go +++ b/internal/references/references_test.go @@ -19,6 +19,11 @@ func TestResolveReferenceKinds(t *testing.T) { {"src/components/App.tsx", "@/assets/logo.png", "src/assets/logo.png"}, {"src/components/App.tsx", "/src/assets/logo.png", "src/assets/logo.png"}, {"src/components/App.tsx", "src/assets/logo.png?raw", "src/assets/logo.png"}, + // Monorepo: @/ resolves relative to nearest src/ ancestor + {"apps/mobile/src/views/Page.vue", "@/assets/logo.png", "apps/mobile/src/assets/logo.png"}, + {"packages/ui/src/components/Card.tsx", "@/assets/icon.svg", "packages/ui/src/assets/icon.svg"}, + // No src/ ancestor falls back to bare src/ + {"lib/util.ts", "@/assets/logo.png", "src/assets/logo.png"}, } for _, tt := range tests { if got := Resolve(root, tt.importer, tt.spec); got != tt.want { @@ -104,6 +109,49 @@ func TestBuildMapResolvesAbsolutePublicReferences(t *testing.T) { } } +func TestBuildMapResolvesAbsolutePathInMonorepo(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "apps", "dashboard", "src", "assets", "hero.webp"), "image") + mustWrite(t, filepath.Join(root, "apps", "dashboard", "index.html"), + `hero`) + + refs, err := BuildMap(context.Background(), + []Project{{ID: "p", Path: root}}, + []Asset{{ProjectID: "p", RepoPath: "apps/dashboard/src/assets/hero.webp"}}, + ) + if err != nil { + t.Fatal(err) + } + got := refs["p\x00apps/dashboard/src/assets/hero.webp"] + if len(got) != 1 || got[0].File != "apps/dashboard/index.html" { + t.Fatalf("absolute path monorepo refs = %#v, want 1 ref from index.html", got) + } +} + +func TestBuildMapResolvesMonorepoAtAliasImports(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "apps", "web", "src", "assets", "images", "banner.png"), "image") + mustWrite(t, filepath.Join(root, "apps", "web", "src", "views", "Home.vue"), + `import Banner from '@/assets/images/banner.png'`) + mustWrite(t, filepath.Join(root, "apps", "web", "src", "views", "About.vue"), + `.hero { background-image: url('@/assets/images/banner.png'); }`) + + refs, err := BuildMap(context.Background(), + []Project{{ID: "p", Path: root}}, + []Asset{{ProjectID: "p", RepoPath: "apps/web/src/assets/images/banner.png"}}, + ) + if err != nil { + t.Fatal(err) + } + got := refs["p\x00apps/web/src/assets/images/banner.png"] + if len(got) != 2 { + t.Fatalf("monorepo @/ refs = %#v, want 2 refs", got) + } + if got[0].File != "apps/web/src/views/About.vue" || got[1].File != "apps/web/src/views/Home.vue" { + t.Fatalf("monorepo @/ refs files = [%s, %s], want About.vue and Home.vue", got[0].File, got[1].File) + } +} + func TestBuildMapWithProgressExcludesMatchedCodeFiles(t *testing.T) { root := t.TempDir() mustWrite(t, filepath.Join(root, "src", "assets", "logo.png"), "image") @@ -162,6 +210,12 @@ func TestReferenceHelperFunctions(t *testing.T) { if !referenceMayPointTo("demo/icons/a.png", "a.png") { t.Fatal("exact filename should match via path boundary") } + if !referenceMayPointTo("apps/web/src/assets/images/banner.png", "@/assets/images/banner.png") { + t.Fatal("@/ alias stripped suffix should match monorepo asset path") + } + if !referenceMayPointTo("packages/ui/src/assets/icon.svg", "~/assets/icon.svg") { + t.Fatal("~/ alias stripped suffix should match monorepo asset path") + } if cleanRepoPath("../escape.png") != "" || cleanRepoPath("./src/a.png") != "src/a.png" { t.Fatal("cleanRepoPath did not normalize safely") }