Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

535 lines
17 KiB
Go

package review
import (
"bufio"
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/zzet/gortex/internal/analysis"
"github.com/zzet/gortex/internal/gitcmd"
"github.com/zzet/gortex/internal/graph"
)
// Anchor-method labels record which resolver tier located a snippet. They ride
// on Finding.Anchor so a caller can tell an exact hunk hit from a fuzzy or LLM
// re-location. An unresolved anchor carries Line == 0 so callers drop it.
const (
AnchorExactHunk = "exact_hunk" // matched a new-side hunk/context line
AnchorOldSide = "old_side" // matched a removed (old-side) line
AnchorFullFile = "full_file" // matched the post-change file on disk
AnchorLLM = "llm" // re-located by the LLM fallback seam
AnchorUnresolved = "unresolved" // nothing matched
)
const (
sideRight = "RIGHT" // new-side
sideLeft = "LEFT" // old-side
)
// DiffLine is one line carried out of a unified diff for snippet anchoring.
// For new-side lines NewLine is the post-change line number and Added marks an
// inserted line (vs a context line). For old-side lines NewLine is the line's
// position in the pre-change file and Added is false.
type DiffLine struct {
NewLine int `json:"new_line"`
Text string `json:"text"`
Added bool `json:"added"`
}
// FileChange is the per-file view a reviewer grounds against: the new-side
// (post-change) lines a finding anchors to, plus the removed old-side lines so
// a snippet that survives only in the pre-change text can still be located.
type FileChange struct {
Path string `json:"path"`
Lines []DiffLine `json:"lines"` // new-side: added + context
OldLines []DiffLine `json:"old_lines,omitempty"` // removed (old-side)
}
// ChangeView is the per-PR/diff view a reviewer grounds against: every changed
// file with its new-side and old-side lines, keyed by cleaned relative path.
type ChangeView struct {
RepoRoot string
ByFile map[string]*FileChange
}
// Anchor is a resolved (file,line) location for a free-text snippet, plus the
// confidence of the match and the resolver tier (Method) that produced it. A
// Method of AnchorUnresolved carries Line == 0 so callers drop the finding.
type Anchor struct {
File string `json:"file"`
Line int `json:"line"`
Confidence float64 `json:"confidence"`
Method string `json:"method"`
Side string `json:"side,omitempty"`
}
// BuildChangeView builds the diff view for a scope by reusing the landed
// new-side substrate (analysis.MapGitDiffWithLines) and parsing the removed
// old-side lines from the same diff. The graph supplies the changed-symbol
// spans inside MapGitDiffWithLines; it may be nil (then only the line text is
// available, which is all the resolver needs). repoPrefix anchors the node
// join in multi-repo mode (see analysis.MapGitDiff).
func BuildChangeView(g graph.Store, repoRoot, repoPrefix, scope, baseRef string) (*ChangeView, error) {
_, newLines, err := analysis.MapGitDiffWithLines(g, repoRoot, repoPrefix, scope, baseRef)
if err != nil {
return nil, err
}
view := &ChangeView{RepoRoot: repoRoot, ByFile: map[string]*FileChange{}}
for file, hunkLines := range newLines {
fc := view.fileChange(file)
for _, hl := range hunkLines {
fc.Lines = append(fc.Lines, DiffLine{
NewLine: hl.NewLine,
Text: hl.Text,
Added: hl.Side == "+",
})
}
}
// The new-side substrate discards removed lines; recover them from the raw
// diff so the old-side tier can match a deleted snippet.
if raw, derr := rawDiff(repoRoot, scope, baseRef); derr == nil {
applyOldSide(view, raw)
}
return view, nil
}
// ChangeViewFromDiff builds a ChangeView from raw unified-diff text — the
// pasted-diff (off-disk) path and the deterministic test seam. It parses both
// the new-side and the old-side lines locally.
func ChangeViewFromDiff(repoRoot, diffText string) *ChangeView {
view := &ChangeView{RepoRoot: repoRoot, ByFile: map[string]*FileChange{}}
applyNewSide(view, diffText)
applyOldSide(view, diffText)
return view
}
func (v *ChangeView) fileChange(file string) *FileChange {
file = cleanPath(file)
fc := v.ByFile[file]
if fc == nil {
fc = &FileChange{Path: file}
v.ByFile[file] = fc
}
return fc
}
// LocateSnippet resolves a free-text snippet to an exact (file,line) using the
// deterministic tiers, stopping at the first that matches:
//
// 1. new-side hunk lines (reuses analysis.LineGrounder scoring)
// 2. removed old-side lines
// 3. the full post-change file on disk
//
// A miss returns Anchor{Method: AnchorUnresolved, Line: 0}. LocateSnippetWithLLM
// wraps this with the optional tier-4 LLM re-location fallback.
func LocateSnippet(view *ChangeView, file, snippet string) Anchor {
if view == nil {
return unresolved(file)
}
file = cleanPath(file)
fc := view.ByFile[file]
if fc == nil {
return unresolved(file)
}
// Tier 1: new-side hunk lines, scored by the landed grounder.
if hit, ok := scoreNewSide(fc, file, snippet); ok {
return Anchor{File: file, Line: hit.line, Confidence: hit.conf, Method: AnchorExactHunk, Side: sideRight}
}
// Tier 2: removed old-side lines.
if line, conf, ok := matchDiffLines(fc.OldLines, snippet); ok {
return Anchor{File: file, Line: line, Confidence: conf, Method: AnchorOldSide, Side: sideLeft}
}
// Tier 3: the full post-change file on disk.
if body, ok := view.readFile(file); ok {
if line, conf, ok := matchFullFile(string(body), snippet); ok {
return Anchor{File: file, Line: line, Confidence: conf, Method: AnchorFullFile, Side: sideRight}
}
}
return unresolved(file)
}
// LocateSnippetWithLLM runs the deterministic LocateSnippet tiers and, on a
// miss, falls back to the optional LLM re-location seam (tier 4). A nil gen
// (disabled seam) simply yields the unresolved anchor — never an error.
func LocateSnippetWithLLM(ctx context.Context, gen LLMGen, view *ChangeView, file, snippet string) Anchor {
a := LocateSnippet(view, file, snippet)
if a.Method != AnchorUnresolved {
return a
}
return ReLocateWithLLM(ctx, gen, view, file, snippet)
}
// ReLocateWithLLM is the tier-4 fallback: it asks the LLM to map a snippet to a
// new-side line number among the file's candidate lines. The seam is optional —
// a nil gen, an empty file, an LLM error, or an out-of-range answer all yield an
// unresolved anchor rather than an error.
func ReLocateWithLLM(ctx context.Context, gen LLMGen, view *ChangeView, file, snippet string) Anchor {
if gen == nil || view == nil {
return unresolved(file)
}
file = cleanPath(file)
fc := view.ByFile[file]
if fc == nil || len(fc.Lines) == 0 {
return unresolved(file)
}
prompt := relocatePrompt(file, snippet, fc.Lines)
out, err := gen(ctx, prompt, 16)
if err != nil {
return unresolved(file)
}
n, ok := parseLineAnswer(out)
if !ok {
return unresolved(file)
}
// Validate the answer against the candidate new-side lines.
for _, dl := range fc.Lines {
if dl.NewLine == n {
return Anchor{File: file, Line: n, Confidence: 0.4, Method: AnchorLLM, Side: sideRight}
}
}
return unresolved(file)
}
// LLMGen is the optional LLM re-location seam: a closure over Service.Generate
// (or any (ctx,prompt,maxTokens)->string). Keeping it a func leaves
// internal/review free of an llm/svc import and lets tests stub it. A nil seam
// means the LLM tier is disabled and yields AnchorUnresolved rather than erroring.
type LLMGen func(ctx context.Context, prompt string, maxTokens int) (string, error)
func unresolved(file string) Anchor {
return Anchor{File: cleanPath(file), Line: 0, Confidence: 0, Method: AnchorUnresolved}
}
// Located reports whether the anchor resolved to a real line.
func (a Anchor) Located() bool { return a.Method != AnchorUnresolved && a.Line > 0 }
// Apply writes the resolved location onto a Finding: File, single-line
// Line/StartLine/EndLine, Side, and the tier in Anchor. The Confidence is set
// only when the anchor improves on a finding's existing confidence so an
// upstream rule confidence is not clobbered by a weaker anchor score.
func (a Anchor) Apply(f *Finding) {
if f == nil {
return
}
f.File = a.File
f.Anchor = a.Method
if !a.Located() {
return
}
f.Line = a.Line
f.StartLine = a.Line
f.EndLine = a.Line
f.Side = a.Side
if f.Confidence == 0 {
f.Confidence = a.Confidence
}
}
// LocateFinding resolves a finding's snippet (from its Message/Body via the
// caller) onto an exact location and writes it back. It is the convenience entry
// the review flow uses: deterministic tiers plus the optional LLM fallback.
func LocateFinding(ctx context.Context, gen LLMGen, view *ChangeView, f *Finding, snippet string) Anchor {
if f == nil {
return unresolved("")
}
a := LocateSnippetWithLLM(ctx, gen, view, f.File, snippet)
a.Apply(f)
return a
}
// --- tier-1 grounder reuse ---
type lineHit struct {
line int
conf float64
}
// scoreNewSide resolves a snippet against the file's new-side lines using the
// landed analysis.LineGrounder scoring (exact / whitespace-normalised /
// token-overlap). ok is false when the file has no new-side lines or nothing
// matches. An empty snippet anchors to the first added line (grounder default).
func scoreNewSide(fc *FileChange, file, snippet string) (lineHit, bool) {
if fc == nil || len(fc.Lines) == 0 {
return lineHit{}, false
}
hunkLines := make([]analysis.HunkLine, 0, len(fc.Lines))
for _, dl := range fc.Lines {
side := " "
if dl.Added {
side = "+"
}
hunkLines = append(hunkLines, analysis.HunkLine{NewLine: dl.NewLine, Side: side, Text: dl.Text})
}
// nil graph: GroundFinding with an empty symbol id scans every file line and
// never needs a symbol-span lookup.
grounder := analysis.NewLineGrounder(nil, map[string][]analysis.HunkLine{file: hunkLines})
hit, ok := grounder.GroundFinding(file, "", snippet)
if !ok {
return lineHit{}, false
}
return lineHit{line: hit.Line, conf: hit.Confidence}, true
}
// --- old-side / full-file deterministic matching ---
// matchDiffLines does exact then whitespace-normalised substring matching over a
// set of diff lines, returning the matched line number and a confidence.
func matchDiffLines(lines []DiffLine, snippet string) (int, float64, bool) {
snip := strings.TrimSpace(snippet)
if snip == "" || len(lines) == 0 {
return 0, 0, false
}
for _, dl := range lines {
if strings.Contains(dl.Text, snip) {
return dl.NewLine, 1.0, true
}
}
normSnip := normalizeWS(snip)
if normSnip != "" {
for _, dl := range lines {
if strings.Contains(normalizeWS(dl.Text), normSnip) {
return dl.NewLine, 0.7, true
}
}
}
return 0, 0, false
}
// matchFullFile does exact then whitespace-normalised substring matching over
// the post-change file, returning a 1-based line number.
func matchFullFile(body, snippet string) (int, float64, bool) {
snip := strings.TrimSpace(snippet)
if snip == "" {
return 0, 0, false
}
fileLines := strings.Split(body, "\n")
for i, l := range fileLines {
if strings.Contains(l, snip) {
return i + 1, 1.0, true
}
}
normSnip := normalizeWS(snip)
if normSnip != "" {
for i, l := range fileLines {
if strings.Contains(normalizeWS(l), normSnip) {
return i + 1, 0.7, true
}
}
}
return 0, 0, false
}
func normalizeWS(s string) string { return strings.Join(strings.Fields(s), " ") }
// --- LLM prompt + answer parsing ---
func relocatePrompt(file, snippet string, lines []DiffLine) string {
var b strings.Builder
fmt.Fprintf(&b, "Map the snippet to the single best matching line number in %s.\n", file)
fmt.Fprintf(&b, "Snippet:\n%s\n\nCandidate lines (number: text):\n", snippet)
for _, dl := range lines {
fmt.Fprintf(&b, "%d: %s\n", dl.NewLine, dl.Text)
}
b.WriteString("\nRespond with only the integer line number.")
return b.String()
}
// parseLineAnswer extracts the first integer from a model reply.
func parseLineAnswer(out string) (int, bool) {
out = strings.TrimSpace(out)
if out == "" {
return 0, false
}
var digits strings.Builder
started := false
for _, r := range out {
if r >= '0' && r <= '9' {
digits.WriteRune(r)
started = true
continue
}
if started {
break
}
}
if digits.Len() == 0 {
return 0, false
}
n, err := strconv.Atoi(digits.String())
if err != nil || n <= 0 {
return 0, false
}
return n, true
}
// cleanPath normalises a path the same way the diff parser keys files, so a
// caller-supplied path joins the ChangeView map.
func cleanPath(p string) string {
p = strings.TrimPrefix(p, "./")
return filepath.Clean(p)
}
// --- diff parsing (local, covers both sides) ---
// applyNewSide parses added + context lines (new-side) from unified-diff text.
func applyNewSide(view *ChangeView, diffText string) {
var file string
var newLine int
sc := bufio.NewScanner(strings.NewReader(diffText))
sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
for sc.Scan() {
line := sc.Text()
if f, ok := parseFileHeaderNew(line); ok {
file, newLine = f, 0
continue
}
if isDiffHeader(line) {
continue
}
if strings.HasPrefix(line, "@@") {
if file == "" {
continue
}
if start, ok := parseNewStart(line); ok {
newLine = start
}
continue
}
if file == "" || newLine == 0 {
continue
}
switch {
case strings.HasPrefix(line, "+"):
view.fileChange(file).Lines = append(view.fileChange(file).Lines, DiffLine{NewLine: newLine, Text: line[1:], Added: true})
newLine++
case strings.HasPrefix(line, "-"):
// removed — handled by applyOldSide
case strings.HasPrefix(line, "\\"):
// "\ No newline at end of file"
default:
text := line
if strings.HasPrefix(line, " ") {
text = line[1:]
}
view.fileChange(file).Lines = append(view.fileChange(file).Lines, DiffLine{NewLine: newLine, Text: text, Added: false})
newLine++
}
}
}
// applyOldSide parses removed lines (old-side) from unified-diff text, tagging
// each with its position in the pre-change file.
func applyOldSide(view *ChangeView, diffText string) {
var file string
var oldLine int
sc := bufio.NewScanner(strings.NewReader(diffText))
sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
for sc.Scan() {
line := sc.Text()
if f, ok := parseFileHeaderNew(line); ok {
file, oldLine = f, 0
continue
}
if isDiffHeader(line) {
continue
}
if strings.HasPrefix(line, "@@") {
if file == "" {
continue
}
if start, ok := parseOldStart(line); ok {
oldLine = start
}
continue
}
if file == "" || oldLine == 0 {
continue
}
switch {
case strings.HasPrefix(line, "-"):
view.fileChange(file).OldLines = append(view.fileChange(file).OldLines, DiffLine{NewLine: oldLine, Text: line[1:], Added: false})
oldLine++
case strings.HasPrefix(line, "+"):
// added — no old-side position
case strings.HasPrefix(line, "\\"):
default:
// context advances the old-side counter too
oldLine++
}
}
}
func parseFileHeaderNew(line string) (string, bool) {
if strings.HasPrefix(line, "+++ b/") {
return cleanPath(strings.TrimPrefix(line, "+++ b/")), true
}
if strings.HasPrefix(line, "+++ /dev/null") {
return "", true
}
return "", false
}
func isDiffHeader(line string) bool {
return 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 ")
}
func parseNewStart(line string) (int, bool) { return parseHunkStart(line, "+") }
func parseOldStart(line string) (int, bool) { return parseHunkStart(line, "-") }
// parseHunkStart extracts the starting line for the requested side ("+"/"-")
// from a "@@ -a,b +c,d @@" header.
func parseHunkStart(line, sidePrefix 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, sidePrefix) {
continue
}
f = strings.TrimPrefix(f, sidePrefix)
rangeP := strings.SplitN(f, ",", 2)
start, err := strconv.Atoi(rangeP[0])
if err != nil {
return 0, false
}
return start, true
}
return 0, false
}
// rawDiff runs the same context-bearing diff MapGitDiffWithLines uses so the
// old-side lines can be recovered. Routed through gitcmd (no ad-hoc exec).
// analysis.GitDiffArgs pins the a/ b/ header prefixes the parsers anchor on.
func rawDiff(repoRoot, scope, baseRef string) (string, error) {
return gitcmd.Output(context.Background(), repoRoot, analysis.GitDiffArgs(scope, baseRef, 3)...)
}
// readFile returns the post-change file content from disk under RepoRoot.
func (v *ChangeView) readFile(file string) ([]byte, bool) {
if v.RepoRoot == "" {
return nil, false
}
b, err := os.ReadFile(filepath.Join(v.RepoRoot, file))
if err != nil {
return nil, false
}
return b, true
}