diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0870d8df4..ec479a9c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,12 +108,20 @@ jobs: # filesystem path (filepath.Join / filepath.Clean) or a '/'-keyed store # path. ParseDiffGitPaths, ParseDiffLinesNewSide and FeedbackDir all # assert on native paths, so they belong here rather than nowhere. + # The path-domain contracts belong here for the same reason. A graph + # key is "/" plus a native-separator remainder, so GraphKey, + # JoinFileNodes, the prefix-shadow end-to-end pair and RankFileRisk's + # per-domain normalization only diverge once '/' and the native + # separator differ — every one of them passes on the linux/macos + # matrix whatever the code does. internal/review joins the list + # because the file-risk normalizer lives there. - name: Test native-separator store path comparisons run: > go test -timeout=10m -count=1 - -run 'NativeSeparator|MixedSeparator|ImportAdjacency|ParseDiffGitPaths|ParseDiffLinesNewSide|FeedbackDir' + -run 'NativeSeparator|MixedSeparator|ImportAdjacency|ParseDiffGitPaths|ParseDiffLinesNewSide|FeedbackDir|ReviewRulepack|PrefixedGraphReportsRulepack|GraphKey|JoinFileNodes|ChangedSymbolsForFiles|PrefixShadow|IdsOnPrefixedGraph|RankFileRisk|PrefixedDeleteAndRename' ./internal/analysis ./internal/graph/store_sqlite ./internal/mcp ./internal/resolver ./internal/persistence + ./internal/review build-linux-static: # The linux release ships a statically linked binary so it runs on any diff --git a/internal/analysis/diffmap.go b/internal/analysis/diffmap.go index af0476a09..165338333 100644 --- a/internal/analysis/diffmap.go +++ b/internal/analysis/diffmap.go @@ -101,36 +101,65 @@ func MapGitDiff(g graph.Store, repoRoot, repoPrefix, scope, baseRef string) (*Di return joinHunksToSymbols(g, repoPrefix, hunks, files), nil } -// JoinFileNodes maps one repo-relative changed-file path to the graph nodes -// its file defines. Indexed file paths carry the repo prefix in multi-repo -// mode ("/") while git and forge APIs emit repo-relative paths, -// so the raw lookup is tried first (single-repo / already-prefixed input) -// and the prefixed form second. -func JoinFileNodes(g graph.Store, repoPrefix, path string) []*graph.Node { - if nodes := g.GetFileNodes(path); len(nodes) > 0 { - return nodes - } - if repoPrefix == "" || strings.HasPrefix(path, repoPrefix+"/") { - return nil - } - return g.GetFileNodes(repoPrefix + "/" + path) -} +// PathDomain names the vocabulary a file path is spelled in. The two overlap, +// so the domain has to travel with the path rather than be recovered from it: +// in a repo whose tree carries a top-level directory named like the repo +// prefix, "repo-a/pkg/widget.go" is a well-formed path in BOTH domains — a +// git-relative path naming the nested file, and the graph key of the entirely +// different top-level "pkg/widget.go". +type PathDomain int + +const ( + // RepoRelativePath is git's and a forge's spelling, relative to the work + // tree. Every changed-file list in this package is this domain. + RepoRelativePath PathDomain = iota + // GraphKeyedPath is the graph's own node key: "/" in + // multi-repo mode, the bare relative path when no prefix applies. + GraphKeyedPath +) -// JoinFilePath returns the graph-keyed variant of a repo-relative file path: -// the raw path when it resolves (or no prefix applies), otherwise the prefixed -// form when that resolves. Falls back to the raw path when neither does, so -// the caller's downstream lookup misses exactly as it would have anyway. -func JoinFilePath(g graph.Store, repoPrefix, path string) string { - if repoPrefix == "" || strings.HasPrefix(path, repoPrefix+"/") { +// GraphKey renders path as the graph keys it. A repo-relative path is prefixed +// unconditionally — never conditionally on how it happens to be spelled — so a +// path that merely looks prefixed cannot be resolved against the wrong file. +func GraphKey(repoPrefix, path string, domain PathDomain) string { + if domain == GraphKeyedPath { return path } - if len(g.GetFileNodes(path)) > 0 { - return path + // A repo-relative path is always converted, prefix or not. The key's shape + // is "/" + the remainder in the indexing machine's native + // separators (see internal/graphpath); with no prefix the remainder IS the + // key, and a '/'-spelled git or forge path still misses a key stored with + // native separators on Windows. FromSlash is the identity on POSIX. + key := filepath.FromSlash(path) + if repoPrefix == "" { + return key } - if prefixed := repoPrefix + "/" + path; len(g.GetFileNodes(prefixed)) > 0 { - return prefixed + return repoPrefix + "/" + key +} + +// RepoRelPath is GraphKey's inverse: the repo-relative '/' spelling that +// CODEOWNERS rules, forge comment APIs and report rows speak. Stripping the +// prefix is safe here and only here, because a GraphKeyedPath carries it by +// construction — the same strip applied to a repo-relative path is the +// prefix-shadow bug. +func RepoRelPath(repoPrefix, path string, domain PathDomain) string { + rel := filepath.ToSlash(path) + if domain == GraphKeyedPath && repoPrefix != "" { + rel = strings.TrimPrefix(rel, repoPrefix+"/") } - return path + return rel +} + +// JoinFileNodes maps one changed-file path to the graph nodes its file +// defines. domain states which vocabulary path is in; see PathDomain for why +// it cannot be inferred. +// +// The previous contract tried the raw key first and the prefixed key second, +// which resolved a legitimate git-relative "/" against the +// same-named top-level file instead — and returned nil when that shadow did +// not exist, never trying the real key. +func JoinFileNodes(g graph.Store, repoPrefix, path string, domain PathDomain) []*graph.Node { + return g.GetFileNodes(GraphKey(repoPrefix, path, domain)) } // joinHunksToSymbols builds the hunk→symbol/file join shared by MapGitDiff @@ -162,7 +191,7 @@ func joinHunksToSymbols(g graph.Store, repoPrefix string, hunks []DiffHunk, file fileSet[hunk.FilePath] = true // Find symbols whose line range overlaps the hunk - for _, n := range JoinFileNodes(g, repoPrefix, hunk.FilePath) { + for _, n := range JoinFileNodes(g, repoPrefix, hunk.FilePath, RepoRelativePath) { // Check if symbol's line range overlaps with the hunk if n.StartLine <= hunk.EndLine && n.EndLine >= hunk.StartLine { addSymbol(n) @@ -187,7 +216,7 @@ func joinHunksToSymbols(g graph.Store, repoPrefix string, hunks []DiffHunk, file continue } fileSet[vanished] = true - for _, n := range JoinFileNodes(g, repoPrefix, vanished) { + for _, n := range JoinFileNodes(g, repoPrefix, vanished, RepoRelativePath) { addSymbol(n) } } diff --git a/internal/analysis/diffmap_test.go b/internal/analysis/diffmap_test.go index bcf2cb4da..3d3a52bb2 100644 --- a/internal/analysis/diffmap_test.go +++ b/internal/analysis/diffmap_test.go @@ -300,43 +300,76 @@ func TestMapGitDiffMnemonicPrefixConfig(t *testing.T) { } func TestJoinFileNodes(t *testing.T) { - g := graph.New() - g.AddNode(&graph.Node{ID: "myrepo/a.go::A", Kind: graph.KindFunction, Name: "A", FilePath: "myrepo/a.go"}) - g.AddNode(&graph.Node{ID: "b.go::B", Kind: graph.KindFunction, Name: "B", FilePath: "b.go"}) + // Graph keys are "/" + the remainder in native separators, so the + // fixture is built the same way the indexer would build it. Writing the + // '/'-joined form here would pass on POSIX and describe a key that does not + // exist on Windows. + key := func(rel string) string { return "myrepo/" + filepath.FromSlash(rel) } - // Raw hit wins (single-repo / unprefixed graph). - if nodes := JoinFileNodes(g, "myrepo", "b.go"); len(nodes) != 1 || nodes[0].ID != "b.go::B" { - t.Fatalf("raw lookup should win: %#v", nodes) - } - // Relative path retries with the prefix. - if nodes := JoinFileNodes(g, "myrepo", "a.go"); len(nodes) != 1 || nodes[0].ID != "myrepo/a.go::A" { - t.Fatalf("prefixed retry should hit: %#v", nodes) - } - // Already-prefixed input does not double-prefix. - if nodes := JoinFileNodes(g, "myrepo", "myrepo/a.go"); len(nodes) != 1 || nodes[0].ID != "myrepo/a.go::A" { - t.Fatalf("already-prefixed input should hit raw: %#v", nodes) - } - // No prefix → raw only. - if nodes := JoinFileNodes(g, "", "a.go"); len(nodes) != 0 { - t.Fatalf("no-prefix miss should stay a miss: %#v", nodes) + g := graph.New() + g.AddNode(&graph.Node{ID: key("a.go") + "::A", Kind: graph.KindFunction, Name: "A", FilePath: key("a.go")}) + // The shadow: the repo's own tree carries a top-level directory named like + // the repo prefix, so "myrepo/a.go" is a well-formed path in BOTH domains — + // the git-relative spelling of this nested file, and the graph key of the + // top-level a.go above. + g.AddNode(&graph.Node{ID: key("myrepo/a.go") + "::Nested", Kind: graph.KindFunction, Name: "Nested", FilePath: key("myrepo/a.go")}) + + // A repo-relative path is prefixed unconditionally. + if nodes := JoinFileNodes(g, "myrepo", "a.go", RepoRelativePath); len(nodes) != 1 || nodes[0].ID != key("a.go")+"::A" { + t.Fatalf("repo-relative path must resolve through the prefix: %#v", nodes) + } + // The shadow case: a repo-relative path that merely LOOKS prefixed still + // gets prefixed, so it resolves to the nested file it names — not to the + // same-named top-level file it would collide with. + if nodes := JoinFileNodes(g, "myrepo", "myrepo/a.go", RepoRelativePath); len(nodes) != 1 || nodes[0].ID != key("myrepo/a.go")+"::Nested" { + t.Fatalf("prefix-shadowed repo-relative path must resolve to the nested file: %#v", nodes) + } + // A caller already holding a graph key says so, and it is used as-is. + if nodes := JoinFileNodes(g, "myrepo", key("a.go"), GraphKeyedPath); len(nodes) != 1 || nodes[0].ID != key("a.go")+"::A" { + t.Fatalf("graph-keyed path must be used verbatim: %#v", nodes) + } + // A graph key is used verbatim whether or not a prefix is supplied. + if nodes := JoinFileNodes(g, "", key("a.go"), GraphKeyedPath); len(nodes) != 1 || nodes[0].ID != key("a.go")+"::A" { + t.Fatalf("a graph key must be used verbatim with no prefix: %#v", nodes) + } + if nodes := JoinFileNodes(g, "myrepo", "missing.go", RepoRelativePath); len(nodes) != 0 { + t.Fatalf("a miss must stay a miss: %#v", nodes) + } + + // Standalone indexer: no repo prefix, but the keys are still native. A + // forge or git path arrives '/'-spelled and must still resolve. + standalone := graph.New() + nested := filepath.FromSlash("pkg/auth/x.go") + standalone.AddNode(&graph.Node{ID: nested + "::Login", Kind: graph.KindFunction, Name: "Login", FilePath: nested}) + if nodes := JoinFileNodes(standalone, "", "pkg/auth/x.go", RepoRelativePath); len(nodes) != 1 || nodes[0].ID != nested+"::Login" { + t.Fatalf("empty prefix must still convert separators: %#v", nodes) } } -func TestJoinFilePath(t *testing.T) { - g := graph.New() - g.AddNode(&graph.Node{ID: "myrepo/a.go::A", Kind: graph.KindFunction, Name: "A", FilePath: "myrepo/a.go"}) - - if got := JoinFilePath(g, "myrepo", "a.go"); got != "myrepo/a.go" { - t.Fatalf("expected prefixed path, got %q", got) - } - if got := JoinFilePath(g, "myrepo", "myrepo/a.go"); got != "myrepo/a.go" { - t.Fatalf("already-prefixed path should pass through, got %q", got) - } - if got := JoinFilePath(g, "myrepo", "missing.go"); got != "missing.go" { - t.Fatalf("unresolvable path should pass through raw, got %q", got) - } - if got := JoinFilePath(g, "", "a.go"); got != "a.go" { - t.Fatalf("no prefix should pass through, got %q", got) +func TestGraphKey(t *testing.T) { + native := func(rel string) string { return "myrepo/" + filepath.FromSlash(rel) } + for _, tc := range []struct { + name string + prefix, path string + domain PathDomain + want string + }{ + {"repo-relative gains the prefix", "myrepo", "a.go", RepoRelativePath, native("a.go")}, + {"prefix-shadowed path still gains it", "myrepo", "myrepo/a.go", RepoRelativePath, native("myrepo/a.go")}, + {"graph-keyed path is untouched", "myrepo", "myrepo/a.go", GraphKeyedPath, "myrepo/a.go"}, + {"no prefix is a no-op", "", "a.go", RepoRelativePath, "a.go"}, + // Multi-segment with no prefix: the standalone indexer's keys still + // carry native separators, so a '/'-spelled forge path must be + // converted even though there is nothing to prefix. A single-segment + // fixture cannot show this — there is no separator to get wrong. + {"no prefix still converts separators", "", "pkg/auth/x.go", RepoRelativePath, filepath.FromSlash("pkg/auth/x.go")}, + {"no prefix, graph-keyed stays verbatim", "", "pkg/auth/x.go", GraphKeyedPath, "pkg/auth/x.go"}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := GraphKey(tc.prefix, tc.path, tc.domain); got != tc.want { + t.Fatalf("GraphKey(%q, %q, %v) = %q, want %q", tc.prefix, tc.path, tc.domain, got, tc.want) + } + }) } } @@ -741,3 +774,52 @@ func TestMapGitDiffUntrackedStaysUnobserved(t *testing.T) { t.Fatalf("git diff cannot observe untracked files; ChangedFiles = %#v", res.ChangedFiles) } } + +// TestJoinHunksToSymbolsPrefixedDeleteAndRename binds the old-side join for the +// two change kinds that carry no usable hunk: a delete (no new side) and a +// rename (PreviousPath). Both resolve through the same RepoRelativePath +// contract as a hunk path, and both were previously covered only with an empty +// prefix, where the contract cannot be observed. +// +// The fixture is prefix-shadowed: the repo's own tree carries a top-level +// directory named like the repo prefix, so each old-side git path is also a +// well-formed graph key for a different, untouched file. +func TestJoinHunksToSymbolsPrefixedDeleteAndRename(t *testing.T) { + const prefix = "repo-a" + key := func(rel string) string { return prefix + "/" + filepath.FromSlash(rel) } + + g := graph.New() + add := func(rel, sym string) { + g.AddNode(&graph.Node{ + ID: key(rel) + "::" + sym, Kind: graph.KindFunction, Name: sym, + FilePath: key(rel), StartLine: 1, EndLine: 10, Language: "go", + }) + } + // Shadows: what a repo-relative old-side path would resolve to if it were + // mistaken for a graph key. + add("pkg/gone.go", "ShadowGone") + add("pkg/old.go", "ShadowOld") + // The real old-side files, nested under the prefix-named directory. + add("repo-a/pkg/gone.go", "Gone") + add("repo-a/pkg/old.go", "Old") + + res := joinHunksToSymbols(g, prefix, nil, []FileChange{ + {Path: "repo-a/pkg/gone.go", Kind: FileDeleted}, + {Path: "repo-a/pkg/new.go", PreviousPath: "repo-a/pkg/old.go", Kind: FileRenamed}, + }) + + got := map[string]bool{} + for _, cs := range res.ChangedSymbols { + got[cs.ID] = true + } + for _, want := range []string{key("repo-a/pkg/gone.go") + "::Gone", key("repo-a/pkg/old.go") + "::Old"} { + if !got[want] { + t.Fatalf("old-side symbol %q missing: %#v", want, res.ChangedSymbols) + } + } + for _, shadow := range []string{key("pkg/gone.go") + "::ShadowGone", key("pkg/old.go") + "::ShadowOld"} { + if got[shadow] { + t.Fatalf("unchanged shadow %q was taken as changed: %#v", shadow, res.ChangedSymbols) + } + } +} diff --git a/internal/mcp/tools_conflicts_test.go b/internal/mcp/tools_conflicts_test.go index 524c806e3..f70d18ad9 100644 --- a/internal/mcp/tools_conflicts_test.go +++ b/internal/mcp/tools_conflicts_test.go @@ -3,6 +3,7 @@ package mcp import ( "context" "encoding/json" + "path/filepath" "strconv" "testing" @@ -126,12 +127,17 @@ func TestCommunityConflicts_EmptyCommunityIDsIgnored(t *testing.T) { func conflictsTestServer(t *testing.T) (*Server, string, string) { t.Helper() g := graph.New() + // The returned names are the forge spelling a caller supplies; the graph + // keys use native separators, which is what the indexer writes even with + // no repo prefix. fileA := "internal/a/svc.go" fileB := "internal/b/svc.go" - idA := fileA + "::DoA" - idB := fileB + "::DoB" - g.AddNode(&graph.Node{ID: idA, Kind: graph.KindFunction, Name: "DoA", FilePath: fileA, StartLine: 1, EndLine: 5}) - g.AddNode(&graph.Node{ID: idB, Kind: graph.KindFunction, Name: "DoB", FilePath: fileB, StartLine: 1, EndLine: 5}) + storedA := filepath.FromSlash(fileA) + storedB := filepath.FromSlash(fileB) + idA := storedA + "::DoA" + idB := storedB + "::DoB" + g.AddNode(&graph.Node{ID: idA, Kind: graph.KindFunction, Name: "DoA", FilePath: storedA, StartLine: 1, EndLine: 5}) + g.AddNode(&graph.Node{ID: idB, Kind: graph.KindFunction, Name: "DoB", FilePath: storedB, StartLine: 1, EndLine: 5}) srv := NewServer(query.NewEngine(g), g, nil, nil, zap.NewNop(), nil) installCommunitiesForTest(srv, &analysis.CommunityResult{ @@ -258,8 +264,11 @@ func conflictsBudgetServer(t *testing.T) (*Server, map[string][]string, []forge. for p := 0; p < 2; p++ { prNum := c*2 + p + 1 file := "internal/x/" + comm + "_" + string(rune('a'+p)) + ".go" - id := file + "::F" - g.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: "F", FilePath: file, StartLine: 1, EndLine: 3}) + // Same split as conflictsTestServer: supplied in the forge + // spelling, stored with native separators. + stored := filepath.FromSlash(file) + id := stored + "::F" + g.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: "F", FilePath: stored, StartLine: 1, EndLine: 3}) nodeToComm[id] = comm members3[c] = append(members3[c], id) files[strconv.Itoa(prNum)] = []string{file} diff --git a/internal/mcp/tools_critique_review.go b/internal/mcp/tools_critique_review.go index e9c740fe8..8c445fbda 100644 --- a/internal/mcp/tools_critique_review.go +++ b/internal/mcp/tools_critique_review.go @@ -192,7 +192,7 @@ func (s *Server) critiqueFindingsFor(ctx context.Context, req mcp.CallToolReques if err != nil { return nil, err } - rulepack = s.reviewRulepackMatches(ctx, diff.ChangedFiles, repoPrefix, allowedRepos) + rulepack = s.reviewRulepackMatches(ctx, diff.ChangedFiles, analysis.RepoRelativePath, repoPrefix, allowedRepos) impact = s.reviewImpact(diff.ChangedSymbols) changedFiles = diff.ChangedFiles } diff --git a/internal/mcp/tools_prs.go b/internal/mcp/tools_prs.go index 2b354d1f9..ff41144e1 100644 --- a/internal/mcp/tools_prs.go +++ b/internal/mcp/tools_prs.go @@ -450,7 +450,7 @@ func (s *Server) changedSymbolsForFiles(repoPrefix string, files []string) ([]st } fileSeen[f] = true changedFiles = append(changedFiles, f) - for _, n := range analysis.JoinFileNodes(s.graph, repoPrefix, f) { + for _, n := range analysis.JoinFileNodes(s.graph, repoPrefix, f, analysis.RepoRelativePath) { if n == nil || n.Kind == graph.KindFile { continue } diff --git a/internal/mcp/tools_prs_test.go b/internal/mcp/tools_prs_test.go index 5a66725da..84cccec86 100644 --- a/internal/mcp/tools_prs_test.go +++ b/internal/mcp/tools_prs_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "path/filepath" "strconv" "testing" @@ -24,12 +25,18 @@ import ( func prToolsTestServer(t *testing.T) (*Server, string) { t.Helper() g := graph.New() + // `file` is the forge/git spelling a caller supplies; `stored` is what the + // indexer writes, which uses native separators even with no repo prefix. + // Hard-coding the '/' form as the graph key describes an index a Windows + // daemon never produces, and the join contract then cannot be observed. file := "internal/auth/login.go" - hubID := file + "::ValidateToken" - g.AddNode(&graph.Node{ID: hubID, Kind: graph.KindFunction, Name: "ValidateToken", FilePath: file, StartLine: 1, EndLine: 10}) + stored := filepath.FromSlash(file) + hubID := stored + "::ValidateToken" + g.AddNode(&graph.Node{ID: hubID, Kind: graph.KindFunction, Name: "ValidateToken", FilePath: stored, StartLine: 1, EndLine: 10}) + callerFile := filepath.FromSlash("pkg/c.go") for i := 0; i < 12; i++ { - cid := "pkg/c.go::caller" + strconv.Itoa(i) - g.AddNode(&graph.Node{ID: cid, Kind: graph.KindFunction, Name: "caller" + strconv.Itoa(i), FilePath: "pkg/c.go"}) + cid := callerFile + "::caller" + strconv.Itoa(i) + g.AddNode(&graph.Node{ID: cid, Kind: graph.KindFunction, Name: "caller" + strconv.Itoa(i), FilePath: callerFile}) g.AddEdge(&graph.Edge{From: cid, To: hubID, Kind: graph.EdgeCalls}) } srv := NewServer(query.NewEngine(g), g, nil, nil, zap.NewNop(), nil) @@ -182,8 +189,13 @@ func TestGetPRImpact_RequiresNumber(t *testing.T) { // stay raw-first for unprefixed graphs, and never double-prefix. func TestChangedSymbolsForFiles_RepoPrefixJoin(t *testing.T) { g := graph.New() - prefixedID := "myrepo/internal/auth/login.go::ValidateToken" - g.AddNode(&graph.Node{ID: prefixedID, Kind: graph.KindFunction, Name: "ValidateToken", FilePath: "myrepo/internal/auth/login.go", StartLine: 1, EndLine: 10}) + // The graph keys a file as "/" + the remainder in the indexing + // machine's native separators (see internal/graphpath), so the fixture is + // built that way. Hard-coding the '/'-joined form describes a key a + // Windows daemon never produces. + prefixedFile := "myrepo/" + filepath.FromSlash("internal/auth/login.go") + prefixedID := prefixedFile + "::ValidateToken" + g.AddNode(&graph.Node{ID: prefixedID, Kind: graph.KindFunction, Name: "ValidateToken", FilePath: prefixedFile, StartLine: 1, EndLine: 10}) srv := NewServer(query.NewEngine(g), g, nil, nil, zap.NewNop(), nil) files, nodes := srv.changedSymbolsForFiles("myrepo", []string{"internal/auth/login.go"}) @@ -197,10 +209,16 @@ func TestChangedSymbolsForFiles_RepoPrefixJoin(t *testing.T) { _, nodes = srv.changedSymbolsForFiles("", []string{"internal/auth/login.go"}) require.Empty(t, nodes) - // Already-prefixed input hits raw and is not double-prefixed. + // A forge file list is repo-relative, so a path that merely LOOKS + // prefixed is still prefixed: it names the nested file, not the + // same-named top-level one. Treating it as already-keyed used to + // resolve the wrong file — or, with no shadow present, nothing at all. + shadowFile := "myrepo/" + filepath.FromSlash("myrepo/internal/auth/login.go") + g.AddNode(&graph.Node{ID: shadowFile + "::Nested", Kind: graph.KindFunction, Name: "Nested", FilePath: shadowFile, StartLine: 1, EndLine: 10}) _, nodes = srv.changedSymbolsForFiles("myrepo", []string{"myrepo/internal/auth/login.go"}) require.Len(t, nodes, 1) - require.Equal(t, prefixedID, nodes[0].ID) + require.Equal(t, shadowFile+"::Nested", nodes[0].ID, + "a repo-relative path that looks prefixed must resolve to the nested file") } // --- list_prs: classification ---------------------------------------------- diff --git a/internal/mcp/tools_review.go b/internal/mcp/tools_review.go index 5b5196f5a..d65174241 100644 --- a/internal/mcp/tools_review.go +++ b/internal/mcp/tools_review.go @@ -16,6 +16,7 @@ import ( "github.com/zzet/gortex/internal/config" "github.com/zzet/gortex/internal/gitcmd" "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/graphpath" "github.com/zzet/gortex/internal/llm" "github.com/zzet/gortex/internal/query" "github.com/zzet/gortex/internal/review" @@ -562,7 +563,7 @@ func (s *Server) handleReview(ctx context.Context, req mcp.CallToolRequest) (*mc if err != nil { return mcp.NewToolResultError(err.Error()), nil } - rulepack = s.reviewRulepackMatches(ctx, diff.ChangedFiles, repoPrefix, allowedRepos) + rulepack = s.reviewRulepackMatches(ctx, diff.ChangedFiles, analysis.RepoRelativePath, repoPrefix, allowedRepos) impact = s.reviewImpact(diff.ChangedSymbols) changedFiles = diff.ChangedFiles } @@ -614,7 +615,10 @@ func (s *Server) handleReview(ctx context.Context, req mcp.CallToolRequest) (*mc // changed files relative to the working tree while the graph keys every file // node "/". Matches come back repo-relative — the spelling that // rule resolution, the per-file risk ranking, and the forge comment API speak. -func (s *Server) reviewRulepackMatches(ctx context.Context, changedFiles []string, repoPrefix string, allowedRepos map[string]bool) []astquery.Match { +// +// domain declares which vocabulary changedFiles is spelled in, because the two +// overlap and cannot be recovered by inspection — see reviewChangedGraphPaths. +func (s *Server) reviewRulepackMatches(ctx context.Context, changedFiles []string, domain analysis.PathDomain, repoPrefix string, allowedRepos map[string]bool) []astquery.Match { bundle := astquery.DetectorsByCategory("review") if len(bundle) == 0 { return nil @@ -627,13 +631,18 @@ func (s *Server) reviewRulepackMatches(ctx context.Context, changedFiles []strin // Narrow to the changed-file set so the rulepack only scans the changeset, // not the whole repository. - changed := reviewChangedGraphPaths(changedFiles, repoPrefix) + changed := reviewChangedGraphPaths(changedFiles, domain, repoPrefix) if len(changed) == 0 { return nil } targets := make([]astquery.Target, 0, len(allTargets)) for _, t := range allTargets { - if changed[filepath.Clean(t.GraphPath)] { + // Normalize, do not Clean. A graph path is "/" + the rest in + // native separators, so filepath.Clean rewrites the prefix slash on + // Windows ("repo-a/pkg\widget.go" -> "repo-a\pkg\widget.go") and the + // key the changeset built no longer matches. graphpath.Norm is the + // canonical comparison form and is the identity on POSIX. + if changed[graphpath.Norm(t.GraphPath)] { targets = append(targets, t) } } @@ -677,16 +686,28 @@ func (s *Server) reviewRulepackMatches(ctx context.Context, changedFiles []strin // // Only the prefixed spelling is admitted once a prefix applies — a bare // relative path would also match a same-named file in a sibling tracked repo. -// Paths that already carry the prefix pass through unchanged, so a caller -// holding graph-keyed paths (a changed symbol's FilePath) joins too. -func reviewChangedGraphPaths(changedFiles []string, repoPrefix string) map[string]bool { +// +// The caller states which vocabulary it holds; this function never guesses. +// The two domains are not distinguishable by inspection: in a repo whose tree +// carries a top-level directory named like the repo prefix, +// `repo-a/pkg/widget.go` is a valid path in *both*. Inferring "already +// prefixed" from that spelling skips the real key +// `repo-a/repo-a/pkg/widget.go`, so the changed target is missed — and when a +// same-named `pkg/widget.go` also exists, that unchanged shadow is scanned in +// its place. Admitting both candidate keys is not a fix either: it still lets +// unchanged code be selected. +func reviewChangedGraphPaths(changedFiles []string, domain analysis.PathDomain, repoPrefix string) map[string]bool { changed := make(map[string]bool, len(changedFiles)) for _, f := range changedFiles { - f = filepath.Clean(strings.TrimSpace(f)) + // Clean collapses "./" and "..", then Norm puts both vocabularies in + // the one comparison spelling — git already speaks '/', and a caller + // handing in a graph-keyed path carries native separators after the + // prefix. Identity on POSIX. + f = graphpath.Norm(filepath.Clean(strings.TrimSpace(f))) if f == "" || f == "." { continue } - if repoPrefix != "" && !strings.HasPrefix(f, repoPrefix+"/") { + if domain == analysis.RepoRelativePath && repoPrefix != "" { f = repoPrefix + "/" + f } changed[f] = true @@ -698,7 +719,12 @@ func reviewChangedGraphPaths(changedFiles []string, repoPrefix string) map[strin // repo-relative spelling the rest of the review pipeline speaks: the rule // resolver matches `.gortex.yaml` globs against it, rankFileRisk keys its rows // on it, and post_review hands it to the forge's comment API. +// Every one of those consumers speaks '/', so the result is normalized: a +// graph path carries native separators after the prefix, and handing +// `pkg\widget.go` to a `.gortex.yaml` glob or the forge comment API would +// match nothing and anchor a comment nowhere. Identity on POSIX. func reviewRepoRelPath(path, repoPrefix string) string { + path = graphpath.Norm(path) if repoPrefix == "" { return path } @@ -1101,7 +1127,7 @@ func (s *Server) handleReviewPack(ctx context.Context, req mcp.CallToolRequest) if err != nil { return mcp.NewToolResultError(err.Error()), nil } - rulepack = s.reviewRulepackMatches(ctx, diff.ChangedFiles, repoPrefix, allowedRepos) + rulepack = s.reviewRulepackMatches(ctx, diff.ChangedFiles, analysis.RepoRelativePath, repoPrefix, allowedRepos) impact = s.reviewImpact(diff.ChangedSymbols) for _, cs := range diff.ChangedSymbols { if cs.ID != "" { diff --git a/internal/mcp/tools_review_post.go b/internal/mcp/tools_review_post.go index c23f5cc28..7c5ceef1e 100644 --- a/internal/mcp/tools_review_post.go +++ b/internal/mcp/tools_review_post.go @@ -145,7 +145,7 @@ func (s *Server) postReviewFindingsFor(ctx context.Context, req mcp.CallToolRequ if err != nil { return nil, err } - rulepack = s.reviewRulepackMatches(ctx, diff.ChangedFiles, repoPrefix, allowedRepos) + rulepack = s.reviewRulepackMatches(ctx, diff.ChangedFiles, analysis.RepoRelativePath, repoPrefix, allowedRepos) impact = s.reviewImpact(diff.ChangedSymbols) changedFiles = diff.ChangedFiles } diff --git a/internal/mcp/tools_review_rulepack_test.go b/internal/mcp/tools_review_rulepack_test.go index 121b57f6f..2c7668063 100644 --- a/internal/mcp/tools_review_rulepack_test.go +++ b/internal/mcp/tools_review_rulepack_test.go @@ -3,12 +3,15 @@ package mcp import ( "context" "os" + "os/exec" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/require" "go.uber.org/zap" + "github.com/zzet/gortex/internal/analysis" "github.com/zzet/gortex/internal/config" "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/indexer" @@ -92,7 +95,7 @@ func TestReviewRulepackMatches_JoinsRepoRelativeChangedFiles(t *testing.T) { "pkg/widget.go": reviewRulepackFixture, }) - matches := srv.reviewRulepackMatches(context.Background(), []string{"pkg/widget.go"}, prefix, nil) + matches := srv.reviewRulepackMatches(context.Background(), []string{"pkg/widget.go"}, analysis.RepoRelativePath, prefix, nil) require.NotEmpty(t, matches, "repo-relative changed file must join the prefixed graph path %q/pkg/widget.go", prefix) @@ -114,10 +117,38 @@ func TestReviewRulepackMatches_IgnoresUnchangedFiles(t *testing.T) { "pkg/other.go": "package pkg\n\nfunc Other() {}\n", }) - matches := srv.reviewRulepackMatches(context.Background(), []string{"pkg/other.go"}, prefix, nil) + matches := srv.reviewRulepackMatches(context.Background(), []string{"pkg/other.go"}, analysis.RepoRelativePath, prefix, nil) require.Empty(t, matches, "a file outside the changeset must not be scanned") } +// TestReviewRulepackMatches_PrefixShadowedPathScansOnlyTheChangedTarget pins +// the case that makes inferring the path domain unsafe. The repo's own tree +// carries a top-level directory named like the repo prefix, so the changed +// git-relative path `repo-a/pkg/widget.go` is *also* a well-formed graph key +// for the different, unchanged file `pkg/widget.go`. +// +// Guessing "this already looks prefixed" skips the real key +// `repo-a/repo-a/pkg/widget.go` — the changed file is never scanned, and the +// unchanged shadow is scanned in its place. Both files carry the detector +// fixture, so a wrong-target scan still returns matches and only the reported +// path distinguishes the two outcomes. +func TestReviewRulepackMatches_PrefixShadowedPathScansOnlyTheChangedTarget(t *testing.T) { + srv, prefix := setupPrefixedReviewServer(t, map[string]string{ + "pkg/widget.go": reviewRulepackFixture, + "repo-a/pkg/widget.go": reviewRulepackFixture, + }) + require.Equal(t, "repo-a", prefix, "the fixture's shadow directory must equal the repo prefix") + + matches := srv.reviewRulepackMatches(context.Background(), + []string{"repo-a/pkg/widget.go"}, analysis.RepoRelativePath, prefix, nil) + + require.NotEmpty(t, matches, "the changed nested file must be scanned") + for _, m := range matches { + require.Equal(t, "repo-a/pkg/widget.go", m.File, + "only the changed target may be scanned; %q is the unchanged shadow", m.File) + } +} + // TestReviewRulepackMatches_AcceptsAlreadyPrefixedChangedFiles covers the // callers that hand in graph-keyed paths (a changed symbol's FilePath) rather // than git's repo-relative spelling. @@ -127,7 +158,7 @@ func TestReviewRulepackMatches_AcceptsAlreadyPrefixedChangedFiles(t *testing.T) }) matches := srv.reviewRulepackMatches(context.Background(), - []string{prefix + "/pkg/widget.go"}, prefix, nil) + []string{prefix + "/pkg/widget.go"}, analysis.GraphKeyedPath, prefix, nil) require.NotEmpty(t, matches, "an already-prefixed changed file must still join") require.Equal(t, "pkg/widget.go", matches[0].File) } @@ -167,3 +198,137 @@ func TestReview_PrefixedGraphReportsRulepackFinding(t *testing.T) { require.NotContains(t, fr.File, "svc-repo/", "risk rows stay repo-relative") } } + +// reviewShadowGitRepo builds the prefix-shadow fixture end to end: a git repo +// whose own tree carries a top-level directory named like the repo prefix, so +// the changed git-relative path `repo-a/pkg/widget.go` is simultaneously a +// well-formed graph key for the different, unchanged `pkg/widget.go`. +// +// Both files carry the flagged source at the base commit and only the nested +// one is modified. A wrong-target join therefore still produces findings — only +// the path they are attributed to separates a correct run from a broken one. +func reviewShadowGitRepo(t *testing.T) (root, changed, shadow string) { + t.Helper() + dir := filepath.Join(t.TempDir(), "repo-a") + require.NoError(t, os.MkdirAll(dir, 0o755)) + run := func(args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", + "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t", + ) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + write := func(rel, src string) { + abs := filepath.Join(dir, filepath.FromSlash(rel)) + require.NoError(t, os.MkdirAll(filepath.Dir(abs), 0o755)) + require.NoError(t, os.WriteFile(abs, []byte(src), 0o644)) + } + + run("init", "-b", "main") + run("config", "user.email", "t@t") + run("config", "user.name", "t") + run("config", "diff.noprefix", "false") + + // Flat paths on purpose. The raw-lookup defect only manifests when the + // changed git path is byte-identical to the shadow's graph key, and a + // nested spelling would differ by separator on Windows ("repo-a/pkg\widget.go" + // stored vs "repo-a/pkg/widget.go" from git) — masking the defect on this + // platform. One segment makes the two coincide everywhere. + shadow = "widget.go" + changed = "repo-a/widget.go" + + flagged := "package pkg\n\nimport \"errors\"\n\n" + + "func Load() error {\n" + + "\terr := errors.New(\"boom\")\n" + + "\tif err == nil {\n" + + "\t\treturn err\n" + + "\t}\n" + + "\treturn nil\n" + + "}\n" + + write(shadow, flagged) + write(changed, flagged) + run("add", ".") + run("commit", "-m", "base") + run("tag", "base-ref") + + // Only the nested file moves, and the edit lands INSIDE Load so the + // hunk overlaps the function's line range. A top-of-file edit would + // change the file without touching any symbol, and ChangedSymbols would + // come back empty for a reason unrelated to the join under test. + write(changed, strings.Replace(flagged, `"boom"`, `"boom-changed"`, 1)) + run("add", ".") + run("commit", "-m", "change") + return dir, changed, shadow +} + +// TestReview_PrefixShadowAttributesOnlyTheChangedFile is the end-to-end half of +// the prefix-shadow regression: it drives `review` through MapGitDiff, +// JoinFileNodes and rankFileRisk rather than calling the narrowing directly, so +// a domain collision anywhere along that path surfaces here. +func TestReview_PrefixShadowAttributesOnlyTheChangedFile(t *testing.T) { + dir, changed, shadow := reviewShadowGitRepo(t) + srv, prefix := prefixedServerOver(t, dir, "repo-a") + require.Equal(t, "repo-a", prefix) + + out := decodeReview(t, callReview(t, srv, map[string]any{ + "repo": dir, + "base": "base-ref", + })) + + require.GreaterOrEqual(t, out.Total, 1, "the changed file's planted finding must surface: %+v", out) + require.Equal(t, "BLOCK", out.Verdict) + + for _, c := range out.Comments { + require.Equal(t, changed, filepath.ToSlash(c.File), + "a finding was attributed to %q; only %q changed", c.File, changed) + } + // Exactly one row: NotEqual(shadow) would still pass if the run + // produced BOTH a graph-prefixed row and a repo-relative one for the + // same file, which is what a domain collision in rankFileRisk does. + require.Len(t, out.FileRisk, 1, + "exactly one risk row for the one changed file: %+v", out.FileRisk) + require.Equal(t, changed, filepath.ToSlash(out.FileRisk[0].File), + "the risk row must name the changed file, not the unchanged shadow %q", shadow) +} + +// TestReviewPack_PrefixShadowAttributesOnlyTheChangedFile covers the packaged +// envelope: changed symbols, per-file risk and findings must all name the +// changed nested file, never the unchanged same-named shadow. +func TestReviewPack_PrefixShadowAttributesOnlyTheChangedFile(t *testing.T) { + dir, changed, shadow := reviewShadowGitRepo(t) + srv, prefix := prefixedServerOver(t, dir, "repo-a") + require.Equal(t, "repo-a", prefix) + + out := decodeReviewPack(t, callReviewPack(t, srv, map[string]any{ + "repo": dir, + "base": "base-ref", + })) + + require.GreaterOrEqual(t, out.Total, 1, "the changed file's planted finding must surface: %+v", out) + + // ChangedSymbols are graph-keyed: the real key nests the prefix twice. + require.NotEmpty(t, out.ChangedSymbols, "the changed nested file must contribute symbols") + // The key is the graph's own spelling, not a '/'-joined guess: the + // remainder after the prefix carries native separators. + wantPrefix := analysis.GraphKey(prefix, changed, analysis.RepoRelativePath) + "::" + for _, cs := range out.ChangedSymbols { + require.Truef(t, strings.HasPrefix(cs.ID, wantPrefix), + "changed symbol %q is not under the changed file %q", cs.ID, wantPrefix) + } + for _, f := range out.Findings { + require.Equal(t, changed, filepath.ToSlash(f.File), + "a finding was attributed to %q; only %q changed", f.File, changed) + } + // Exactly one row: NotEqual(shadow) would still pass if the run + // produced BOTH a graph-prefixed row and a repo-relative one for the + // same file, which is what a domain collision in rankFileRisk does. + require.Len(t, out.FileRisk, 1, + "exactly one risk row for the one changed file: %+v", out.FileRisk) + require.Equal(t, changed, filepath.ToSlash(out.FileRisk[0].File), + "the risk row must name the changed file, not the unchanged shadow %q", shadow) +} diff --git a/internal/mcp/tools_suggest_reviewers.go b/internal/mcp/tools_suggest_reviewers.go index 7e4c064b7..74038a30b 100644 --- a/internal/mcp/tools_suggest_reviewers.go +++ b/internal/mcp/tools_suggest_reviewers.go @@ -73,7 +73,7 @@ func (s *Server) handleSuggestReviewers(ctx context.Context, req mcp.CallToolReq repo := strings.TrimSpace(req.GetString("repo", "")) repoRoot, repoPrefix := s.diffRepoScope(ctx, repo) - changedFiles, _, err := s.resolveReviewerChangeset(ctx, req, repoRoot) + changedFiles, fileDomain, _, err := s.resolveReviewerChangeset(ctx, req, repoRoot) if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -85,7 +85,13 @@ func (s *Server) handleSuggestReviewers(ctx context.Context, req mcp.CallToolReq codeownerFiles := map[string][]string{} if coFound { for _, f := range changedFiles { - owners := codeowners.MatchFile(relForRepo(f, repoRoot), coRules) + // CODEOWNERS rules are written against repo-relative paths, and a + // root-anchored rule (`/pkg/auth/`) only matches that spelling. In + // ids mode the changed files are graph keys, so relForRepo — which + // only strips an absolute repo root — would leave the repo prefix + // on and silently match nothing. + rel := analysis.RepoRelPath(repoPrefix, f, fileDomain) + owners := codeowners.MatchFile(relForRepo(rel, repoRoot), coRules) for _, owner := range owners { name := normalizeReviewer(owner) if name == "" { @@ -93,7 +99,9 @@ func (s *Server) handleSuggestReviewers(ctx context.Context, req mcp.CallToolReq } codeownerCounts[name]++ codeownerKinds[name] = classifyReviewer(owner) - codeownerFiles[name] = appendUnique(codeownerFiles[name], f) + // matched_files is reported to the caller, so it carries the + // repo-relative spelling too, not the graph key. + codeownerFiles[name] = appendUnique(codeownerFiles[name], rel) } } } @@ -104,7 +112,7 @@ func (s *Server) handleSuggestReviewers(ctx context.Context, req mcp.CallToolReq blame := blameRowsByID(s.graph) authorCounts := map[string]int{} for _, f := range changedFiles { - for _, n := range analysis.JoinFileNodes(s.graph, repoPrefix, f) { + for _, n := range analysis.JoinFileNodes(s.graph, repoPrefix, f, fileDomain) { if la, ok := lastAuthoredFrom(blame, n); ok && la.Email != "" { authorCounts[normalizeReviewer(la.Email)]++ } @@ -116,7 +124,7 @@ func (s *Server) handleSuggestReviewers(ctx context.Context, req mcp.CallToolReq // candidate experts; the count is the number of co-change links. coChangeCounts := map[string]int{} for _, f := range changedFiles { - for partner := range s.coChangeScores(analysis.JoinFilePath(s.graph, repoPrefix, f)) { + for partner := range s.coChangeScores(analysis.GraphKey(repoPrefix, f, fileDomain)) { for _, n := range s.graph.GetFileNodes(partner) { if la, ok := lastAuthoredFrom(blame, n); ok && la.Email != "" { coChangeCounts[normalizeReviewer(la.Email)]++ @@ -144,7 +152,13 @@ func (s *Server) handleSuggestReviewers(ctx context.Context, req mcp.CallToolReq // resolveReviewerChangeset turns the ids / base / number input into a set of // changed file paths (and, where available, the changed symbol IDs). Exactly // one input source is honoured, checked in ids → base → number order. -func (s *Server) resolveReviewerChangeset(ctx context.Context, req mcp.CallToolRequest, repoRoot string) (files []string, symbolIDs []string, err error) { +// +// The three sources do NOT share a path vocabulary, so the domain is returned +// with the files rather than assumed by the caller: ids yields graph node +// FilePaths, while base (git) and number (forge) yield repo-relative paths. +// Forcing one domain on all three double-prefixes the ids branch on a prefixed +// graph and silently drops the ownership and co-change signals. +func (s *Server) resolveReviewerChangeset(ctx context.Context, req mcp.CallToolRequest, repoRoot string) (files []string, domain analysis.PathDomain, symbolIDs []string, err error) { idsStr := strings.TrimSpace(req.GetString("ids", "")) base := strings.TrimSpace(req.GetString("base", "")) number := req.GetInt("number", 0) @@ -163,36 +177,37 @@ func (s *Server) resolveReviewerChangeset(ctx context.Context, req mcp.CallToolR files = append(files, n.FilePath) } } - return files, symbolIDs, nil + return files, analysis.GraphKeyedPath, symbolIDs, nil case base != "": if repoRoot == "" { - return nil, nil, fmt.Errorf("could not resolve a repository root for the base diff") + return nil, analysis.RepoRelativePath, nil, fmt.Errorf("could not resolve a repository root for the base diff") } diff, derr := analysis.MapGitDiff(s.graph, repoRoot, s.diffJoinPrefix(repoRoot), "compare", base) if derr != nil { - return nil, nil, fmt.Errorf("git diff against %q failed: %v", base, derr) + return nil, analysis.RepoRelativePath, nil, fmt.Errorf("git diff against %q failed: %v", base, derr) } for _, cs := range diff.ChangedSymbols { symbolIDs = append(symbolIDs, cs.ID) } - return diff.ChangedFiles, symbolIDs, nil + return diff.ChangedFiles, analysis.RepoRelativePath, symbolIDs, nil case number > 0: if !forge.Available(ctx) { - return nil, nil, fmt.Errorf("forge unavailable: set GH_TOKEN (or GITHUB_TOKEN) in the daemon environment to resolve PR files") + return nil, analysis.RepoRelativePath, nil, fmt.Errorf("forge unavailable: set GH_TOKEN (or GITHUB_TOKEN) in the daemon environment to resolve PR files") } if repoRoot == "" { - return nil, nil, fmt.Errorf("could not resolve a repository root for the PR file fetch") + return nil, analysis.RepoRelativePath, nil, fmt.Errorf("could not resolve a repository root for the PR file fetch") } prFiles, ferr := forge.PRFiles(ctx, repoRoot, number) if ferr != nil { - return nil, nil, fmt.Errorf("fetching files for PR #%d failed: %v", number, ferr) + return nil, analysis.RepoRelativePath, nil, fmt.Errorf("fetching files for PR #%d failed: %v", number, ferr) } - return prFiles, nil, nil + // A forge file list is repo-relative. + return prFiles, analysis.RepoRelativePath, nil, nil default: - return nil, nil, fmt.Errorf("one of ids, base, or number is required") + return nil, analysis.RepoRelativePath, nil, fmt.Errorf("one of ids, base, or number is required") } } diff --git a/internal/mcp/tools_suggest_reviewers_test.go b/internal/mcp/tools_suggest_reviewers_test.go index 17241bee4..da6a56ad3 100644 --- a/internal/mcp/tools_suggest_reviewers_test.go +++ b/internal/mcp/tools_suggest_reviewers_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" mcplib "github.com/mark3labs/mcp-go/mcp" @@ -259,3 +260,93 @@ func TestSuggestReviewers_GCXAndTOONAndBudget(t *testing.T) { require.False(t, toon.IsError) require.Contains(t, toon.Content[0].(mcplib.TextContent).Text, "reviewer") } + +// TestSuggestReviewers_IdsOnPrefixedGraphKeepsAllSignals pins the path domain +// of the ids branch. resolveReviewerChangeset's ids case returns graph node +// FilePaths, which are already graph-keyed; base and number return +// repo-relative paths. Forcing one domain on all three prefixes the ids +// entries a second time on a multi-repo graph — `repo-a/pkg/auth/login.go` +// looked up as `repo-a/repo-a/pkg/auth/login.go` — and the ownership and +// co-change signals silently disappear while CODEOWNERS (which matches on the +// repo-relative spelling) still answers, so the tool looks healthy. +// +// The existing ids tests build an unprefixed graph, where both domains +// coincide and the regression is invisible. +func TestSuggestReviewers_IdsOnPrefixedGraphKeepsAllSignals(t *testing.T) { + dir := filepath.Join(t.TempDir(), "repo-a") + write := func(rel, src string) { + abs := filepath.Join(dir, filepath.FromSlash(rel)) + require.NoError(t, os.MkdirAll(filepath.Dir(abs), 0o755)) + require.NoError(t, os.WriteFile(abs, []byte(src), 0o644)) + } + write("pkg/auth/login.go", "package auth\n\nfunc Login() error { return nil }\n") + write("pkg/util/util.go", "package util\n\nfunc Helper() error { return nil }\n") + // Root-anchored on purpose: `/pkg/auth/` matches only the repo-relative + // spelling. An unanchored `pkg/auth/` rule would also match + // `repo-a/pkg/auth/login.go`, masking a graph-keyed path reaching + // CODEOWNERS in ids mode. + write(".github/CODEOWNERS", "/pkg/auth/ @org/secteam\n") + + srv, prefix := prefixedServerOver(t, dir, "repo-a") + require.Equal(t, "repo-a", prefix) + + nodeIDNamed := func(name string) (id, file string) { + t.Helper() + for _, n := range srv.graph.FindNodesByName(name) { + if n != nil && n.Kind == graph.KindFunction { + return n.ID, n.FilePath + } + } + t.Fatalf("fixture did not index %q", name) + return "", "" + } + loginID, loginFile := nodeIDNamed("Login") + helperID, helperFile := nodeIDNamed("Helper") + + // Both file paths are graph keys, so they carry the repo prefix. That is + // exactly what the ids branch hands on. + require.True(t, strings.HasPrefix(loginFile, prefix+"/"), "fixture must be prefixed, got %q", loginFile) + + stampAuthor := func(id, email string, ts int64) { + n := srv.graph.GetNode(id) + require.NotNil(t, n, "node %q missing", id) + if n.Meta == nil { + n.Meta = map[string]any{} + } + n.Meta["last_authored"] = map[string]any{ + "email": email, "timestamp": ts, "commit": "deadbee", + } + } + stampAuthor(loginID, "alice@example.com", 1_700_000_000) + stampAuthor(helperID, "bob@example.com", 1_700_000_100) + + // login.go historically changes with util.go, so util.go's author is a + // co-change expert. The co-change index is keyed by graph path. + srv.cochangeByFile = map[string]map[string]float64{ + loginFile: {helperFile: 0.8}, + } + + res := callSuggestReviewers(t, srv, map[string]any{"ids": loginID, "repo": dir}) + require.False(t, res.IsError, "errored: %v", res) + + var out struct { + Reviewers []struct { + Reviewer string `json:"reviewer"` + Kind string `json:"kind"` + Reasons []string `json:"reasons"` + } `json:"reviewers"` + CodeownersFound bool `json:"codeowners_found"` + } + require.NoError(t, json.Unmarshal([]byte(res.Content[0].(mcplib.TextContent).Text), &out)) + + seen := map[string]bool{} + for _, r := range out.Reviewers { + seen[r.Reviewer] = true + } + require.True(t, out.CodeownersFound, "CODEOWNERS must still resolve: %+v", out) + require.True(t, seen["org/secteam"], "codeowner signal missing: %+v", out.Reviewers) + require.True(t, seen["alice@example.com"], + "recent-author signal missing — the ids branch is graph-keyed and must not be prefixed twice: %+v", out.Reviewers) + require.True(t, seen["bob@example.com"], + "co-change signal missing — the ids branch is graph-keyed and must not be prefixed twice: %+v", out.Reviewers) +} diff --git a/internal/review/report.go b/internal/review/report.go index ffc1af8fd..d8226aaad 100644 --- a/internal/review/report.go +++ b/internal/review/report.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/zzet/gortex/internal/analysis" + "github.com/zzet/gortex/internal/graphpath" ) // ReviewReport is the output of the hybrid review flow: a worst-of verdict over @@ -115,10 +116,11 @@ func worseVerdict(a, b Verdict) Verdict { // worst-first, then by file for determinism. // // repoPrefix normalizes the two path vocabularies onto one key: changed-symbol -// (and finding) paths come from graph nodes, which multi-repo daemons key as -// "/", while the diff's changed files are repo-relative. Without -// stripping the prefix every file would surface twice — once with its real -// impact tier and once as a LOW diff-only row. +// paths come from graph nodes, which multi-repo daemons key as "/", +// while findings and the diff's changed files are repo-relative. Without +// stripping the prefix off the graph-keyed side every file would surface twice — +// once with its real impact tier and once as a LOW diff-only row. The strip is +// applied per domain, never to every input: see the normalizers below. // // coverageKnown gates the coverage evidence: when the graph indexes no test // symbols at all, "no covering test" is blindness, not a finding — the rows @@ -127,13 +129,28 @@ func worseVerdict(a, b Verdict) Verdict { // a test function needs no test of its own, so counting one as missing is a // demand that can never be met. func rankFileRisk(diff *analysis.DiffResult, impact map[string]*analysis.ImpactResult, findings []Finding, repoPrefix string, coverageKnown bool) []FileRisk { - norm := func(file string) string { - file = cleanPath(file) + // Two vocabularies reach this function and they overlap, so each input is + // normalized by its own domain instead of through one shared strip. + // ChangedSymbol.FilePath is a graph node key; findings and ChangedFiles are + // already repo-relative. Stripping the prefix off a repo-relative path that + // legitimately begins with the prefix name rewrites it onto a different + // file — `repo-a/pkg/widget.go` becomes `pkg/widget.go` — and attributes the + // risk to that unchanged shadow. + fromGraphKey := func(file string) string { + // Normalize to the '/' comparison form BEFORE removing the prefix. + // cleanPath ends in filepath.Clean, so on Windows the graph key + // "repo-a/repo-a\widget.go" becomes "repo-a\repo-a\widget.go" and the + // '/'-joined prefix no longer matches: the row keeps its prefix while + // the repo-relative side produces a second row for the same file. + file = graphpath.Norm(cleanPath(file)) if repoPrefix != "" { file = strings.TrimPrefix(file, repoPrefix+"/") } return file } + // Both domains leave here in the documented repo-relative '/' spelling — + // the one the rule globs, the risk rows and the forge comment API speak. + fromRepoRel := func(file string) string { return graphpath.Norm(cleanPath(file)) } byFile := map[string]string{} findingCount := map[string]int{} @@ -146,13 +163,13 @@ func rankFileRisk(diff *analysis.DiffResult, impact map[string]*analysis.ImpactR for _, f := range findings { if f.File != "" { - findingCount[norm(f.File)]++ + findingCount[fromRepoRel(f.File)]++ } } if diff != nil { for _, cs := range diff.ChangedSymbols { - file := norm(cs.FilePath) + file := fromGraphKey(cs.FilePath) if file == "" { continue } @@ -192,7 +209,7 @@ func rankFileRisk(diff *analysis.DiffResult, impact map[string]*analysis.ImpactR byFile[file] = worseRisk(byFile[file], risk) } for _, file := range diff.ChangedFiles { - file = norm(file) + file = fromRepoRel(file) if file == "" { continue }