Files
Tien Dung Dao 49b7e5ecd0 fix(analysis,mcp): convert repo-relative paths with no prefix, and hand CODEOWNERS the repo-relative spelling
Fourth review round on #646.

1. GraphKey returned early on an empty repo prefix, before filepath.FromSlash.

A standalone index carries no prefix but its keys still use native
separators, so a '/'-spelled forge or git path missed them on Windows and
get_pr_impact / suggest_reviewers(number=...) silently lost changed symbols
and reviewer signals. Only GraphKeyedPath returns unchanged now;
RepoRelativePath always converts, then takes the prefix when one applies.

2. CODEOWNERS received a graph key in ids mode.

relForRepo only strips an absolute repo root, so repo-a/pkg/auth/login.go
reached MatchFile with the prefix still on and a root-anchored rule such as
/pkg/auth/ matched nothing. matched_files reported the same spelling back to
the caller. Both now go through the new analysis.RepoRelPath, GraphKey's
inverse: it strips the prefix only for a GraphKeyedPath, because that is the
one domain that carries it by construction.

3. Tests.

- Empty-prefix multi-segment cases for GraphKey and JoinFileNodes. The
  previous a.go fixture has no separator, so it cannot fail either way.
- The ids CODEOWNERS fixture is now root-anchored (/pkg/auth/). The
  unanchored rule also matched the graph-keyed spelling and masked the bug.
- TestJoinHunksToSymbolsPrefixedDeleteAndRename covers the two hunk-less
  change kinds against a prefix-shadowed graph: the delete's Path and the
  rename's PreviousPath must resolve the nested old-side key and never the
  same-named shadow. Their existing tests use an empty prefix and so did not
  bind this contract.
- The Windows selector gains PrefixedDeleteAndRename.

Each fix is sabotage-verified separately: forcing the ids branch back to a
raw graph key leaves only the CODEOWNERS-less reviewers, passing the graph
key to MatchFile drops the codeowner signal entirely, and declaring the
vanished path GraphKeyedPath makes the delete/rename test miss both old-side
symbols.

Four fixtures needed the same correction as TestChangedSymbolsForFiles did
earlier: prToolsTestServer, conflictsTestServer and conflictsBudgetServer
hard-coded '/'-spelled graph keys while supplying the same strings as forge
paths, which describes an index a Windows daemon never writes. They now store
native and supply the forge spelling, which is the shape under test.

Windows: internal/mcp 27 -> 24 against main, failing test names diffed,
newly-broken set empty. internal/analysis passes clean. internal/review keeps
its four pre-existing failures. Lint: 6 staticcheck findings, pre-existing.
2026-08-24 00:24:12 +07:00

638 lines
22 KiB
Go

package analysis
import (
"bufio"
"context"
"fmt"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/zzet/gortex/internal/gitcmd"
"github.com/zzet/gortex/internal/graph"
)
// DiffHunk represents a changed range in a file.
//
// StartLine/EndLine are new-side (post-change) line numbers, except when
// Deleted is set: a deleted file has no new side at all, so its range is the
// old-side span the file occupied before it was removed. That is the span
// that joins the still-indexed pre-delete symbols.
type DiffHunk struct {
FilePath string `json:"file_path"`
StartLine int `json:"start_line"`
EndLine int `json:"end_line"`
Deleted bool `json:"deleted,omitempty"`
}
// FileChangeKind classifies one file entry in a diff.
type FileChangeKind string
const (
FileAdded FileChangeKind = "added"
FileModified FileChangeKind = "modified"
FileDeleted FileChangeKind = "deleted"
FileRenamed FileChangeKind = "renamed"
)
// FileChange is one file-level entry in a diff, carried independently of the
// hunks. Two common change kinds carry no usable hunk at all — a deleted file
// (new side is /dev/null) and a 100%-similar rename (no @@ header is emitted) —
// so a hunk-only view of a diff reports them as no change whatsoever.
// Path is the file's post-change path, or its pre-change path when the change
// removed it; PreviousPath is set only for a rename.
type FileChange struct {
Path string `json:"path"`
PreviousPath string `json:"previous_path,omitempty"`
Kind FileChangeKind `json:"change_kind"`
}
// ChangedSymbol is a symbol affected by a git diff hunk.
type ChangedSymbol struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
FilePath string `json:"file_path"`
Line int `json:"start_line"`
}
// DiffResult is the output of git diff → symbol mapping.
//
// FileChanges is the file-granular view and is authoritative for *what* the
// diff touched: ChangedSymbols is empty for any file the graph does not index
// (docs, manifests, fixtures) and for deletes/renames the graph still indexes
// under the old path, so an empty ChangedSymbols never means "nothing changed".
type DiffResult struct {
Hunks []DiffHunk `json:"hunks"`
ChangedSymbols []ChangedSymbol `json:"changed_symbols"`
ChangedFiles []string `json:"changed_files"`
FileChanges []FileChange `json:"file_changes"`
}
// MapGitDiff parses git diff output and maps changed lines to symbols in the graph.
// scope: "unstaged", "staged", "all", "compare"
// baseRef: used when scope is "compare" (e.g., "main")
// repoRoot: absolute path to the repository root
// repoPrefix: the graph repo prefix anchoring repoRoot's indexed nodes.
// The daemon keys every file path as "<prefix>/<rel>" while git emits
// repo-relative paths; empty only for the standalone Indexer, which
// mints unprefixed paths.
func MapGitDiff(g graph.Store, repoRoot, repoPrefix, scope, baseRef string) (*DiffResult, error) {
if err := gitcmd.ValidateRef(baseRef); err != nil {
return nil, err
}
args := buildDiffArgs(scope, baseRef)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// gitcmd runs `git -C repoRoot args...`; use Run (raw stdout, no trailing
// trim) so the parsed diff stays byte-identical to the pre-gitcmd output.
output, err := gitcmd.Run(ctx, repoRoot, args...)
if err != nil {
// A failed `git diff` is not an empty diff. Swallowing the error
// whenever stdout happened to be empty is how an unknown base ref, a
// repo root that is not a work tree, or a git that never ran at all
// became a confident "no changes" answer.
return nil, fmt.Errorf("git diff failed: %w", err)
}
hunks, files := parseDiffFiles(string(output))
return joinHunksToSymbols(g, repoPrefix, hunks, files), nil
}
// 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: "<prefix>/<rel>" in
// multi-repo mode, the bare relative path when no prefix applies.
GraphKeyedPath
)
// 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
}
// A repo-relative path is always converted, prefix or not. The key's shape
// is "<prefix>/" + 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
}
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 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 "<prefix>/<rel>" 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
// and MapGitDiffWithLines: every symbol whose line range overlaps a changed
// hunk in its file, deduped, plus the changed-file set. ChangedFiles keeps
// the diff-relative paths (callers re-join them with git pathspecs); only
// the node lookup is prefix-aware.
func joinHunksToSymbols(g graph.Store, repoPrefix string, hunks []DiffHunk, files []FileChange) *DiffResult {
result := &DiffResult{Hunks: hunks, FileChanges: files}
fileSet := make(map[string]bool)
symbolSeen := make(map[string]bool)
addSymbol := func(n *graph.Node) {
if n.Kind == graph.KindFile || symbolSeen[n.ID] {
return
}
symbolSeen[n.ID] = true
result.ChangedSymbols = append(result.ChangedSymbols, ChangedSymbol{
ID: n.ID,
Name: n.Name,
Kind: string(n.Kind),
FilePath: n.FilePath,
Line: n.StartLine,
})
}
for _, hunk := range hunks {
fileSet[hunk.FilePath] = true
// Find symbols whose line range overlaps the hunk
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)
}
}
}
// A file record reaches ChangedFiles even when it carried no hunk, which
// is how a delete of an empty file or a 100%-similar rename stays visible.
// Removing or moving a file affects every symbol the graph still indexes
// under its old path, and no line range in the diff describes that — so
// those symbols are taken whole rather than by overlap.
for _, fc := range files {
if fc.Path != "" {
fileSet[fc.Path] = true
}
vanished := fc.PreviousPath
if fc.Kind == FileDeleted {
vanished = fc.Path
}
if vanished == "" {
continue
}
fileSet[vanished] = true
for _, n := range JoinFileNodes(g, repoPrefix, vanished, RepoRelativePath) {
addSymbol(n)
}
}
for f := range fileSet {
result.ChangedFiles = append(result.ChangedFiles, f)
}
sort.Strings(result.ChangedFiles)
return result
}
// GitDiffArgs builds the `git diff` argv for a scope ("unstaged", "staged",
// "all", "compare") with the given context width. The -c overrides pin the
// diff header prefixes to git's standard a/ b/ form: parseDiffHunks and
// parseDiffLines anchor on "+++ b/", which a developer's diff.mnemonicPrefix
// (c/ w/ headers on worktree-side diffs) or diff.noprefix config would
// otherwise silently zero out — every hunk drops, every diff-driven tool
// reports an empty changeset.
func GitDiffArgs(scope, baseRef string, unified int) []string {
args := []string{
"-c", "diff.mnemonicPrefix=false",
"-c", "diff.noprefix=false",
"diff",
}
switch scope {
case "staged":
args = append(args, "--cached")
case "all":
args = append(args, "HEAD")
case "compare":
// A hostile base degrades to the documented default rather than
// reaching argv: the token is baseRef+"...HEAD", so a value
// starting with "-" is still an option to git, and GitDiffArgs
// has no error return to surface it through. MapGitDiff rejects
// it loudly before calling here; this is the backstop for the
// other callers that build args directly.
baseRef = gitcmd.SafeRef(baseRef)
if baseRef == "" {
baseRef = "main"
}
args = append(args, baseRef+"...HEAD")
default: // unstaged — bare `git diff`
}
return append(args, fmt.Sprintf("--unified=%d", unified))
}
func buildDiffArgs(scope, baseRef string) []string {
return GitDiffArgs(scope, baseRef, 0)
}
// buildDiffArgsWithContext mirrors buildDiffArgs but emits a context window so
// the new-side line text survives into the hunk body for snippet grounding.
func buildDiffArgsWithContext(scope, baseRef string) []string {
return GitDiffArgs(scope, baseRef, 3)
}
// HunkLine is a single new-side line carried out of a unified diff: added lines
// (Side "+") and context lines (Side " "), each tagged with its line number in
// the post-change file. Removed lines never appear (they have no new-side line).
type HunkLine struct {
NewLine int `json:"new_line"`
Side string `json:"side"`
Text string `json:"text"`
}
// ParseDiffHunks parses unified git-diff output into per-file changed ranges.
// It is the exported entry point over the same parser MapGitDiff uses and
// returns results identical to the internal parser.
func ParseDiffHunks(output string) []DiffHunk {
return parseDiffHunks(output)
}
// parseDiffLines walks unified diff output and returns, per new-side file path,
// the added and context lines with their post-change line numbers. Removed
// lines are skipped; the new-side line counter only advances on added/context.
// Keys match DiffHunk.FilePath (cleaned, relative) so a hunk and its lines join.
func parseDiffLines(output string) map[string][]HunkLine {
lines := make(map[string][]HunkLine)
var currentFile string
var newLine int
scanner := bufio.NewScanner(strings.NewReader(output))
scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "+++ b/") {
currentFile = filepath.Clean(strings.TrimPrefix(line, "+++ b/"))
newLine = 0
continue
}
if strings.HasPrefix(line, "+++ /dev/null") {
currentFile = ""
newLine = 0
continue
}
// Skip the remaining diff-header lines so their leading +/-/space never
// leaks into the hunk body.
if strings.HasPrefix(line, "--- ") || strings.HasPrefix(line, "diff ") ||
strings.HasPrefix(line, "index ") || strings.HasPrefix(line, "new file") ||
strings.HasPrefix(line, "deleted file") || strings.HasPrefix(line, "rename ") ||
strings.HasPrefix(line, "similarity ") || strings.HasPrefix(line, "old mode") ||
strings.HasPrefix(line, "new mode") || strings.HasPrefix(line, "copy ") {
continue
}
if strings.HasPrefix(line, "@@") {
if currentFile == "" {
continue
}
start, ok := parseNewStart(line)
if !ok {
continue
}
newLine = start
continue
}
if currentFile == "" || newLine == 0 {
continue
}
switch {
case strings.HasPrefix(line, "+"):
lines[currentFile] = append(lines[currentFile], HunkLine{
NewLine: newLine,
Side: "+",
Text: line[1:],
})
newLine++
case strings.HasPrefix(line, "-"):
// Removed line — no new-side position, do not advance.
case strings.HasPrefix(line, "\\"):
// "\ No newline at end of file" marker — not a content line.
default:
// Context line (leading space), or a bare blank context line.
text := line
if strings.HasPrefix(line, " ") {
text = line[1:]
}
lines[currentFile] = append(lines[currentFile], HunkLine{
NewLine: newLine,
Side: " ",
Text: text,
})
newLine++
}
}
return lines
}
// parseNewStart extracts the new-side starting line from a "@@ -a,b +c,d @@"
// hunk header.
func parseNewStart(line string) (int, bool) {
parts := strings.SplitN(line, "@@", 3)
if len(parts) < 2 {
return 0, false
}
for _, f := range strings.Fields(strings.TrimSpace(parts[1])) {
if !strings.HasPrefix(f, "+") {
continue
}
f = strings.TrimPrefix(f, "+")
rangeP := strings.SplitN(f, ",", 2)
start, err := strconv.Atoi(rangeP[0])
if err != nil {
return 0, false
}
return start, true
}
return 0, false
}
// MapGitDiffWithLines mirrors MapGitDiff but uses a context-bearing diff so it
// can additionally return, per file, the new-side lines (added + context) with
// their post-change line numbers — the substrate snippet grounding anchors on.
// The returned *DiffResult is computed with the same logic as MapGitDiff (only
// the diff's context width differs), so symbol overlap is unaffected.
// repoPrefix anchors the node join exactly as in MapGitDiff.
func MapGitDiffWithLines(g graph.Store, repoRoot, repoPrefix, scope, baseRef string) (*DiffResult, map[string][]HunkLine, error) {
if err := gitcmd.ValidateRef(baseRef); err != nil {
return nil, nil, err
}
args := buildDiffArgsWithContext(scope, baseRef)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Run (raw stdout) keeps the trailing newline so the final hunk line is
// never dropped; gitcmd injects `-C repoRoot` for us.
output, err := gitcmd.Run(ctx, repoRoot, args...)
if err != nil {
// See MapGitDiff: an error here means the diff was never observed,
// which is not the same answer as "the diff was empty".
return nil, nil, fmt.Errorf("git diff failed: %w", err)
}
text := string(output)
hunks, files := parseDiffFiles(text)
lines := parseDiffLines(text)
return joinHunksToSymbols(g, repoPrefix, hunks, files), lines, nil
}
// parseDiffHunks returns only the changed line ranges. Callers that need to
// know a file changed at all — including the deletes and renames that carry no
// hunk — want parseDiffFiles instead.
func parseDiffHunks(output string) []DiffHunk {
hunks, _ := parseDiffFiles(output)
return hunks
}
// fileDiffState accumulates the header facts parseDiffFiles has seen for the
// diff entry it is currently inside.
type fileDiffState struct {
oldPath string
newPath string
added bool
deleted bool
renamed bool
}
// change projects the accumulated header facts onto a FileChange. Rename is
// checked first: a rename carries both an old and a new path, and describing
// it as a delete of one plus an add of the other loses the link between them.
func (s fileDiffState) change() FileChange {
switch {
case s.renamed:
return FileChange{Path: s.newPath, PreviousPath: s.oldPath, Kind: FileRenamed}
case s.deleted:
return FileChange{Path: s.oldPath, Kind: FileDeleted}
case s.added:
return FileChange{Path: s.newPath, Kind: FileAdded}
default:
return FileChange{Path: s.newPath, Kind: FileModified}
}
}
// parseDiffFiles walks unified diff output and returns the changed line ranges
// together with a file-level record for every entry in the diff — including
// the entries that carry no hunk at all.
//
// Anchoring only on "+++ b/" and "@@" — the pre-fix parser — silently drops
// two change kinds that are squarely inside every scope this package serves:
//
// - a deleted file, whose new side is "+++ /dev/null", so clearing the
// current path there also skips the "@@" that follows it;
// - a 100%-similar rename, which git reports with "rename from"/"rename to"
// headers and no "@@" header whatsoever.
//
// Both produced zero hunks, so the file never reached ChangedFiles and its
// symbols never reached ChangedSymbols: a delete-only or rename-only change
// read as an empty diff. Deletes are therefore parsed off the old side and
// renames off their rename headers.
func parseDiffFiles(output string) ([]DiffHunk, []FileChange) {
var (
hunks []DiffHunk
changes []FileChange
cur fileDiffState
inEntry bool
)
flush := func() {
if !inEntry {
return
}
if change := cur.change(); change.Path != "" {
changes = append(changes, change)
}
}
scanner := bufio.NewScanner(strings.NewReader(output))
scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
for scanner.Scan() {
line := scanner.Text()
switch {
case strings.HasPrefix(line, "diff --git "):
flush()
cur = fileDiffState{}
inEntry = true
// Only a same-path header parses unambiguously (see
// parseDiffGitPaths). Every other entry shape carries an
// explicit ---/+++ or rename header below, which overrides this.
cur.oldPath = parseDiffGitPaths(line)
cur.newPath = cur.oldPath
continue
case strings.HasPrefix(line, "deleted file mode "):
cur.deleted = true
continue
case strings.HasPrefix(line, "new file mode "):
cur.added = true
continue
case strings.HasPrefix(line, "rename from "):
cur.renamed = true
cur.oldPath = cleanDiffPath(strings.TrimPrefix(line, "rename from "))
continue
case strings.HasPrefix(line, "rename to "):
cur.renamed = true
cur.newPath = cleanDiffPath(strings.TrimPrefix(line, "rename to "))
continue
case strings.HasPrefix(line, "--- a/"):
cur.oldPath = cleanDiffPath(strings.TrimPrefix(line, "--- a/"))
continue
case strings.HasPrefix(line, "--- /dev/null"):
cur.added = true
continue
case strings.HasPrefix(line, "+++ b/"):
cur.newPath = cleanDiffPath(strings.TrimPrefix(line, "+++ b/"))
continue
case strings.HasPrefix(line, "+++ /dev/null"):
cur.deleted = true
continue
case !strings.HasPrefix(line, "@@"):
continue
}
// A deleted file has no new side, so its only line numbers are
// old-side — and they span exactly the lines the file used to hold,
// which is what joins the symbols the graph still indexes for it.
if cur.deleted && !cur.renamed {
if hunk := parseHunkHeader(line, cur.oldPath, "-"); hunk != nil {
hunk.Deleted = true
hunks = append(hunks, *hunk)
}
continue
}
if hunk := parseHunkHeader(line, cur.newPath, "+"); hunk != nil {
hunks = append(hunks, *hunk)
}
}
flush()
return hunks, changes
}
// cleanDiffPath normalizes a path lifted out of a diff header the same way
// DiffHunk.FilePath and parseDiffLines do, so a hunk and its file record join.
func cleanDiffPath(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
return filepath.Clean(path)
}
// parseDiffGitPaths recovers the path from a "diff --git a/P b/P" header. It is
// the only path source for an entry that carries no ---/+++ and no rename
// header — a mode-only change (chmod) is the common case.
//
// The header is ambiguous in general: git separates the two paths with a bare
// space, which a path may itself contain. When both sides name the same file
// the line is exactly "a/P b/P", so P is recoverable by length regardless of
// its spaces. When they differ, the entry is a rename or a copy and carries
// explicit rename headers, so this returns "" and lets those win.
func parseDiffGitPaths(line string) string {
rest := strings.TrimPrefix(line, "diff --git ")
// Git quotes paths containing control or non-ASCII bytes; decoding that
// quoting here would be guesswork, and every quoted entry that matters
// also carries an explicit header below.
if !strings.HasPrefix(rest, "a/") || strings.Contains(rest, `"`) {
return ""
}
// len("a/") + len(P) + len(" ") + len("b/") + len(P)
pathLen := (len(rest) - 5) / 2
if pathLen <= 0 || len(rest) != 2*pathLen+5 {
return ""
}
old, next := rest[2:2+pathLen], rest[2+pathLen:]
if !strings.HasPrefix(next, " b/") || next[3:] != old {
return ""
}
return cleanDiffPath(old)
}
// parseHunkHeader reads one "@@ -old,count +new,count @@" header and returns
// the range on the requested side ("+" for the new side, "-" for the old side
// of a deleted file), attributed to filePath.
func parseHunkHeader(line, filePath, side string) *DiffHunk {
if filePath == "" {
return nil
}
// Format: @@ -old,count +new,count @@
parts := strings.SplitN(line, "@@", 3)
if len(parts) < 2 {
return nil
}
ranges := strings.TrimSpace(parts[1])
fields := strings.Fields(ranges)
for _, f := range fields {
if strings.HasPrefix(f, side) {
f = strings.TrimPrefix(f, side)
rangeP := strings.SplitN(f, ",", 2)
start, err := strconv.Atoi(rangeP[0])
if err != nil {
continue
}
count := 1
if len(rangeP) > 1 {
count, _ = strconv.Atoi(rangeP[1])
}
if count == 0 {
count = 1
}
// Normalize file path to be relative
relPath := filepath.Clean(filePath)
return &DiffHunk{
FilePath: relPath,
StartLine: start,
EndLine: start + count - 1,
}
}
}
return nil
}