From 9f6ed5e98af55fcbcaeb0ce75c1868a971a00d6b Mon Sep 17 00:00:00 2001 From: hiro05097952 Date: Mon, 8 Jun 2026 15:42:54 +0800 Subject: [PATCH 1/3] fix: resolve @/ alias relative to nearest src/ ancestor in monorepos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In monorepo projects the importer lives deep inside e.g. apps/mobile/src/views/, but Resolve() hardcoded @/ → src/, producing src/assets/… which never matched the real repo path apps/mobile/src/assets/…. The fuzzy fallback also failed because the short specifier could not suffix-match the long repo path. Fix Resolve to walk up from the importer and find the nearest src/ directory, and add @/ and ~/ prefix stripping to referenceMayPointTo as a safety net. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/references/references.go | 25 ++++++++++++++++-- internal/references/references_test.go | 35 ++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/internal/references/references.go b/internal/references/references.go index eb9732cd..41b93ad4 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,16 @@ 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) + 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..b123c300 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,30 @@ func TestBuildMapResolvesAbsolutePublicReferences(t *testing.T) { } } +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 +191,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") } From fc23876f09d82cde3787e66f8bb38602721b9a5c Mon Sep 17 00:00:00 2001 From: hiro05097952 Date: Mon, 8 Jun 2026 16:11:28 +0800 Subject: [PATCH 2/3] fix: preserve @/ and ~/ alias format when rewriting optimization references referenceChanges always used relativeSpecifier, converting @/assets/... imports to ../../assets/... when applying optimizations. New rewriteSpecifier detects alias prefixes and rewrites only the path suffix and extension, preserving the original import style. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/actions/actions.go | 31 ++++++++++++++++++++++++++++++- internal/actions/actions_test.go | 16 ++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) 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) From c20f4331af0c2e82c64a47ea33f0b25d55bf7591 Mon Sep 17 00:00:00 2001 From: hiro05097952 Date: Mon, 8 Jun 2026 18:22:23 +0800 Subject: [PATCH 3/3] fix: strip leading slash in fuzzy reference matching referenceMayPointTo produced a double-slash when checking absolute-path specifiers like /src/landing/assets/design.webp, causing the suffix match to always fail in monorepo layouts. --- internal/references/references.go | 1 + internal/references/references_test.go | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/internal/references/references.go b/internal/references/references.go index 41b93ad4..3bc0e2cc 100644 --- a/internal/references/references.go +++ b/internal/references/references.go @@ -378,6 +378,7 @@ func key(projectID, repoPath string) string { func referenceMayPointTo(repoPath, specifier string) bool { clean := stripQuery(filepath.ToSlash(specifier)) clean = strings.TrimPrefix(clean, "./") + clean = strings.TrimPrefix(clean, "/") if clean == repoPath || strings.HasSuffix(clean, "/"+repoPath) || strings.HasSuffix(repoPath, "/"+clean) { return true } diff --git a/internal/references/references_test.go b/internal/references/references_test.go index b123c300..8e9dbd6e 100644 --- a/internal/references/references_test.go +++ b/internal/references/references_test.go @@ -109,6 +109,25 @@ 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")