chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:33:42 +08:00
commit a06f331eb8
3186 changed files with 689843 additions and 0 deletions
+579
View File
@@ -0,0 +1,579 @@
// Package dataflow implements the CPG-lite dataflow primitives
// surfaced by the flow_between and taint_paths MCP tools. It walks
// EdgeValueFlow / EdgeArgOf / EdgeReturnsTo edges over the live
// graph to answer two questions agents care about:
//
// 1. flow_between(source_id, sink_id, max_depth) — list every
// ranked path that connects two specific symbols. Used for
// refactor-safety questions ("if I change f's return type,
// every site that ultimately consumes that value") and bug
// investigation ("trace where this wrong value originated").
//
// 2. taint_paths(source_pattern, sink_pattern) — pattern-driven
// resolution of source / sink sets, then a flow_between for
// each candidate pair. Used for security-style queries
// ("every flow from os.Getenv to db.Query") and architectural
// audits ("every flow from request.Body to a logger").
//
// The traversal forms the smallest useful subset of Joern's CPG
// reachability primitives that the segment can ship inside an MCP
// surface — intra-procedural data dependence, captured at
// extraction time as EdgeValueFlow, plus inter-procedural binding
// at every call site, captured as EdgeArgOf / EdgeReturnsTo.
//
// Direction. flow_between always walks forward — out-edges of the
// current frontier — because every dataflow edge points in the
// direction of value movement: a value_flow goes source→consumer,
// an arg_of goes argument→callee param, and a returns_to goes
// callee→assignment. BFS over OutEdges therefore traces "where
// does this value go". Reverse flow ("where did this value come
// from") is a future addition; today the user can swap source
// and sink to walk the same edges from the other end.
package dataflow
import (
"sort"
"strings"
"github.com/zzet/gortex/internal/graph"
)
// DefaultMaxDepth bounds how far BFS will look — each hop on a
// dataflow edge counts. Eight is empirically wide enough to cover
// most real handlers and security-relevant flows while keeping
// pathological "fully connected" graphs from blowing the response
// budget.
const DefaultMaxDepth = 8
// DefaultMaxPaths bounds how many distinct paths flow_between
// will return for a single (source, sink) pair. The handler ranks
// refinement-confirmed paths ahead of disproved (pruned) ones, then
// by length, then by edge-confidence, so the user gets the most
// plausible paths first.
const DefaultMaxPaths = 10
// EdgeStep is one hop along a flow path. It carries the edge kind,
// origin tier, and coarse tier label so the caller can distinguish a
// strong intra-procedural chain from a heuristic inter-procedural
// binding without recomputing the origin → tier mapping. Refined is
// stamped by the CFG-backed reaching-definitions refinement when the
// hop's endpoints are bindings of the same function:
// confirmed_intraprocedural (a def→use chain verifies the hop) or
// pruned (the source's definition is killed before the target on
// every path). Empty when the hop is out of refinement scope.
type EdgeStep struct {
From string `json:"from"`
To string `json:"to"`
Kind string `json:"kind"`
Origin string `json:"origin,omitempty"`
Tier string `json:"tier,omitempty"`
Refined string `json:"refined,omitempty"`
}
// Path is an ordered sequence of edge hops from a source node to
// a sink node. The IDs slice is the source-first node sequence and
// is always one longer than Edges. Confidence is a normalised
// 0-1 score derived from edge origin tiers — higher means a
// stronger end-to-end binding.
type Path struct {
IDs []string `json:"ids"`
Edges []EdgeStep `json:"edges"`
Confidence float64 `json:"confidence"`
}
// Length returns the number of hops in the path.
func (p Path) Length() int { return len(p.Edges) }
// Engine is the dataflow query backend. It holds a reference to
// the graph and exposes the two MCP-ready primitives. Concurrency-
// safe by virtue of relying only on graph.Store's read methods —
// unless a Refiner is attached (refiners cache per-function CFGs and
// are meant for a single query).
type Engine struct {
g graph.Store
refiner *Refiner
}
// New returns an engine backed by the given graph.
func New(g graph.Store) *Engine { return &Engine{g: g} }
// WithRefiner attaches a CFG-backed reaching-definitions refiner:
// FlowBetween / TaintPaths results get per-hop
// confirmed_intraprocedural / pruned markers where both hop
// endpoints are bindings of the same function. Returns the engine
// for chaining.
func (e *Engine) WithRefiner(r *Refiner) *Engine {
e.refiner = r
return e
}
// IsDataflowKind returns true for the three edge kinds the BFS
// traverses.
func IsDataflowKind(k graph.EdgeKind) bool {
switch k {
case graph.EdgeValueFlow, graph.EdgeArgOf, graph.EdgeReturnsTo:
return true
}
return false
}
// FlowBetween returns up to maxPaths shortest paths from sourceID
// to sinkID, walking forward over dataflow edges. Returns nil when
// no path exists within maxDepth hops.
//
// maxDepth and maxPaths are clamped to safe defaults when zero.
func (e *Engine) FlowBetween(sourceID, sinkID string, maxDepth, maxPaths int) []Path {
return e.FlowBetweenWithTier(sourceID, sinkID, maxDepth, maxPaths, "")
}
// FlowBetweenWithTier is FlowBetween with an additional provenance
// filter: edges whose backfilled Origin tier ranks below minTier are
// skipped during traversal, pruning entire branches that cannot
// produce a fully-resolved path. Empty minTier disables the filter
// (identical to FlowBetween). The per-step Tier label is stamped on
// every retained EdgeStep so callers do not need to recompute it.
func (e *Engine) FlowBetweenWithTier(sourceID, sinkID string, maxDepth, maxPaths int, minTier string) []Path {
if e == nil || e.g == nil || sourceID == "" || sinkID == "" {
return nil
}
if sourceID == sinkID {
return []Path{{IDs: []string{sourceID}, Confidence: 1}}
}
if maxDepth <= 0 {
maxDepth = DefaultMaxDepth
}
if maxPaths <= 0 {
maxPaths = DefaultMaxPaths
}
// DFS with depth bound. We track the current path's node set
// to prevent cycles. Memoisation by (node, depth) would help
// pathological graphs but the maxDepth cap is already
// effective on real codebases — the typical taint walk hits
// 4-6 hops before either landing on the sink or running out
// of frontier.
var paths []Path
visited := make(map[string]bool, 32)
stack := []string{sourceID}
steps := []EdgeStep{}
visited[sourceID] = true
var dfs func(nodeID string, depth int)
dfs = func(nodeID string, depth int) {
if len(paths) >= maxPaths {
return
}
if depth >= maxDepth {
return
}
out := e.g.GetOutEdges(nodeID)
for _, ed := range out {
if !IsDataflowKind(ed.Kind) {
continue
}
if visited[ed.To] {
continue
}
origin := edgeOrigin(ed)
if minTier != "" && !graph.MeetsMinTier(origin, minTier) {
continue
}
step := EdgeStep{
From: ed.From,
To: ed.To,
Kind: string(ed.Kind),
Origin: origin,
Tier: graph.ResolvedBy(origin),
}
if ed.To == sinkID {
ids := append([]string(nil), stack...)
ids = append(ids, ed.To)
edgeCopy := append([]EdgeStep(nil), steps...)
edgeCopy = append(edgeCopy, step)
paths = append(paths, Path{
IDs: ids,
Edges: edgeCopy,
Confidence: confidenceFromEdges(edgeCopy),
})
if len(paths) >= maxPaths {
return
}
continue
}
visited[ed.To] = true
stack = append(stack, ed.To)
steps = append(steps, step)
dfs(ed.To, depth+1)
stack = stack[:len(stack)-1]
steps = steps[:len(steps)-1]
delete(visited, ed.To)
}
}
dfs(sourceID, 0)
// CFG-backed refinement: judge same-function value_flow hops
// with reaching-definition chains before ranking, so pruned
// paths sink below confirmed ones.
if e.refiner != nil {
e.refiner.refinePaths(paths)
}
rankPaths(paths)
if len(paths) > maxPaths {
paths = paths[:maxPaths]
}
return paths
}
// edgeOrigin returns the stamped Origin on an edge, falling back to
// DefaultOriginFor when the field is empty so back-compat graphs
// (produced before Origin was a first-class field) still classify
// cleanly for filtering and tier surfacing.
func edgeOrigin(e *graph.Edge) string {
if e.Origin != "" {
return e.Origin
}
src, _ := e.Meta["semantic_source"].(string)
return graph.DefaultOriginFor(e.Kind, e.Confidence, src)
}
// rankPaths sorts in-place so the most plausible explanation comes
// first. A path the reaching-definitions refinement disproved (any
// pruned hop) always sinks below every non-pruned path, regardless of
// length — confidence demotion alone can't move a pruned hop past a
// shorter confirmed one when length is the primary key. Among paths
// of the same pruned status, shorter and then higher-confidence paths
// sort first.
func rankPaths(paths []Path) {
sort.SliceStable(paths, func(i, j int) bool {
pi, pj := hasPrunedHop(paths[i]), hasPrunedHop(paths[j])
if pi != pj {
return !pi // non-pruned paths rank ahead of pruned ones
}
if len(paths[i].Edges) != len(paths[j].Edges) {
return len(paths[i].Edges) < len(paths[j].Edges)
}
return paths[i].Confidence > paths[j].Confidence
})
}
// hasPrunedHop reports whether any hop on the path was disproved by
// the CFG-backed refinement.
func hasPrunedHop(p Path) bool {
for _, e := range p.Edges {
if e.Refined == RefinedPruned {
return true
}
}
return false
}
// confidenceFromEdges computes a normalised path confidence from
// the per-edge origin tiers. Each edge contributes a 0-1 score
// based on how well-grounded its kind / origin are; the path's
// score is the geometric mean (product) so a single weak edge
// drags the whole chain down — matching the agent intuition that
// a dataflow path is only as strong as its weakest hop.
func confidenceFromEdges(edges []EdgeStep) float64 {
if len(edges) == 0 {
return 1
}
prod := 1.0
for _, e := range edges {
prod *= confidenceFromOrigin(e.Origin, e.Kind)
}
return prod
}
func confidenceFromOrigin(origin, kind string) float64 {
// Delegates to the shared graph.EdgeTierScore so dataflow and callpath
// compute path confidence from one provenance→weight mapping.
return graph.EdgeTierScore(origin, graph.EdgeKind(kind))
}
// TaintPattern resolves a source / sink pattern against the live
// graph. Patterns are name-substrings (case-insensitive) by
// default; `exact:Name` switches to exact-name match; `path:dir/`
// scopes to nodes whose FilePath starts with that prefix combined
// with any name fragment after `::`.
type TaintPattern struct {
Raw string
NameMatch func(string) bool
PathMatch func(string) bool
KindMatch func(graph.NodeKind) bool
}
// ParsePattern compiles a string into a TaintPattern. Supported
// syntaxes:
//
// - "Name" → case-insensitive substring match on node name.
// - "exact:Name" → exact name match.
// - "path:dir/" → file path prefix match (any name).
// - "kind:function" → restrict to a particular node kind.
// - "name=… kind=…" → multi-clause AND form (space-separated).
//
// The clauses combine with AND; an empty pattern matches nothing.
func ParsePattern(raw string) TaintPattern {
p := TaintPattern{Raw: raw}
if strings.TrimSpace(raw) == "" {
return p
}
clauses := strings.Fields(raw)
for _, c := range clauses {
switch {
case strings.HasPrefix(c, "exact:"):
want := strings.TrimPrefix(c, "exact:")
p.NameMatch = chainName(p.NameMatch, func(name string) bool {
return name == want
})
case strings.HasPrefix(c, "name:"):
want := strings.ToLower(strings.TrimPrefix(c, "name:"))
p.NameMatch = chainName(p.NameMatch, func(name string) bool {
return strings.Contains(strings.ToLower(name), want)
})
case strings.HasPrefix(c, "name="):
want := strings.ToLower(strings.TrimPrefix(c, "name="))
p.NameMatch = chainName(p.NameMatch, func(name string) bool {
return strings.Contains(strings.ToLower(name), want)
})
case strings.HasPrefix(c, "path:"):
want := strings.TrimPrefix(c, "path:")
p.PathMatch = chainName(p.PathMatch, func(path string) bool {
return strings.HasPrefix(path, want)
})
case strings.HasPrefix(c, "kind:"):
wantKind := graph.NodeKind(strings.TrimPrefix(c, "kind:"))
p.KindMatch = chainKind(p.KindMatch, func(k graph.NodeKind) bool {
return k == wantKind
})
default:
// Bare token — case-insensitive substring on name.
want := strings.ToLower(c)
p.NameMatch = chainName(p.NameMatch, func(name string) bool {
return strings.Contains(strings.ToLower(name), want)
})
}
}
return p
}
func chainName(prev, next func(string) bool) func(string) bool {
if prev == nil {
return next
}
return func(s string) bool { return prev(s) && next(s) }
}
func chainKind(prev, next func(graph.NodeKind) bool) func(graph.NodeKind) bool {
if prev == nil {
return next
}
return func(k graph.NodeKind) bool { return prev(k) && next(k) }
}
// Empty reports whether the pattern matches nothing.
func (p TaintPattern) Empty() bool {
return p.Raw == "" || (p.NameMatch == nil && p.PathMatch == nil && p.KindMatch == nil)
}
// matches reports whether n satisfies the compiled clauses. All
// configured matchers must pass; absent matchers are skipped.
func (p TaintPattern) matches(n *graph.Node) bool {
if n == nil {
return false
}
if p.NameMatch != nil && !p.NameMatch(n.Name) {
return false
}
if p.PathMatch != nil && !p.PathMatch(n.FilePath) {
return false
}
if p.KindMatch != nil && !p.KindMatch(n.Kind) {
return false
}
return true
}
// ResolveCandidates walks the graph and returns up to limit
// distinct symbol IDs whose nodes match the pattern. Returns the
// caller-friendly nodes themselves so MCP responses can include
// names + paths without a second lookup.
//
// The seed set is bounded by taintEligibleKinds — the fixed 8-kind
// allowlist (function/method/param/field/variable/constant/type/
// interface) that taintEligible enforces. Iterating the per-kind
// NodesByKind bucket of each lets the backend stream only those
// kinds instead of materialising the full node table;
// on a disk backend AllNodes() pulls ~70k rows per request just to land
// at a handful of taint candidates. Pattern post-filters (name /
// path / pattern-supplied kind) still run Go-side — they compose
// AND, can't be projected onto the bucket index efficiently, and
// the per-bucket population is already small.
func (e *Engine) ResolveCandidates(p TaintPattern, limit int) []*graph.Node {
if e == nil || e.g == nil || p.Empty() {
return nil
}
if limit <= 0 {
limit = 100
}
out := make([]*graph.Node, 0, 16)
for _, k := range taintEligibleKinds {
if len(out) >= limit {
break
}
for n := range e.g.NodesByKind(k) {
if n == nil {
continue
}
if !p.matches(n) {
continue
}
out = append(out, n)
if len(out) >= limit {
break
}
}
}
sort.SliceStable(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
// taintEligibleKinds is the seed-bucket allowlist of node kinds that
// could plausibly be a dataflow source or sink. Files / imports / pkg
// markers don't carry value semantics, so excluding them up front
// keeps the candidate set focused. Kept as a slice (not a set) so
// callers can iterate the NodesByKind bucket of each kind in a stable
// order.
var taintEligibleKinds = []graph.NodeKind{
graph.KindFunction, graph.KindMethod, graph.KindParam,
graph.KindField, graph.KindVariable, graph.KindConstant,
graph.KindType, graph.KindInterface,
}
// TaintFinding is one (source, sink) hit produced by TaintPaths.
// Paths is non-empty when at least one BFS path connects the two.
type TaintFinding struct {
Source *graph.Node `json:"source"`
Sink *graph.Node `json:"sink"`
Paths []Path `json:"paths"`
}
// TaintPaths resolves both patterns, then runs flow_between for
// each (source, sink) pair. Returns up to limit findings, sorted
// by best path confidence × shortest length.
//
// Role-aware expansion. Sources and sinks expand differently
// because the dataflow edges are directional. A "source" function
// produces values via its return — flow originates at the
// function node itself, which has incoming returns_to edges
// agents will walk forward from. A "sink" function consumes
// values via its parameters — flow terminates at the param nodes
// where arg_of lands. So when the sink pattern resolves to a
// function/method, we automatically include each declared
// parameter as an additional candidate. This matches the agent
// intuition that `name:DBQuery` for a sink means "every value
// that lands in any argument of DBQuery", not the function
// itself (which has no incoming dataflow).
func (e *Engine) TaintPaths(sourcePattern, sinkPattern TaintPattern, maxDepth, limit int) []TaintFinding {
return e.TaintPathsWithTier(sourcePattern, sinkPattern, maxDepth, limit, "")
}
// TaintPathsWithTier is TaintPaths with the same per-edge provenance
// filter as FlowBetweenWithTier; empty minTier preserves the legacy
// behavior.
func (e *Engine) TaintPathsWithTier(sourcePattern, sinkPattern TaintPattern, maxDepth, limit int, minTier string) []TaintFinding {
if e == nil || e.g == nil {
return nil
}
if maxDepth <= 0 {
maxDepth = DefaultMaxDepth
}
if limit <= 0 {
limit = 20
}
sources := e.ResolveCandidates(sourcePattern, 0)
rawSinks := e.ResolveCandidates(sinkPattern, 0)
sinks := e.expandSinkCandidates(rawSinks)
if len(sources) == 0 || len(sinks) == 0 {
return nil
}
var findings []TaintFinding
for _, src := range sources {
for _, sink := range sinks {
if src.ID == sink.ID {
continue
}
paths := e.FlowBetweenWithTier(src.ID, sink.ID, maxDepth, DefaultMaxPaths, minTier)
if len(paths) == 0 {
continue
}
findings = append(findings, TaintFinding{
Source: src,
Sink: sink,
Paths: paths,
})
}
}
sort.SliceStable(findings, func(i, j int) bool {
bi, bj := bestPath(findings[i].Paths), bestPath(findings[j].Paths)
if bi.Length() != bj.Length() {
return bi.Length() < bj.Length()
}
return bi.Confidence > bj.Confidence
})
if len(findings) > limit {
findings = findings[:limit]
}
return findings
}
// expandSinkCandidates appends every parameter node of every
// matched function/method, deduplicated by ID. Originals stay in
// the candidate set — sometimes a flow really does land on the
// function symbol itself (e.g., when a callee value is passed
// straight back through another return) and excluding it would
// hide that case.
func (e *Engine) expandSinkCandidates(raw []*graph.Node) []*graph.Node {
if len(raw) == 0 || e == nil || e.g == nil {
return raw
}
seen := make(map[string]struct{}, len(raw)*2)
out := make([]*graph.Node, 0, len(raw)*2)
add := func(n *graph.Node) {
if n == nil {
return
}
if _, ok := seen[n.ID]; ok {
return
}
seen[n.ID] = struct{}{}
out = append(out, n)
}
for _, n := range raw {
add(n)
if n.Kind != graph.KindFunction && n.Kind != graph.KindMethod {
continue
}
for _, edge := range e.g.GetInEdges(n.ID) {
if edge.Kind != graph.EdgeParamOf {
continue
}
pNode := e.g.GetNode(edge.From)
if pNode == nil || pNode.Kind != graph.KindParam {
continue
}
add(pNode)
}
}
return out
}
// bestPath returns the lowest-cost path in a finding (assumed
// already rankPaths-sorted). Falls back to a zero-value when the
// finding has none.
func bestPath(paths []Path) Path {
if len(paths) == 0 {
return Path{}
}
return paths[0]
}
+226
View File
@@ -0,0 +1,226 @@
package dataflow
import (
"testing"
"github.com/zzet/gortex/internal/graph"
)
func newGraphWithNodes(ids ...string) *graph.Graph {
g := graph.New()
for _, id := range ids {
g.AddNode(&graph.Node{
ID: id,
Kind: graph.KindFunction,
Name: id,
FilePath: "test.go",
})
}
return g
}
func addEdge(g *graph.Graph, kind graph.EdgeKind, from, to string) {
g.AddEdge(&graph.Edge{
From: from,
To: to,
Kind: kind,
FilePath: "test.go",
Origin: graph.OriginASTResolved,
})
}
func TestEngine_FlowBetween_Direct(t *testing.T) {
g := newGraphWithNodes("A", "B")
addEdge(g, graph.EdgeValueFlow, "A", "B")
e := New(g)
paths := e.FlowBetween("A", "B", 0, 0)
if len(paths) != 1 {
t.Fatalf("expected 1 path, got %d", len(paths))
}
if paths[0].Length() != 1 || paths[0].IDs[0] != "A" || paths[0].IDs[1] != "B" {
t.Errorf("unexpected path: %+v", paths[0])
}
if paths[0].Confidence <= 0 || paths[0].Confidence > 1 {
t.Errorf("confidence out of range: %v", paths[0].Confidence)
}
}
func TestEngine_FlowBetween_MultiHop(t *testing.T) {
g := newGraphWithNodes("A", "B", "C", "D")
addEdge(g, graph.EdgeValueFlow, "A", "B")
addEdge(g, graph.EdgeArgOf, "B", "C")
addEdge(g, graph.EdgeReturnsTo, "C", "D")
e := New(g)
paths := e.FlowBetween("A", "D", 0, 0)
if len(paths) != 1 {
t.Fatalf("expected 1 path, got %d: %+v", len(paths), paths)
}
if paths[0].Length() != 3 {
t.Errorf("unexpected length: %d", paths[0].Length())
}
}
func TestEngine_FlowBetween_NoPath(t *testing.T) {
g := newGraphWithNodes("A", "B", "C")
addEdge(g, graph.EdgeValueFlow, "A", "B")
e := New(g)
paths := e.FlowBetween("A", "C", 0, 0)
if len(paths) != 0 {
t.Errorf("expected no paths, got %+v", paths)
}
}
func TestEngine_FlowBetween_DepthBound(t *testing.T) {
// 5-hop chain: A → B → C → D → E → F
g := newGraphWithNodes("A", "B", "C", "D", "E", "F")
addEdge(g, graph.EdgeValueFlow, "A", "B")
addEdge(g, graph.EdgeValueFlow, "B", "C")
addEdge(g, graph.EdgeValueFlow, "C", "D")
addEdge(g, graph.EdgeValueFlow, "D", "E")
addEdge(g, graph.EdgeValueFlow, "E", "F")
e := New(g)
paths := e.FlowBetween("A", "F", 3, 0)
if len(paths) != 0 {
t.Errorf("max_depth=3 should not reach F at hop 5, got %+v", paths)
}
paths = e.FlowBetween("A", "F", 5, 0)
if len(paths) != 1 {
t.Errorf("max_depth=5 should reach F, got %+v", paths)
}
}
func TestEngine_FlowBetween_MultiplePaths(t *testing.T) {
// Diamond: A → B → D
// A → C → D
g := newGraphWithNodes("A", "B", "C", "D")
addEdge(g, graph.EdgeValueFlow, "A", "B")
addEdge(g, graph.EdgeValueFlow, "B", "D")
addEdge(g, graph.EdgeValueFlow, "A", "C")
addEdge(g, graph.EdgeValueFlow, "C", "D")
e := New(g)
paths := e.FlowBetween("A", "D", 0, 0)
if len(paths) != 2 {
t.Fatalf("expected 2 paths through diamond, got %d: %+v", len(paths), paths)
}
}
func TestEngine_FlowBetween_IgnoresNonDataflow(t *testing.T) {
g := newGraphWithNodes("A", "B")
// Calls is not a dataflow edge; the BFS must skip it.
addEdge(g, graph.EdgeCalls, "A", "B")
e := New(g)
if got := e.FlowBetween("A", "B", 0, 0); len(got) != 0 {
t.Errorf("expected no paths on call-only graph, got %+v", got)
}
}
func TestEngine_FlowBetween_SelfPath(t *testing.T) {
g := newGraphWithNodes("A")
e := New(g)
paths := e.FlowBetween("A", "A", 0, 0)
if len(paths) != 1 || len(paths[0].IDs) != 1 {
t.Errorf("expected one trivial self-path, got %+v", paths)
}
}
func TestEngine_TaintPaths_PatternMatch(t *testing.T) {
g := graph.New()
addNamedFunc := func(id, name string) {
g.AddNode(&graph.Node{
ID: id,
Kind: graph.KindFunction,
Name: name,
FilePath: "test.go",
})
}
addNamedFunc("pkg.GetEnv", "GetEnv")
addNamedFunc("pkg.Mid", "mid")
addNamedFunc("pkg.DBQuery", "DBQuery")
addEdge(g, graph.EdgeValueFlow, "pkg.GetEnv", "pkg.Mid")
addEdge(g, graph.EdgeArgOf, "pkg.Mid", "pkg.DBQuery")
e := New(g)
src := ParsePattern("name:Env")
sink := ParsePattern("name:Query")
findings := e.TaintPaths(src, sink, 0, 0)
if len(findings) != 1 {
t.Fatalf("expected 1 finding, got %d: %+v", len(findings), findings)
}
f := findings[0]
if f.Source.ID != "pkg.GetEnv" || f.Sink.ID != "pkg.DBQuery" {
t.Errorf("unexpected pair: %s → %s", f.Source.ID, f.Sink.ID)
}
if len(f.Paths) == 0 {
t.Errorf("path missing on finding")
}
}
func TestEngine_TaintPaths_RanksByLength(t *testing.T) {
g := graph.New()
for _, name := range []string{"src1", "src2", "mid", "sink"} {
g.AddNode(&graph.Node{
ID: name, Kind: graph.KindFunction, Name: name, FilePath: "t.go",
})
}
// src1 → sink (1 hop)
addEdge(g, graph.EdgeValueFlow, "src1", "sink")
// src2 → mid → sink (2 hops)
addEdge(g, graph.EdgeValueFlow, "src2", "mid")
addEdge(g, graph.EdgeValueFlow, "mid", "sink")
e := New(g)
src := ParsePattern("name:src")
sink := ParsePattern("exact:sink")
findings := e.TaintPaths(src, sink, 0, 0)
if len(findings) != 2 {
t.Fatalf("expected 2 findings, got %d", len(findings))
}
if findings[0].Source.ID != "src1" {
t.Errorf("expected src1 first (1 hop), got %s", findings[0].Source.ID)
}
}
func TestParsePattern_Combined(t *testing.T) {
p := ParsePattern("name:foo kind:method")
if p.Empty() {
t.Fatal("pattern should match")
}
matchNode := func(name string, kind graph.NodeKind) bool {
return p.matches(&graph.Node{Name: name, Kind: kind})
}
if !matchNode("foobar", graph.KindMethod) {
t.Error("foobar method should match")
}
if matchNode("foobar", graph.KindFunction) {
t.Error("function should not match kind:method clause")
}
if matchNode("baz", graph.KindMethod) {
t.Error("baz should not match name:foo clause")
}
}
func TestParsePattern_Exact(t *testing.T) {
p := ParsePattern("exact:Foo")
matchNode := func(name string) bool {
return p.matches(&graph.Node{Name: name, Kind: graph.KindFunction})
}
if !matchNode("Foo") {
t.Error("exact match must hit Foo")
}
if matchNode("FooBar") {
t.Error("exact match must not hit FooBar")
}
}
func TestParsePattern_Path(t *testing.T) {
p := ParsePattern("path:cmd/ name:main")
matchNode := func(name, path string) bool {
return p.matches(&graph.Node{Name: name, FilePath: path, Kind: graph.KindFunction})
}
if !matchNode("main", "cmd/server/main.go") {
t.Error("cmd/server/main.go::main should match")
}
if matchNode("main", "internal/server/main.go") {
t.Error("internal path should not match cmd/ prefix")
}
}
+269
View File
@@ -0,0 +1,269 @@
package dataflow
import (
"strings"
"github.com/zzet/gortex/internal/cfg"
"github.com/zzet/gortex/internal/graph"
)
// Refinement markers stamped on EdgeStep.Refined. A confirmed hop
// has a CFG-verified reaching-definition chain from the source
// binding to the statement that defines the target binding; a pruned
// hop is one the chain analysis disproves (the source's definition
// is killed on every path before the target's defining statement).
const (
RefinedConfirmed = "confirmed_intraprocedural"
RefinedPruned = "pruned"
)
// prunedPenalty scales a path's confidence for every hop the
// reaching-definitions analysis disproves, so stale edges sink in
// the ranking instead of silently disappearing.
const prunedPenalty = 0.25
// defaultRefinerCapacity bounds how many per-function CFGs one
// refiner will hold. Refiners are per-call; the cap keeps a
// pathological many-function path from accumulating parse trees.
//
// A taint sweep reuses one refiner across every (source, sink) pair,
// and refinement runs per pair before the findings are ranked and
// capped — so a function on several candidate paths must survive in
// the cache between pairs or it gets re-parsed. The working set of a
// single flow_between walk is bounded by DefaultMaxPaths distinct
// paths of at most DefaultMaxDepth hops; sizing the cache to cover
// that union keeps a broad pattern sweep from thrashing the FIFO,
// while still bounding transient memory (the refiner is discarded at
// the end of the call).
const defaultRefinerCapacity = DefaultMaxPaths * DefaultMaxDepth
// FuncSource is one function's source text plus the file-absolute
// line its first byte sits on.
type FuncSource struct {
Src []byte
StartLine int
}
// SourceResolver fetches the source of a function/method node. The
// MCP layer supplies an overlay-aware reader; tests can return
// source from memory.
type SourceResolver func(fn *graph.Node) (FuncSource, error)
// Refiner upgrades value_flow hops whose endpoints are bindings of
// the same function using statement-granular reaching-definition
// chains. CFGs are built lazily — only for functions that actually
// appear on a candidate path — and cached with FIFO eviction. Not
// safe for concurrent use; construct one per query.
type Refiner struct {
g graph.Store
resolve SourceResolver
cap int
entries map[string]*refEntry
order []string
}
// refEntry caches one function's analysis; a nil graph marks a
// negative entry (unsupported language, unreadable source, parse
// failure) so the failure isn't retried per hop.
type refEntry struct {
c *cfg.CFG
r *cfg.ReachingResult
}
// NewRefiner builds a refiner over the graph with the given source
// resolver. capacity <= 0 selects the default.
func NewRefiner(g graph.Store, resolve SourceResolver, capacity int) *Refiner {
if capacity <= 0 {
capacity = defaultRefinerCapacity
}
return &Refiner{
g: g,
resolve: resolve,
cap: capacity,
entries: make(map[string]*refEntry, capacity),
}
}
// refinePaths stamps Refined markers on the value_flow hops it can
// judge and rescales path confidence for pruned hops. Returns true
// when any confidence changed (callers re-rank).
func (r *Refiner) refinePaths(paths []Path) bool {
if r == nil {
return false
}
changed := false
for pi := range paths {
for si := range paths[pi].Edges {
step := &paths[pi].Edges[si]
if graph.EdgeKind(step.Kind) != graph.EdgeValueFlow {
continue
}
switch r.refineStep(step) {
case RefinedConfirmed:
step.Refined = RefinedConfirmed
case RefinedPruned:
step.Refined = RefinedPruned
paths[pi].Confidence *= prunedPenalty
changed = true
}
}
}
return changed
}
// refineStep judges one hop. Returns "" when the hop is out of scope
// (endpoints not bindings of the same function, unsupported
// language) or the CFG can't anchor both endpoints — unmarked hops
// keep their coarse-edge semantics.
func (r *Refiner) refineStep(step *EdgeStep) string {
fromOwner, fromName, ok := splitBindingID(step.From)
if !ok {
return ""
}
toOwner, toName, ok := splitBindingID(step.To)
if !ok || fromOwner != toOwner || fromName == "" || toName == "" {
return ""
}
ent := r.entryFor(fromOwner)
if ent == nil || ent.c == nil {
return ""
}
defStmt := r.bindingDefStmt(ent, step.From, fromName)
if defStmt == nil {
return ""
}
toNode := r.g.GetNode(step.To)
if toNode == nil || toNode.StartLine == 0 {
return ""
}
useStmt := ent.c.StatementAt(toNode.StartLine, toName)
if useStmt == nil || useStmt.Index == defStmt.Index {
return ""
}
// The hop claims `toName`'s definition consumes `fromName`. If
// the CFG statement doesn't even read fromName the extraction
// disagrees with the graph edge — stay unmarked rather than
// judging on mismatched evidence.
reads := false
for _, u := range useStmt.Uses {
if u == fromName {
reads = true
break
}
}
if !reads {
return ""
}
for _, ch := range ent.r.ChainsFor(useStmt.Index) {
if ch.Var != fromName {
continue
}
for _, d := range ch.Defs {
if d == defStmt.Index {
return RefinedConfirmed
}
}
}
// fromName is read at the target statement, but the specific
// definition this hop starts from never reaches it — every path
// kills it first.
return RefinedPruned
}
// bindingDefStmt anchors a binding node onto its defining CFG
// statement: params map to the synthetic entry-block param
// statements, locals to the statement covering their binding line.
func (r *Refiner) bindingDefStmt(ent *refEntry, id, name string) *cfg.Statement {
if strings.Contains(id, "#param:") {
for _, st := range ent.c.Stmts {
if st.Kind != "param" {
continue
}
for _, d := range st.Defs {
if d == name {
return st
}
}
}
return nil
}
node := r.g.GetNode(id)
if node == nil || node.StartLine == 0 {
return nil
}
return ent.c.StatementAt(node.StartLine, name)
}
// entryFor returns the cached analysis for a function, building it
// on first sight. Failures cache negatively.
func (r *Refiner) entryFor(ownerID string) *refEntry {
if ent, ok := r.entries[ownerID]; ok {
return ent
}
ent := &refEntry{}
r.insert(ownerID, ent)
fn := r.g.GetNode(ownerID)
if fn == nil || (fn.Kind != graph.KindFunction && fn.Kind != graph.KindMethod) {
return ent
}
if !cfg.SupportedLanguage(fn.Language) || fn.StartLine == 0 || fn.EndLine == 0 {
return ent
}
src, err := r.resolve(fn)
if err != nil || len(src.Src) == 0 {
return ent
}
c, err := cfg.Build(src.Src, fn.Language, cfg.Options{
LineOffset: src.StartLine - 1,
FuncName: fn.Name,
})
if err != nil {
return ent
}
ent.c = c
ent.r = c.ReachingDefinitions()
return ent
}
func (r *Refiner) insert(key string, ent *refEntry) {
if len(r.order) >= r.cap {
oldest := r.order[0]
r.order = r.order[1:]
delete(r.entries, oldest)
}
r.entries[key] = ent
r.order = append(r.order, key)
}
// splitBindingID decomposes the dataflow binding ID forms emitted at
// extraction time into the owning function ID and the variable name.
// Both binding forms may carry a position/offset suffix after `@`:
//
// - locals: `<owner>#local:<name>@+<offsetFromOwnerStartLine>`
// - params: `<owner>#param:<name>` (Go) or
// `<owner>#param:<name>@<position>` (Python / TypeScript / Rust /
// Java / C#, which disambiguate duplicate names by position).
//
// The `@…` suffix is stripped from both so the bare variable name
// matches the CFG's def/use sets; without that the param branch
// silently left every non-Go param hop unrefined.
func splitBindingID(id string) (owner, name string, ok bool) {
if i := strings.Index(id, "#local:"); i > 0 {
return id[:i], trimBindingSuffix(id[i+len("#local:"):]), true
}
if i := strings.Index(id, "#param:"); i > 0 {
return id[:i], trimBindingSuffix(id[i+len("#param:"):]), true
}
return "", "", false
}
// trimBindingSuffix drops the `@<position>` / `@+<offset>`
// disambiguator a binding ID carries after its name.
func trimBindingSuffix(rest string) string {
if j := strings.IndexByte(rest, '@'); j >= 0 {
return rest[:j]
}
return rest
}
+318
View File
@@ -0,0 +1,318 @@
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)
}
}
}