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

319 lines
12 KiB
Go

package dataflow
import (
"errors"
"testing"
"github.com/zzet/gortex/internal/graph"
)
// refineFixtureSrc is the function the refinement tests reason
// about. Binding sites (snippet lines):
//
// line 1: func F(a int) int — param a
// line 2: b := a — local b@+2 (consumes a)
// line 3: c := b — local c@+3 (consumes the live b)
// line 4: b = 99 — kills b@+2
// line 5: d := b — local d@+5 (consumes the NEW b, not b@+2)
// line 6: return d
const refineFixtureSrc = `func F(a int) int {
b := a
c := b
b = 99
d := b
return d
}`
const refineOwner = "main.go::F"
// refineGraph wires the binding nodes and value_flow edges. The
// b@+2 → d@+5 edge is deliberately stale: by line 5 the b binding
// from line 2 has been overwritten, so the reaching-definitions
// analysis must prune it.
func refineGraph(t *testing.T) *graph.Graph {
t.Helper()
g := graph.New()
g.AddNode(&graph.Node{
ID: refineOwner, Kind: graph.KindFunction, Name: "F",
FilePath: "main.go", Language: "go", StartLine: 1, EndLine: 7,
})
add := func(id string, kind graph.NodeKind, name string, line int) {
g.AddNode(&graph.Node{
ID: id, Kind: kind, Name: name,
FilePath: "main.go", Language: "go", StartLine: line, EndLine: line,
})
}
add(refineOwner+"#param:a", graph.KindParam, "a", 1)
add(refineOwner+"#local:b@+2", graph.KindLocal, "b", 2)
add(refineOwner+"#local:c@+3", graph.KindLocal, "c", 3)
add(refineOwner+"#local:d@+5", graph.KindLocal, "d", 5)
flow := func(from, to string, line int) {
g.AddEdge(&graph.Edge{
From: from, To: to, Kind: graph.EdgeValueFlow,
FilePath: "main.go", Line: line, Origin: graph.OriginASTResolved,
})
}
// True flows.
flow(refineOwner+"#param:a", refineOwner+"#local:b@+2", 2)
flow(refineOwner+"#local:b@+2", refineOwner+"#local:c@+3", 3)
// Stale flow: b@+2 cannot reach d@+5 (b reassigned at line 4).
flow(refineOwner+"#local:b@+2", refineOwner+"#local:d@+5", 5)
return g
}
func fixtureResolver(t *testing.T, calls *int) SourceResolver {
return func(fn *graph.Node) (FuncSource, error) {
if calls != nil {
*calls++
}
if fn.ID != refineOwner {
return FuncSource{}, errors.New("unknown function")
}
return FuncSource{Src: []byte(refineFixtureSrc), StartLine: 1}, nil
}
}
func TestRefinerConfirmsTrueFlow(t *testing.T) {
g := refineGraph(t)
e := New(g).WithRefiner(NewRefiner(g, fixtureResolver(t, nil), 0))
paths := e.FlowBetween(refineOwner+"#param:a", refineOwner+"#local:b@+2", 0, 0)
if len(paths) != 1 || paths[0].Length() != 1 {
t.Fatalf("expected one single-hop path, got %+v", paths)
}
step := paths[0].Edges[0]
if step.Refined != RefinedConfirmed {
t.Errorf("param→local flow should be confirmed, got %q", step.Refined)
}
}
func TestRefinerConfirmsChainedFlow(t *testing.T) {
g := refineGraph(t)
e := New(g).WithRefiner(NewRefiner(g, fixtureResolver(t, nil), 0))
paths := e.FlowBetween(refineOwner+"#local:b@+2", refineOwner+"#local:c@+3", 0, 0)
if len(paths) != 1 {
t.Fatalf("expected one path, got %+v", paths)
}
if got := paths[0].Edges[0].Refined; got != RefinedConfirmed {
t.Errorf("live local→local flow should be confirmed, got %q", got)
}
}
func TestRefinerPrunesStaleFlow(t *testing.T) {
g := refineGraph(t)
e := New(g).WithRefiner(NewRefiner(g, fixtureResolver(t, nil), 0))
paths := e.FlowBetween(refineOwner+"#local:b@+2", refineOwner+"#local:d@+5", 0, 0)
if len(paths) != 1 {
t.Fatalf("expected one path, got %+v", paths)
}
step := paths[0].Edges[0]
if step.Refined != RefinedPruned {
t.Errorf("stale flow (b reassigned before d := b) should be pruned, got %q", step.Refined)
}
// Pruning must cost confidence relative to the unrefined run.
plain := New(g).FlowBetween(refineOwner+"#local:b@+2", refineOwner+"#local:d@+5", 0, 0)
if !(paths[0].Confidence < plain[0].Confidence) {
t.Errorf("pruned path confidence %v must drop below unrefined %v",
paths[0].Confidence, plain[0].Confidence)
}
}
func TestRefinerRanksConfirmedAbovePruned(t *testing.T) {
// Two same-length paths from b@+2 to a common sink — one through
// the live binding c@+3, one through the stale hop to d@+5. After
// refinement the pruned path's confidence drops, so the confirmed
// route must rank first.
g := refineGraph(t)
g.AddNode(&graph.Node{
ID: "main.go::Sink", Kind: graph.KindFunction, Name: "Sink",
FilePath: "main.go", Language: "go", StartLine: 20, EndLine: 22,
})
for _, from := range []string{refineOwner + "#local:c@+3", refineOwner + "#local:d@+5"} {
g.AddEdge(&graph.Edge{
From: from, To: "main.go::Sink", Kind: graph.EdgeValueFlow,
FilePath: "main.go", Origin: graph.OriginASTResolved,
})
}
e := New(g).WithRefiner(NewRefiner(g, fixtureResolver(t, nil), 0))
paths := e.FlowBetween(refineOwner+"#local:b@+2", "main.go::Sink", 0, 0)
if len(paths) != 2 {
t.Fatalf("expected two competing paths, got %+v", paths)
}
first, second := paths[0], paths[1]
if first.Edges[0].Refined != RefinedConfirmed {
t.Errorf("top-ranked path must start with the confirmed hop, got %q (path %v)", first.Edges[0].Refined, first.IDs)
}
if second.Edges[0].Refined != RefinedPruned {
t.Errorf("second path must carry the pruned marker, got %q (path %v)", second.Edges[0].Refined, second.IDs)
}
if !(first.Confidence > second.Confidence) {
t.Errorf("confirmed path confidence %v must beat pruned %v", first.Confidence, second.Confidence)
}
}
// A param whose binding ID carries a `@<position>` suffix (the form
// every non-Go extractor mints) must still anchor onto the CFG's
// param statement so the hop is refined, not silently skipped.
func TestRefinerConfirmsPositionalParamFlow(t *testing.T) {
g := graph.New()
owner := "shape.go::F"
g.AddNode(&graph.Node{
ID: owner, Kind: graph.KindFunction, Name: "F",
FilePath: "shape.go", Language: "go", StartLine: 1, EndLine: 4,
})
// Param ID with a positional disambiguator, as Python/TS/etc emit.
paramID := owner + "#param:a@0"
localID := owner + "#local:b@+2"
g.AddNode(&graph.Node{ID: paramID, Kind: graph.KindParam, Name: "a", FilePath: "shape.go", Language: "go", StartLine: 1, EndLine: 1})
g.AddNode(&graph.Node{ID: localID, Kind: graph.KindLocal, Name: "b", FilePath: "shape.go", Language: "go", StartLine: 2, EndLine: 2})
g.AddEdge(&graph.Edge{From: paramID, To: localID, Kind: graph.EdgeValueFlow, FilePath: "shape.go", Line: 2, Origin: graph.OriginASTResolved})
resolve := func(fn *graph.Node) (FuncSource, error) {
return FuncSource{Src: []byte("func F(a int) int {\n\tb := a\n\treturn b\n}"), StartLine: 1}, nil
}
e := New(g).WithRefiner(NewRefiner(g, resolve, 0))
paths := e.FlowBetween(paramID, localID, 0, 0)
if len(paths) != 1 {
t.Fatalf("expected one path, got %+v", paths)
}
if got := paths[0].Edges[0].Refined; got != RefinedConfirmed {
t.Errorf("positional-param flow must be confirmed, got %q (the @0 suffix must be stripped)", got)
}
}
// A confirmed path that is LONGER than a competing pruned path must
// still rank ahead of it: confidence demotion alone can't move a
// pruned hop past a shorter confirmed one when length is the primary
// sort key, so pruned paths must sink categorically.
func TestRefinerSinksPrunedBelowLongerConfirmed(t *testing.T) {
// Reuse the fixture's stale b@+2 → d@+5 hop (pruned, 1 hop) and
// build a longer confirmed route a → b → c → sink (3 hops) to the
// same sink, so the pruned path is strictly shorter.
g := refineGraph(t)
g.AddNode(&graph.Node{
ID: "main.go::Sink", Kind: graph.KindFunction, Name: "Sink",
FilePath: "main.go", Language: "go", StartLine: 20, EndLine: 22,
})
// Shorter pruned route: b@+2 → d@+5 → Sink (2 hops, first hop pruned).
g.AddEdge(&graph.Edge{From: refineOwner + "#local:d@+5", To: "main.go::Sink", Kind: graph.EdgeValueFlow, FilePath: "main.go", Origin: graph.OriginASTResolved})
// Longer confirmed route: b@+2 → c@+3 → Sink (2 hops) — but make it
// genuinely longer by routing through an extra confirmed local.
// c@+3 is live, so b→c is confirmed; c→Sink is out of scope
// (unmarked, not pruned). Add an intermediate to force length 3.
g.AddNode(&graph.Node{ID: "main.go::Mid", Kind: graph.KindFunction, Name: "Mid", FilePath: "main.go", Language: "go", StartLine: 15, EndLine: 16})
g.AddEdge(&graph.Edge{From: refineOwner + "#local:c@+3", To: "main.go::Mid", Kind: graph.EdgeValueFlow, FilePath: "main.go", Origin: graph.OriginASTResolved})
g.AddEdge(&graph.Edge{From: "main.go::Mid", To: "main.go::Sink", Kind: graph.EdgeValueFlow, FilePath: "main.go", Origin: graph.OriginASTResolved})
e := New(g).WithRefiner(NewRefiner(g, fixtureResolver(t, nil), 0))
paths := e.FlowBetween(refineOwner+"#local:b@+2", "main.go::Sink", 0, 0)
if len(paths) < 2 {
t.Fatalf("expected at least two competing paths, got %+v", paths)
}
// The shortest path is the 2-hop pruned route; the confirmed route
// is 3 hops. Despite being longer, the confirmed path must rank
// first because the pruned path sinks.
top := paths[0]
if hasPrunedHop(top) {
t.Errorf("top-ranked path must not be the pruned one; got pruned path %v ranked first", top.IDs)
}
// And the pruned path must still be present, just demoted.
sawPruned := false
for _, p := range paths {
if hasPrunedHop(p) {
sawPruned = true
}
}
if !sawPruned {
t.Fatalf("the pruned path must remain in the result set (demoted, not dropped): %+v", paths)
}
}
func TestRefinerCachesPerFunctionCFG(t *testing.T) {
g := refineGraph(t)
calls := 0
e := New(g).WithRefiner(NewRefiner(g, fixtureResolver(t, &calls), 0))
// Two hops inside the same function: a→b→c. One CFG build.
paths := e.FlowBetween(refineOwner+"#param:a", refineOwner+"#local:c@+3", 0, 0)
if len(paths) != 1 || paths[0].Length() != 2 {
t.Fatalf("expected one two-hop path, got %+v", paths)
}
if calls != 1 {
t.Errorf("CFG source resolved %d times, want 1 (cached per function)", calls)
}
for _, step := range paths[0].Edges {
if step.Refined != RefinedConfirmed {
t.Errorf("hop %s→%s should be confirmed, got %q", step.From, step.To, step.Refined)
}
}
}
func TestRefinerLeavesOutOfScopeHopsUnmarked(t *testing.T) {
// Function-level nodes (no #local:/#param: binding form) are out
// of refinement scope and stay unmarked.
g := graph.New()
for _, id := range []string{"A", "B"} {
g.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: id, FilePath: "x.go", Language: "go", StartLine: 1, EndLine: 2})
}
g.AddEdge(&graph.Edge{From: "A", To: "B", Kind: graph.EdgeValueFlow, FilePath: "x.go", Origin: graph.OriginASTResolved})
resolverCalls := 0
e := New(g).WithRefiner(NewRefiner(g, fixtureResolver(t, &resolverCalls), 0))
paths := e.FlowBetween("A", "B", 0, 0)
if len(paths) != 1 {
t.Fatalf("expected one path, got %+v", paths)
}
if got := paths[0].Edges[0].Refined; got != "" {
t.Errorf("cross-symbol hop must stay unmarked, got %q", got)
}
if resolverCalls != 0 {
t.Errorf("out-of-scope hops must not trigger CFG builds (%d calls)", resolverCalls)
}
}
func TestRefinerSurvivesResolverFailure(t *testing.T) {
g := refineGraph(t)
failing := func(fn *graph.Node) (FuncSource, error) {
return FuncSource{}, errors.New("disk gone")
}
e := New(g).WithRefiner(NewRefiner(g, failing, 0))
paths := e.FlowBetween(refineOwner+"#param:a", refineOwner+"#local:b@+2", 0, 0)
if len(paths) != 1 {
t.Fatalf("paths must survive resolver failure, got %+v", paths)
}
if got := paths[0].Edges[0].Refined; got != "" {
t.Errorf("unresolvable source must leave hops unmarked, got %q", got)
}
}
func TestSplitBindingID(t *testing.T) {
cases := []struct {
id string
owner, name string
ok bool
}{
{"f.go::F#local:x@+3", "f.go::F", "x", true},
{"f.go::F#param:in", "f.go::F", "in", true},
// Non-Go extractors (Python / TS / Rust / Java / C#) append a
// position disambiguator to param IDs; the suffix must be
// stripped so the bare name matches the CFG's def/use sets.
{"a.py::f#param:limit@0", "a.py::f", "limit", true},
{"a.ts::f#param:limit@2", "a.ts::f", "limit", true},
{"f.go::F", "", "", false},
{"unresolved::X", "", "", false},
{"", "", "", false},
}
for _, c := range cases {
owner, name, ok := splitBindingID(c.id)
if owner != c.owner || name != c.name || ok != c.ok {
t.Errorf("splitBindingID(%q) = (%q,%q,%v), want (%q,%q,%v)",
c.id, owner, name, ok, c.owner, c.name, c.ok)
}
}
}