diff --git a/blast_radius.go b/blast_radius.go index c952879..331cb3c 100644 --- a/blast_radius.go +++ b/blast_radius.go @@ -1412,7 +1412,13 @@ func renderCoverage(w io.Writer, status string, notes []string) { } func buildImportersReportFromGraph(root, file string, fg *scanner.FileGraph) scanner.ImportersReport { + if canonical, err := filepath.EvalSymlinks(root); err == nil { + root = canonical + } if filepath.IsAbs(file) { + if canonical, err := filepath.EvalSymlinks(file); err == nil { + file = canonical + } if rel, err := filepath.Rel(root, file); err == nil { file = rel } diff --git a/main.go b/main.go index d535e07..ebe6ebd 100644 --- a/main.go +++ b/main.go @@ -58,7 +58,7 @@ type watchReadiness struct { } func main() { - args, err := applyGlobalRootOptions(os.Args[1:]) + args, projectRootExplicit, err := applyGlobalRootOptions(os.Args[1:]) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(2) @@ -318,7 +318,12 @@ func main() { os.Exit(0) } - root := flag.Arg(0) + root, importer, err := resolveImportersInvocation(flag.Arg(0), *importersMode, projectRootExplicit) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(2) + } + *importersMode = importer if root == "" { root = "." } @@ -512,29 +517,29 @@ func main() { } } -func applyGlobalRootOptions(args []string) ([]string, error) { +func applyGlobalRootOptions(args []string) ([]string, bool, error) { opts, remaining, err := cmd.ParseGlobalRootOptions(args) if err != nil { - return nil, err + return nil, false, err } if !opts.Active() { launchDir, err := os.Getwd() if err != nil { - return nil, fmt.Errorf("get working directory: %w", err) + return nil, false, fmt.Errorf("get working directory: %w", err) } if _, err := projectpath.Select(launchDir); err != nil { - return nil, err + return nil, false, err } - return remaining, nil + return remaining, false, nil } launchDir, err := os.Getwd() if err != nil { - return nil, fmt.Errorf("get working directory: %w", err) + return nil, false, fmt.Errorf("get working directory: %w", err) } roots, err := cmd.ResolveGlobalRoots(opts, launchDir) if err != nil { - return nil, err + return nil, false, err } // Canonicalize the stored setup root so projectpath and daemon args agree // with the resolved project root (e.g. macOS /var -> /private/var). @@ -542,7 +547,7 @@ func applyGlobalRootOptions(args []string) ([]string, error) { roots.Setup = canonical } if err := os.Chdir(roots.Project); err != nil { - return nil, fmt.Errorf("change to project root %q: %w", roots.Project, err) + return nil, false, fmt.Errorf("change to project root %q: %w", roots.Project, err) } if opts.SetupRoot != "" { projectpath.SetSetupRoot(roots.Setup) @@ -550,7 +555,33 @@ func applyGlobalRootOptions(args []string) ([]string, error) { projectpath.ResetSetupRoot() } - return remaining, nil + return remaining, opts.Directory != "", nil +} + +func resolveImportersInvocation(root, importer string, projectRootExplicit bool) (string, string, error) { + if root != "" || projectRootExplicit || importer == "" || !filepath.IsAbs(importer) { + // Canonicalize the importer so the relative path matches the + // canonicalized project root (e.g. macOS /tmp -> /private/tmp). + if filepath.IsAbs(importer) { + if canonical, err := filepath.EvalSymlinks(importer); err == nil { + importer = canonical + } + } + return root, importer, nil + } + + canonicalImporter, err := filepath.EvalSymlinks(importer) + if err != nil { + return "", "", fmt.Errorf("resolve --importers file %q: %w", importer, err) + } + inferred, found, err := cmd.ResolveNearestGitRoot(filepath.Dir(canonicalImporter)) + if err != nil { + return "", "", fmt.Errorf("infer project root from --importers %q: %w", importer, err) + } + if !found { + return "", "", fmt.Errorf("infer project root from --importers %q: file is not inside a Git repository", importer) + } + return inferred, canonicalImporter, nil } // stdinManifest is the JSON format accepted by --stdin. diff --git a/main_more_test.go b/main_more_test.go index a4b96d3..a466755 100644 --- a/main_more_test.go +++ b/main_more_test.go @@ -57,7 +57,7 @@ func TestApplyGlobalRootOptions(t *testing.T) { projectpath.ResetSetupRoot() t.Cleanup(projectpath.ResetSetupRoot) - args, err := applyGlobalRootOptions([]string{ + args, projectRootExplicit, err := applyGlobalRootOptions([]string{ "context", "--project-root", projectNested, "--setup-root", setupNested, "--compact", }) @@ -67,6 +67,9 @@ func TestApplyGlobalRootOptions(t *testing.T) { if got := strings.Join(args, "|"); got != "context|--compact" { t.Fatalf("args = %q, want context|--compact", got) } + if !projectRootExplicit { + t.Fatal("expected explicit project root") + } gotDir, err := os.Getwd() if err != nil { t.Fatal(err) @@ -116,7 +119,7 @@ func TestApplyGlobalRootOptionsDirectoryKeepsAutomaticLinkedSelection(t *testing projectpath.ResetSetupRoot() t.Cleanup(projectpath.ResetSetupRoot) - if _, err := applyGlobalRootOptions([]string{"-C", filepath.Join(linked, "pkg"), "context"}); err != nil { + if _, _, err := applyGlobalRootOptions([]string{"-C", filepath.Join(linked, "pkg"), "context"}); err != nil { t.Fatalf("applyGlobalRootOptions() error: %v", err) } if got := projectpath.ConfiguredSetupRoot(); got != "" { @@ -146,7 +149,7 @@ func TestApplyGlobalRootOptionsRejectsMalformedLinkedMetadataWithoutFlags(t *tes } t.Cleanup(func() { _ = os.Chdir(originalDir) }) - if _, err := applyGlobalRootOptions([]string{"context"}); err == nil || !strings.Contains(err.Error(), "resolve linked worktree setup") { + if _, _, err := applyGlobalRootOptions([]string{"context"}); err == nil || !strings.Contains(err.Error(), "resolve linked worktree setup") { t.Fatalf("applyGlobalRootOptions() error = %v, want bounded linked-worktree error", err) } } @@ -620,6 +623,175 @@ func TestRunImportersMode(t *testing.T) { } } +func TestResolveImportersProjectRoot(t *testing.T) { + repo := makeMainGitRepo(t, "main") + absoluteFile := filepath.Join(repo, "main.go") + canonicalRepo, err := filepath.EvalSymlinks(repo) + if err != nil { + t.Fatal(err) + } + + t.Run("infers repository from absolute importer", func(t *testing.T) { + gotRoot, gotFile, err := resolveImportersInvocation("", absoluteFile, false) + if err != nil { + t.Fatalf("resolveImportersInvocation() error: %v", err) + } + if gotRoot != canonicalRepo { + t.Fatalf("root = %q, want %q", gotRoot, canonicalRepo) + } + canonicalFile, err := filepath.EvalSymlinks(absoluteFile) + if err != nil { + t.Fatal(err) + } + if gotFile != canonicalFile { + t.Fatalf("file = %q, want %q", gotFile, canonicalFile) + } + }) + + t.Run("explicit roots win", func(t *testing.T) { + for _, test := range []struct { + name string + positionalRoot string + projectRootExplicit bool + want string + }{ + {name: "positional", positionalRoot: "/explicit/positional", want: "/explicit/positional"}, + {name: "project flag", projectRootExplicit: true, want: ""}, + } { + t.Run(test.name, func(t *testing.T) { + gotRoot, gotFile, err := resolveImportersInvocation(test.positionalRoot, absoluteFile, test.projectRootExplicit) + if err != nil { + t.Fatalf("resolveImportersInvocation() error: %v", err) + } + if gotRoot != test.want { + t.Fatalf("root = %q, want %q", gotRoot, test.want) + } + // The explicit root wins, but the absolute importer is still + // canonicalized so its relative path matches the canonical + // project root (e.g. macOS /tmp -> /private/tmp). + canonicalFile, err := filepath.EvalSymlinks(absoluteFile) + if err != nil { + t.Fatal(err) + } + if gotFile != canonicalFile { + t.Fatalf("file = %q, want canonical %q", gotFile, canonicalFile) + } + }) + } + }) + + t.Run("absolute importer outside repository fails", func(t *testing.T) { + file := filepath.Join(t.TempDir(), "outside.go") + if err := os.WriteFile(file, []byte("package outside\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, _, err := resolveImportersInvocation("", file, false); err == nil || + !strings.Contains(err.Error(), "is not inside a Git repository") { + t.Fatalf("resolveImportersInvocation() error = %v, want repository error", err) + } + }) +} + +func TestAbsoluteImporterInfersProjectRootFromUnrelatedDirectory(t *testing.T) { + if !scanner.NewAstGrepAnalyzer().Available() { + t.Skip("ast-grep not available") + } + + repo := makeMainGitRepo(t, "main") + canonicalRepo, err := filepath.EvalSymlinks(repo) + if err != nil { + t.Fatal(err) + } + out, err := runRootOptionsBinary( + t.TempDir(), + "--json", + "--importers", filepath.Join(repo, "main.go"), + ) + if err != nil { + t.Fatalf("codemap failed: %v\n%s", err, out) + } + + var report scanner.ImportersReport + if err := json.Unmarshal([]byte(out), &report); err != nil { + t.Fatalf("decode importers report: %v\n%s", err, out) + } + if report.Root != canonicalRepo { + t.Fatalf("report root = %q, want %q", report.Root, canonicalRepo) + } + if report.File != "main.go" { + t.Fatalf("report file = %q, want main.go", report.File) + } +} + +// TestExplicitRootImportersReportUsesCanonicalRelativePath ensures an +// absolute importer with a differently spelled root still reports a +// canonical root-relative path. +func TestExplicitRootImportersReportUsesCanonicalRelativePath(t *testing.T) { + if !scanner.NewAstGrepAnalyzer().Available() { + t.Skip("ast-grep not available") + } + + repo := makeMainGitRepo(t, "main") + canonicalRepo, err := filepath.EvalSymlinks(repo) + if err != nil { + t.Fatal(err) + } + out, err := runRootOptionsBinary( + t.TempDir(), + "--json", + "--importers", filepath.Join(repo, "main.go"), + canonicalRepo, + ) + if err != nil { + t.Fatalf("codemap failed: %v\n%s", err, out) + } + + var report scanner.ImportersReport + if err := json.Unmarshal([]byte(out), &report); err != nil { + t.Fatalf("decode importers report: %v\n%s", err, out) + } + if report.Root != canonicalRepo { + t.Fatalf("report root = %q, want %q", report.Root, canonicalRepo) + } + if report.File != "main.go" { + t.Fatalf("report file = %q, want main.go (canonical-relative)", report.File) + } +} + +func TestExplicitRootImportersReportCanonicalizesSymlinkedRoot(t *testing.T) { + if !scanner.NewAstGrepAnalyzer().Available() { + t.Skip("ast-grep not available") + } + + repo := makeMainGitRepo(t, "main") + aliasParent := t.TempDir() + alias := filepath.Join(aliasParent, "repo") + if err := os.Symlink(repo, alias); err != nil { + t.Fatal(err) + } + canonicalRepo, err := filepath.EvalSymlinks(repo) + if err != nil { + t.Fatal(err) + } + out, err := runRootOptionsBinary( + t.TempDir(), + "--json", + "--importers", filepath.Join(repo, "main.go"), + alias, + ) + if err != nil { + t.Fatalf("codemap failed: %v\n%s", err, out) + } + + var report scanner.ImportersReport + if err := json.Unmarshal([]byte(out), &report); err != nil { + t.Fatalf("decode importers report: %v\n%s", err, out) + } + if report.Root != canonicalRepo || report.File != "main.go" { + t.Fatalf("report = root %q, file %q; want root %q and main.go", report.Root, report.File, canonicalRepo) + } +} + func TestRunDepsModeJSONAndMainDispatchesDepsAndImporters(t *testing.T) { if !scanner.NewAstGrepAnalyzer().Available() { t.Skip("ast-grep not available")