From bdf7d3e224294d461c9a9d88cd8a88fabcebc71f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 16:16:38 +0000 Subject: [PATCH 1/4] fix(cli): pass absolute root into the deps scan for cargo fallback runDepsMode passed the raw CLI root (e.g. ".") into the deps scan instead of absRoot. With a relative root and a degraded/absent ast-grep, buildCargoFallbackOutcome resolved each package's absolute manifest_path against that relative root, filepath.Rel errored, and every cargo-metadata package was silently dropped -- losing the recovered Rust edges and causing cargo metadata to run a second, redundant time during graph build. Pass absRoot at the call site, and absolutize root defensively at the top of scanForGraphOutcomeWithFilters. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PEUvjGsJemDFSV8nbxvxBo --- main.go | 2 +- scanner/cargofallback.go | 10 ++++++ scanner/cargofallback_test.go | 59 +++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/main.go b/main.go index 0563f78..cda48bb 100644 --- a/main.go +++ b/main.go @@ -547,7 +547,7 @@ func runDepsMode(absRoot, root string, jsonMode bool, diffRef string, changedFil externalDeps = make(map[string][]string) } } else { - outcome, err = scanForDepsOutcomeWithHint(root, filters) + outcome, err = scanForDepsOutcomeWithHint(absRoot, filters) if err != nil { if errors.Is(err, scanner.ErrAstGrepNotFound) { printAstGrepInstallHint(os.Stderr, err) diff --git a/scanner/cargofallback.go b/scanner/cargofallback.go index caae93c..b20a939 100644 --- a/scanner/cargofallback.go +++ b/scanner/cargofallback.go @@ -58,6 +58,16 @@ func scanForGraphOutcome(ctx context.Context, root string, scan dependencyOutcom } func scanForGraphOutcomeWithFilters(ctx context.Context, root string, filters Filters, scan dependencyOutcomeScanner, loader cargoMetadataLoader, allowCargoOnly bool) (ScanOutcome, bool, error) { + // The Cargo fallback resolves each package's absolute manifest_path + // against root; a relative root (e.g. "." from the CLI) makes + // filepath.Rel fail and silently drops every recovered package, so + // absolutize here even though callers are expected to pass absRoot. + absRoot, err := filepath.Abs(root) + if err != nil { + return ScanOutcome{}, false, err + } + root = absRoot + outcome, err := scan(root) degraded := false if err == nil { diff --git a/scanner/cargofallback_test.go b/scanner/cargofallback_test.go index 38cecb1..4d328c4 100644 --- a/scanner/cargofallback_test.go +++ b/scanner/cargofallback_test.go @@ -565,6 +565,65 @@ func TestCargoFallbackPropagatesCanceledDiscovery(t *testing.T) { } } +func TestScanForGraphOutcomeCargoFallbackResolvesRelativeRoot(t *testing.T) { + // A relative root (e.g. "." from `codemap --deps .`) must recover the + // same Cargo fallback edges as an absolute one: rustPackageFromCargoMetadata + // resolves each package's absolute manifest_path against root via + // projectRelativePath, which errors on filepath.Rel(".", "/abs/..."). + root, metadata := cargoFallbackFixture(t, map[string]any{ + "name": "core", "path": "core", "kind": nil, + }) + writeRustCargoFixture(t, root, map[string]string{ + "main.go": "package main\n\nimport \"fmt\"\n\nfunc main() { fmt.Println(\"x\") }\n", + }) + t.Chdir(root) + + outcome, usedFallback, err := scanForGraphOutcomeWithFilters( + context.Background(), + ".", + Filters{}, + func(string) (ScanOutcome, error) { + return ScanOutcome{}, newIncompleteScanError("ast-grep", ScanSourceUnavailable, "ast-grep unavailable", ErrAstGrepNotFound) + }, + func(context.Context, string) ([]byte, error) { + return metadata, nil + }, + true, + ) + if err != nil { + t.Fatalf("scanForGraphOutcomeWithFilters with relative root: %v", err) + } + if !usedFallback { + t.Fatal("expected fallback to be used") + } + + var cargoStatus ScanSourceStatus + found := false + for _, source := range outcome.Sources { + if source.Name == "cargo-metadata" { + cargoStatus = source.Status + found = true + } + } + if !found { + t.Fatalf("no cargo-metadata source in outcome: %#v", outcome.Sources) + } + if cargoStatus != ScanSourceFallback { + t.Fatalf("cargo-metadata status = %q, want %q", cargoStatus, ScanSourceFallback) + } + + wantEdge := fileEdge{from: "app/src/lib.rs", to: "core/src/lib.rs"} + edgeFound := false + for _, edge := range outcome.precomputedEdges { + if edge == wantEdge { + edgeFound = true + } + } + if !edgeFound { + t.Fatalf("precomputed edges = %#v, want cross-crate edge %#v", outcome.precomputedEdges, wantEdge) + } +} + func TestCargoFallbackAcceptsDevDependencyFromLibraryTarget(t *testing.T) { // #98 merged all-non-build-target dev-dependency resolution; the fallback // must treat dev dependencies on source targets as proven edges. From 7939a22b4b9f69fc486bed67f69bac87915bdcaf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 16:19:45 +0000 Subject: [PATCH 2/4] fix(scanner): exclude hidden dirs and nested repos from Go fallback scanForGraphOutcomeWithFilters fed the Go fallback from ScanFiles, which -- unlike the ast-grep primary -- does not skip dot-prefixed directories or nested git repos (findNestedGitRepos exists precisely for that). With ast-grep unavailable, hidden dirs and nested repos contributed phantom Go analyses, inflating importers/hub flags beyond what the primary would report. buildGoFallbackOutcome now drops files under a dot-prefixed directory component or a nested-repo subtree before parsing them. Relates to #131 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PEUvjGsJemDFSV8nbxvxBo --- scanner/cargofallback_test.go | 46 +++++++++++++++++++++++++++++++++++ scanner/gofallback.go | 28 +++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/scanner/cargofallback_test.go b/scanner/cargofallback_test.go index 4d328c4..6ddad10 100644 --- a/scanner/cargofallback_test.go +++ b/scanner/cargofallback_test.go @@ -109,6 +109,52 @@ func TestScanForGraphOutcomeUsesGoFallbackWithoutCargo(t *testing.T) { } } +func TestScanForGraphOutcomeGoFallbackExcludesHiddenAndNestedRepoFiles(t *testing.T) { + // The Go fallback must scan the same file universe as the ast-grep + // primary: hidden directories and nested git repos are importer-eligible + // under plain ScanFiles but ast-grep (and findNestedGitRepos) exclude + // them, so leaving them in produces phantom importers. + root := t.TempDir() + writeRustCargoFixture(t, root, map[string]string{ + "go.mod": "module example.com/demo\n\ngo 1.22\n", + "lib/lib.go": "package lib\n\nfunc Foo() {}\n", + "main.go": "package main\n\nimport \"example.com/demo/lib\"\n\nfunc main() { lib.Foo() }\n", + ".tools/gen.go": "package tools\n\nimport \"example.com/demo/lib\"\n\nfunc Gen() { lib.Foo() }\n", + "sub/cmd/x.go": "package cmd\n\nimport \"example.com/demo/lib\"\n\nfunc X() { lib.Foo() }\n", + }) + // sub/ is its own nested git repo, mirroring findNestedGitRepos' target. + if err := os.MkdirAll(filepath.Join(root, "sub", ".git"), 0o755); err != nil { + t.Fatal(err) + } + + outcome, usedFallback, err := scanForGraphOutcome( + context.Background(), + root, + func(string) (ScanOutcome, error) { + return ScanOutcome{}, newIncompleteScanError("ast-grep", ScanSourceUnavailable, "ast-grep unavailable", ErrAstGrepNotFound) + }, + func(context.Context, string) ([]byte, error) { + return nil, errors.New("unexpected Cargo fallback") + }, + false, + ) + if err != nil { + t.Fatal(err) + } + if !usedFallback { + t.Fatal("Go-only recovery did not report fallback use") + } + + var paths []string + for _, analysis := range outcome.Analyses { + paths = append(paths, filepath.ToSlash(analysis.Path)) + } + want := []string{"lib/lib.go", "main.go"} + if !reflect.DeepEqual(paths, want) { + t.Fatalf("analysis paths = %v, want %v (.tools/gen.go and sub/cmd/x.go must not leak into the Go fallback)", paths, want) + } +} + func TestScanForGraphOutcomeCombinesGoAndCargoFallbacks(t *testing.T) { root, metadata := cargoFallbackFixture(t, map[string]any{ "name": "core", "path": "core", "kind": nil, diff --git a/scanner/gofallback.go b/scanner/gofallback.go index c5cec66..182060e 100644 --- a/scanner/gofallback.go +++ b/scanner/gofallback.go @@ -24,7 +24,15 @@ func buildGoFallbackOutcome(ctx context.Context, root string, files []FileInfo) skipped := 0 fset := token.NewFileSet() + // Unlike ScanFiles, the ast-grep primary excludes hidden directories and + // nested git repos (its own ignore behavior, plus findNestedGitRepos' + // --globs excludes); mirror that here so the fallback doesn't contribute + // phantom importers from files the primary would never have seen. + nestedRepos := findNestedGitRepos(root) for _, file := range files { + if goFallbackFileExcluded(file.Path, nestedRepos) { + continue + } if !strings.EqualFold(filepath.Ext(file.Path), ".go") { continue } @@ -82,3 +90,23 @@ func buildGoFallbackOutcome(ctx context.Context, root string, files []FileInfo) }}, }, nil } + +// goFallbackFileExcluded reports whether path lies in a hidden directory or +// under one of the given nested-repo subtrees, matching what the ast-grep +// primary would have skipped. +func goFallbackFileExcluded(path string, nestedRepos []string) bool { + dir := filepath.Dir(path) + if dir != "." { + for _, part := range strings.Split(dir, string(filepath.Separator)) { + if strings.HasPrefix(part, ".") { + return true + } + } + } + for _, repo := range nestedRepos { + if path == repo || strings.HasPrefix(path, repo+string(filepath.Separator)) { + return true + } + } + return false +} From d08d7be90c5e68775209d96f2a512a048c67ff37 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 16:25:38 +0000 Subject: [PATCH 3/4] fix(scanner): don't resolve self/super rust-use paths inside inline mods `use super::{a, b};` inside an inline module (e.g. `#[cfg(test)] mod tests { ... }`) is relative to that module, not the file, but expansion treated it as file-relative and could resolve `super::x` to an unrelated sibling file that happens to share x's name. Split rust-use-imports into an outside-mod_item rule (unchanged behavior) and an inside-mod_item variant whose handling drops self/super-rooted paths instead of resolving them -- a missed edge beats a wrong one. crate::-rooted paths mean the same thing everywhere and still resolve inside inline modules. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PEUvjGsJemDFSV8nbxvxBo --- scanner/astgrep.go | 16 +++++++++---- scanner/rustuse.go | 8 +++++++ scanner/rustuse_test.go | 50 +++++++++++++++++++++++++++++++++++++++ scanner/sg-rules/rust.yml | 28 ++++++++++++++++++---- 4 files changed, 93 insertions(+), 9 deletions(-) diff --git a/scanner/astgrep.go b/scanner/astgrep.go index cede0d3..4157599 100644 --- a/scanner/astgrep.go +++ b/scanner/astgrep.go @@ -421,7 +421,7 @@ func (s *AstGrepScanner) scanDirectory(parent context.Context, root string) ([]F } } - if m.RuleID == "rust-mod-imports" || m.RuleID == "rust-path-module-imports" || m.RuleID == "rust-path-imports" || m.RuleID == "rust-use-imports" || m.RuleID == "rust-askama-template-imports" || m.RuleID == "rust-include-imports" || m.RuleID == "rust-embedded-file-imports" || m.RuleID == "rust-cargo-rerun-imports" { + if m.RuleID == "rust-mod-imports" || m.RuleID == "rust-path-module-imports" || m.RuleID == "rust-path-imports" || m.RuleID == "rust-use-imports" || m.RuleID == "rust-use-imports-nested" || m.RuleID == "rust-askama-template-imports" || m.RuleID == "rust-include-imports" || m.RuleID == "rust-embedded-file-imports" || m.RuleID == "rust-cargo-rerun-imports" { var path string var explicitTarget string kind := "rust-path" @@ -441,11 +441,19 @@ func (s *AstGrepScanner) scanDirectory(parent context.Context, root string) ([]F } case "rust-path-imports": path = m.Text - case "rust-use-imports": + case "rust-use-imports", "rust-use-imports-nested": if pathVar, ok := m.MetaVariables.Single["PATH"]; ok { path = pathVar.Text } - if len(expandRustUseReferencePaths(path)) > 0 || strings.ContainsAny(path, "{}") { + if m.RuleID == "rust-use-imports-nested" && rustUseIsSelfOrSuperRooted(path) { + // `use self::…`/`use super::…` inside an inline module + // (`mod tests { … }`) is relative to that module, not the + // file, and resolving it file-relative produced false + // edges. A missed edge beats a wrong one, so drop it; + // `use crate::…` means the same thing everywhere and + // still resolves below. + path = "" + } else if len(expandRustUseReferencePaths(path)) > 0 || strings.ContainsAny(path, "{}") { // Unexpandable brace trees stay rust-use; raw braces must not // create crate-root edges. kind = "rust-use" @@ -477,7 +485,7 @@ func (s *AstGrepScanner) scanDirectory(parent context.Context, root string) ([]F } } if path != "" { - if m.RuleID != "rust-path-imports" && m.RuleID != "rust-askama-template-imports" && m.RuleID != "rust-embedded-file-imports" && m.RuleID != "rust-cargo-rerun-imports" { + if m.RuleID != "rust-path-imports" && m.RuleID != "rust-use-imports-nested" && m.RuleID != "rust-askama-template-imports" && m.RuleID != "rust-embedded-file-imports" && m.RuleID != "rust-cargo-rerun-imports" { fileMap[relPath].Imports = append(fileMap[relPath].Imports, path) } fileMap[relPath].References = append(fileMap[relPath].References, ImportReference{ diff --git a/scanner/rustuse.go b/scanner/rustuse.go index 5bbdbc2..acf7674 100644 --- a/scanner/rustuse.go +++ b/scanner/rustuse.go @@ -27,6 +27,14 @@ func expandRustUseReferencePaths(path string) []string { return expandRustUseTreePaths(path) } +// rustUseIsSelfOrSuperRooted reports whether a use-tree path's root is +// `self` or `super`, which resolve relative to the enclosing module rather +// than the file. +func rustUseIsSelfOrSuperRooted(path string) bool { + root := rustUseRoot(strings.TrimSpace(path)) + return root == "self" || root == "super" +} + func rustUseRoot(path string) string { if end := strings.IndexAny(path, ":{ \r\n"); end >= 0 { return path[:end] diff --git a/scanner/rustuse_test.go b/scanner/rustuse_test.go index 09a55c8..a5f82f5 100644 --- a/scanner/rustuse_test.go +++ b/scanner/rustuse_test.go @@ -339,6 +339,56 @@ func TestRustUseMalformedTreesDoNotEmitCrateRootEdge(t *testing.T) { } } +func TestRustUseSuperInsideInlineModDoesNotCreateFalseEdge(t *testing.T) { + astScanner, err := NewAstGrepScanner() + if err != nil { + t.Fatal(err) + } + t.Cleanup(astScanner.Close) + if !astScanner.Available() { + t.Skip("ast-grep not available") + } + + root := t.TempDir() + writeRustCargoFixture(t, root, map[string]string{ + "Cargo.toml": "[package]\nname = \"foocrate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + "src/lib.rs": "pub mod config;\npub mod foo;\npub mod control;\n", + "src/config.rs": "pub fn value() -> i32 { 1 }\n", + // foo's own `config` function shadows the sibling `config` module + // from `tests`' point of view: `super::config` there means foo's fn, + // not src/config.rs. + "src/foo.rs": "pub fn config() -> i32 { 42 }\n\npub fn other() -> i32 { 7 }\n\n" + + "#[cfg(test)]\nmod tests {\n use super::{config, other};\n\n" + + " fn use_both() -> i32 { config() + other() }\n}\n", + // Control: a plain top-level (non-nested) crate-rooted use still + // resolves to the sibling module. + "src/control.rs": "use crate::config;\n\npub fn call() -> i32 { config::value() }\n", + }) + + outcome, err := astScanner.ScanDirectory(context.Background(), root) + if err != nil { + t.Fatal(err) + } + metadata := cargoMetadataJSON(t, root, []map[string]any{ + cargoPackage(root, ".", "foocrate", "foocrate", nil), + }) + graph, err := buildFileGraphFromAnalysesWithCargoMetadata(context.Background(), root, outcome.Analyses, func(context.Context, string) ([]byte, error) { + return metadata, nil + }) + if err != nil { + t.Fatal(err) + } + + for _, target := range graph.Imports["src/foo.rs"] { + if target == "src/config.rs" { + t.Fatalf("src/foo.rs imports = %#v, want no edge to src/config.rs (super::config inside mod tests is foo's own fn)", graph.Imports["src/foo.rs"]) + } + } + if got, want := graph.Imports["src/control.rs"], []string{"src/config.rs"}; !reflect.DeepEqual(got, want) { + t.Fatalf("control top-level crate-rooted use = %#v, want %#v", got, want) + } +} + func TestExpandRustUsePathsBoundedAtMaxDepth(t *testing.T) { if paths, ok := expandRustUseTree("a::{b}", "", maxRustUseTreeDepth+1); ok || paths != nil { t.Fatalf("expected depth bound to reject, got ok=%v paths=%v", ok, paths) diff --git a/scanner/sg-rules/rust.yml b/scanner/sg-rules/rust.yml index c2d9a67..2ca5bdd 100644 --- a/scanner/sg-rules/rust.yml +++ b/scanner/sg-rules/rust.yml @@ -1,11 +1,29 @@ id: rust-use-imports language: rust rule: - any: - - pattern: use $PATH; - - pattern: use $PATH::$$$_; - - pattern: pub use $PATH; - - pattern: pub use $PATH::$$$_; + all: + - any: + - pattern: use $PATH; + - pattern: use $PATH::$$$_; + - pattern: pub use $PATH; + - pattern: pub use $PATH::$$$_; + - not: + inside: + kind: mod_item + stopBy: end +--- +id: rust-use-imports-nested +language: rust +rule: + all: + - any: + - pattern: use $PATH; + - pattern: use $PATH::$$$_; + - pattern: pub use $PATH; + - pattern: pub use $PATH::$$$_; + - inside: + kind: mod_item + stopBy: end --- id: rust-mod-imports language: rust From 2b093ba4bbc2d0ec7b7c8c8cd5370e0784d0c3bd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 16:29:32 +0000 Subject: [PATCH 4/4] fix(scanner): stop rust-use imports leak and fix byExact exact-count (a) The imports-append exclusion list excluded askama, cargo-rerun, and embedded-file kinds but not rust-use-imports, so raw brace-tree text (e.g. crate::{a::one, b::two}) shipped verbatim in the versioned imports array and fed --diff's basename matching as noise. Rust graph edges come from References, which are unaffected. (b) resolveRustBuildScriptInput and resolveRustAskamaTemplate used the naive `len(idx.byExact[target]) != 1` shape, which drops a legitimate target whenever a sibling shares its extension-stripped key (e.g. data.json + data.json.gz). Both now use the exact-count idiom already used by resolveRustInclude and resolveRustEmbeddedFile, preserving each function's other guards. Relates to #130 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PEUvjGsJemDFSV8nbxvxBo --- scanner/astgrep.go | 2 +- scanner/rustaskama_test.go | 31 +++++++++++++++++++++++++++++++ scanner/rustbuildscript.go | 14 ++++++++++++-- scanner/rustbuildscript_test.go | 33 +++++++++++++++++++++++++++++++++ scanner/rustgraph.go | 13 ++++++++++--- scanner/rustuse_test.go | 12 ++++++++++++ 6 files changed, 99 insertions(+), 6 deletions(-) diff --git a/scanner/astgrep.go b/scanner/astgrep.go index 4157599..60298fe 100644 --- a/scanner/astgrep.go +++ b/scanner/astgrep.go @@ -485,7 +485,7 @@ func (s *AstGrepScanner) scanDirectory(parent context.Context, root string) ([]F } } if path != "" { - if m.RuleID != "rust-path-imports" && m.RuleID != "rust-use-imports-nested" && m.RuleID != "rust-askama-template-imports" && m.RuleID != "rust-embedded-file-imports" && m.RuleID != "rust-cargo-rerun-imports" { + if m.RuleID != "rust-path-imports" && m.RuleID != "rust-use-imports" && m.RuleID != "rust-use-imports-nested" && m.RuleID != "rust-askama-template-imports" && m.RuleID != "rust-embedded-file-imports" && m.RuleID != "rust-cargo-rerun-imports" { fileMap[relPath].Imports = append(fileMap[relPath].Imports, path) } fileMap[relPath].References = append(fileMap[relPath].References, ImportReference{ diff --git a/scanner/rustaskama_test.go b/scanner/rustaskama_test.go index f760997..a1e6ad1 100644 --- a/scanner/rustaskama_test.go +++ b/scanner/rustaskama_test.go @@ -136,6 +136,37 @@ func TestRustAskamaTemplateResolvesWithinCargoPackage(t *testing.T) { } } +func TestRustAskamaTemplateResolvesWithExtensionSibling(t *testing.T) { + // idx.byExact also indexes files under their extension-stripped key, so + // "app/templates/template.html.orig" appears under the same + // "app/templates/template.html" key as the real target. + root := t.TempDir() + writeRustCargoFixture(t, root, map[string]string{ + "Cargo.toml": "[package]\nname = \"app\"\nversion = \"0.1.0\"\n", + "src/lib.rs": "pub fn app() {}\n", + "templates/template.html": "real template\n", + "templates/template.html.orig": "backup\n", + }) + metadata := cargoMetadataJSON(t, root, []map[string]any{ + cargoPackage(root, ".", "app", "app", nil), + }) + analyses := []FileAnalysis{ + {Path: "src/lib.rs", Language: "rust", References: []ImportReference{ + {Path: `"template.html"`, Kind: "rust-askama-template", ExplicitTarget: `"template.html"`}, + }}, + {Path: "templates/template.html", Language: "html"}, + {Path: "templates/template.html.orig", Language: ""}, + } + graph, err := buildFileGraphFromAnalysesWithCargoMetadata(context.Background(), root, analyses, + func(context.Context, string) ([]byte, error) { return metadata, nil }) + if err != nil { + t.Fatal(err) + } + if got, want := sortedImports(graph, "src/lib.rs"), []string{"templates/template.html"}; !reflect.DeepEqual(got, want) { + t.Fatalf("Askama imports = %#v, want %#v", got, want) + } +} + func TestRustAskamaTemplateRequiresAuthoritativeUnambiguousTarget(t *testing.T) { idx := &fileIndex{byExact: map[string][]string{ filepath.FromSlash("app/templates/page.html"): {"app/templates/page.html", "app/templates/page.html"}, diff --git a/scanner/rustbuildscript.go b/scanner/rustbuildscript.go index 5d4536c..03b67ec 100644 --- a/scanner/rustbuildscript.go +++ b/scanner/rustbuildscript.go @@ -38,8 +38,18 @@ func resolveRustBuildScriptInput(fromFile, input string, idx *fileIndex, workspa return "" } candidate := filepath.Clean(filepath.Join(pkg.root, path)) - files := idx.byExact[candidate] - if len(files) != 1 || files[0] != candidate || candidate == fromFile { + if candidate == fromFile { + return "" + } + // byExact also indexes files under their extension-stripped key, so + // accept only when the target itself is indexed exactly once. + exact := 0 + for _, file := range idx.byExact[candidate] { + if file == candidate { + exact++ + } + } + if exact != 1 { return "" } return candidate diff --git a/scanner/rustbuildscript_test.go b/scanner/rustbuildscript_test.go index 8654e27..2ab93d5 100644 --- a/scanner/rustbuildscript_test.go +++ b/scanner/rustbuildscript_test.go @@ -124,6 +124,39 @@ func TestRustBuildScriptResolvesStaticCargoInputs(t *testing.T) { } } +func TestRustBuildScriptResolvesTargetWithExtensionSibling(t *testing.T) { + // idx.byExact also indexes files under their extension-stripped key, so + // "app/data.json.gz" appears under the same "app/data.json" key as the + // real target. The real directive must still resolve. + root := t.TempDir() + writeRustCargoFixture(t, root, map[string]string{ + "Cargo.toml": "[package]\nname = \"app\"\nversion = \"0.1.0\"\nbuild = \"build.rs\"\n", + "build.rs": "fn main() {}\n", + "data.json": "{}\n", + "data.json.gz": "not really gzip\n", + }) + + metadata := cargoMetadataJSON(t, root, []map[string]any{ + cargoPackageWithTargets(root, ".", "app", []map[string]any{ + cargoTargetJSON(root, "build.rs", "build-script-build", rustTargetCustomBuild), + }, nil), + }) + graph, err := buildFileGraphFromAnalysesWithCargoMetadata( + context.Background(), + root, + []FileAnalysis{{Path: "build.rs", Language: "rust", References: []ImportReference{ + {Path: "data.json", Kind: "rust-build-input"}, + }}}, + func(context.Context, string) ([]byte, error) { return metadata, nil }, + ) + if err != nil { + t.Fatal(err) + } + if got, want := graph.Imports["build.rs"], []string{"data.json"}; !reflect.DeepEqual(got, want) { + t.Fatalf("build script inputs = %#v, want %#v", got, want) + } +} + func TestRustBuildScriptInputsRequireCustomBuildTarget(t *testing.T) { root := t.TempDir() writeRustCargoFixture(t, root, map[string]string{ diff --git a/scanner/rustgraph.go b/scanner/rustgraph.go index da06e98..52561e6 100644 --- a/scanner/rustgraph.go +++ b/scanner/rustgraph.go @@ -667,11 +667,18 @@ func resolveRustAskamaTemplate(root, fromFile, literal string, idx *fileIndex, w if !pathWithin(target, templateRoot) { return "" } - files := idx.byExact[target] - if len(files) != 1 || files[0] != target { + // byExact also indexes files under their extension-stripped key, so + // accept only when the target itself is indexed exactly once. + exact := 0 + for _, file := range idx.byExact[target] { + if file == target { + exact++ + } + } + if exact != 1 { return "" } - return files[0] + return target } func resolveRustReferences(root string, analysis FileAnalysis, idx *fileIndex, workspace *rustWorkspaceIndex) []string { diff --git a/scanner/rustuse_test.go b/scanner/rustuse_test.go index a5f82f5..0c15330 100644 --- a/scanner/rustuse_test.go +++ b/scanner/rustuse_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "reflect" "sort" + "strings" "testing" "time" ) @@ -123,6 +124,17 @@ func TestAstGrepRustUseTreeExtraction(t *testing.T) { if !reflect.DeepEqual(got, want) { t.Fatalf("whole Rust use references = %#v, want %#v", got, want) } + + // Rust graph edges come from References, not Imports; the raw brace text + // must not leak into the versioned imports array, where --diff's + // basename matching would treat it as noise. + for _, analysis := range outcome.Analyses { + for _, imp := range analysis.Imports { + if strings.ContainsAny(imp, "{}") { + t.Fatalf("Imports leaked raw rust-use brace text: %q in %#v", imp, analysis.Imports) + } + } + } } func TestRustUseReferencesResolveCurrentPackageLibraryFromBinary(t *testing.T) {