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
402 lines
13 KiB
Go
402 lines
13 KiB
Go
package indexer
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// TestMaterializeDataflowParamsForFile_EquivalentToWholeGraph proves the
|
|
// correctness claim behind the scoped per-file dataflow materialisation:
|
|
// materializeDataflowParamsForFile, run once per file, rewrites EXACTLY
|
|
// the same EdgeArgOf / EdgeReturnsTo edges — to the same (From, To, Kind)
|
|
// tuples — as the whole-graph materializeDataflowParams does in a single
|
|
// AllEdges scan.
|
|
//
|
|
// Why this holds (the invariant under test): returns_to's From is the
|
|
// enclosing caller function (a file node), while arg_of's From is the
|
|
// argument's source — a file local for a bare in-scope identifier, but a
|
|
// synthetic `unresolved::` id for selector / package-qualified / global /
|
|
// nested-call arguments, which is NOT a file node. The scoped pass
|
|
// therefore probes the union of (the file's nodes) and (the synthetic
|
|
// From ids the file's freshly-extracted edges carry), then keeps only
|
|
// edges whose FilePath is this file — exactly the arg_of+returns_to set
|
|
// the whole-graph pass would touch for it. The fixture below exercises
|
|
// all four argument shapes so the synthetic-From cases are covered.
|
|
//
|
|
// Method: build ONE resolved-but-not-yet-materialised graph from a small
|
|
// multi-file Go fixture (a caller file that calls a callee in another
|
|
// file, passing a parameter as an argument and assigning the return
|
|
// value), deep-clone it into two byte-identical graphs, then:
|
|
//
|
|
// (a) run materializeDataflowParams() once on gGlobal
|
|
// (b) run materializeDataflowParamsForFile(path) for each file on gScoped
|
|
//
|
|
// and assert the arg_of+returns_to {From,To,Kind} tuple sets are
|
|
// IDENTICAL. Cloning (not two independent indexings) removes any
|
|
// node-id / ordering nondeterminism, so any divergence is the scoping
|
|
// logic, not the build.
|
|
func TestMaterializeDataflowParamsForFile_EquivalentToWholeGraph(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
// callee.go: a function with a declared parameter and a return value.
|
|
// The param node gives rewriteArgOf a #param: target to lift the
|
|
// arg_of edge onto; the return value gives the caller a returns_to
|
|
// edge to rewrite onto the resolved callee.
|
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "sink"), 0o755))
|
|
writeFile(t, filepath.Join(dir, "sink", "callee.go"), `package sink
|
|
|
|
// Transform consumes payload and returns a derived value. The declared
|
|
// parameter is what rewriteArgOf lifts an arg_of edge onto.
|
|
func Transform(payload string) string {
|
|
return payload + "!"
|
|
}
|
|
`)
|
|
|
|
// caller.go: calls sink.Transform passing its own parameter as the
|
|
// argument (so arg_of's From is a dataflow node, not a literal) and
|
|
// assigns the return value (so returns_to is emitted). Both edges are
|
|
// anchored to nodes in THIS file.
|
|
writeFile(t, filepath.Join(dir, "caller.go"), `package main
|
|
|
|
import "fmt"
|
|
|
|
import "`+goModName+`/sink"
|
|
|
|
var GlobalCfg = "cfg"
|
|
|
|
type Box struct{ Payload string }
|
|
|
|
func Drive(input string, b Box) {
|
|
out := sink.Transform(input) // bare in-scope arg: From resolves to a file local
|
|
fmt.Println(out) // arg_of(out) + returns_to
|
|
sink.Transform(b.Payload) // selector arg: From = synthetic unresolved::*.Payload
|
|
sink.Transform(GlobalCfg) // global arg: From = synthetic unresolved::GlobalCfg
|
|
sink.Transform(echo(input)) // nested-call arg: From = synthetic unresolved::echo
|
|
}
|
|
|
|
func echo(s string) string { return s }
|
|
`)
|
|
|
|
// A go.mod so the cross-file import resolves to a real callee node
|
|
// (resolver.ResolveAll lifts unresolved::Transform → the sink node).
|
|
writeFile(t, filepath.Join(dir, "go.mod"), "module "+goModName+"\n\ngo 1.22\n")
|
|
|
|
// Build ONE raw graph: index every file WITHOUT the per-file dataflow
|
|
// pass, then run the cross-file resolver so unresolved:: call targets
|
|
// are lifted — but stop short of any materialisation. This is exactly
|
|
// the state both materialise passes are designed to consume.
|
|
gRaw := graph.New()
|
|
idx := newTestIndexer(gRaw)
|
|
files := goFilesUnder(t, dir)
|
|
require.NotEmpty(t, files)
|
|
for _, f := range files {
|
|
require.NoError(t, idx.IndexFileNoResolve(f))
|
|
}
|
|
idx.resolver.ResolveAll()
|
|
|
|
// Sanity: the fixture must actually emit the edges we claim to test.
|
|
// If it doesn't, an "equivalent" result is vacuously true and proves
|
|
// nothing — fail loudly instead.
|
|
preArg, preRet := countKinds(gRaw)
|
|
require.Greaterf(t, preArg, 0,
|
|
"fixture produced no EdgeArgOf edges; nothing to materialise (edges: %s)", dumpDataflow(gRaw))
|
|
require.Greaterf(t, preRet, 0,
|
|
"fixture produced no EdgeReturnsTo edges; nothing to materialise (edges: %s)", dumpDataflow(gRaw))
|
|
// Guard against a vacuous pass: the fixture MUST produce at least one
|
|
// arg_of edge whose From is a synthetic (unresolved::/external::) id —
|
|
// the selector / global / nested-call shape a node-membership scope
|
|
// misses. This is the exact regression the scoped pass must handle, so
|
|
// fail loudly if the fixture stops exercising it.
|
|
require.Truef(t, hasSyntheticArgFrom(gRaw),
|
|
"fixture produced no synthetic-From arg_of edge; the regression case is not exercised (edges: %s)", dumpDataflow(gRaw))
|
|
|
|
// Two byte-identical clones of the raw graph.
|
|
gGlobal := cloneGraph(gRaw)
|
|
gScoped := cloneGraph(gRaw)
|
|
require.Equal(t, dataflowTupleSet(gRaw), dataflowTupleSet(gGlobal),
|
|
"clone must reproduce the raw graph's dataflow edges before any pass runs")
|
|
require.Equal(t, dataflowTupleSet(gRaw), dataflowTupleSet(gScoped),
|
|
"clone must reproduce the raw graph's dataflow edges before any pass runs")
|
|
|
|
// (a) whole-graph pass on gGlobal.
|
|
idxGlobal := newTestIndexer(gGlobal)
|
|
idxGlobal.materializeDataflowParams()
|
|
|
|
// (b) scoped per-file pass on gScoped — once per file, mirroring the
|
|
// incremental re-index path that calls it after ResolveFile.
|
|
idxScoped := newTestIndexer(gScoped)
|
|
for _, gp := range graphFilePaths(gScoped) {
|
|
idxScoped.materializeDataflowParamsForFile(gp, fileEdgesOf(gScoped, gp))
|
|
}
|
|
|
|
globalSet := dataflowTupleSet(gGlobal)
|
|
scopedSet := dataflowTupleSet(gScoped)
|
|
|
|
// The whole point: a rewrite must have actually occurred (at least one
|
|
// arg_of lifted to a #param: target, at least one returns_to lifted to
|
|
// the resolved callee), otherwise both sets equalling the raw set
|
|
// would pass trivially without exercising the rewrite logic.
|
|
require.Truef(t, rewriteOccurred(gGlobal),
|
|
"whole-graph pass performed no rewrite; test would be vacuous (edges: %s)", dumpDataflow(gGlobal))
|
|
|
|
if globalSet != scopedSet {
|
|
t.Fatalf("scoped per-file dataflow materialisation diverged from the whole-graph pass\n%s",
|
|
diffTupleSets(globalSet, scopedSet))
|
|
}
|
|
}
|
|
|
|
const goModName = "dataflowfixture"
|
|
|
|
// goFilesUnder returns absolute paths to every .go file under dir, sorted
|
|
// for determinism.
|
|
func goFilesUnder(t *testing.T, dir string) []string {
|
|
t.Helper()
|
|
var out []string
|
|
require.NoError(t, filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
if strings.HasSuffix(path, ".go") {
|
|
out = append(out, path)
|
|
}
|
|
return nil
|
|
}))
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// graphFilePaths returns the distinct file-node paths in the graph
|
|
// (the keys GetFileNodes / materializeDataflowParamsForFile accept),
|
|
// sorted for determinism.
|
|
func graphFilePaths(g graph.Store) []string {
|
|
seen := map[string]struct{}{}
|
|
for _, n := range g.AllNodes() {
|
|
if n == nil || n.FilePath == "" {
|
|
continue
|
|
}
|
|
seen[n.FilePath] = struct{}{}
|
|
}
|
|
out := make([]string, 0, len(seen))
|
|
for p := range seen {
|
|
out = append(out, p)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// fileEdgesOf returns the edges the given file emitted, matched by the
|
|
// edge's own FilePath — the test stand-in for indexFile's result.Edges,
|
|
// from which materializeDataflowParamsForFile reads From endpoints
|
|
// (including the synthetic ids that are not file nodes).
|
|
func fileEdgesOf(g graph.Store, filePath string) []*graph.Edge {
|
|
var out []*graph.Edge
|
|
for _, e := range g.AllEdges() {
|
|
if e != nil && e.FilePath == filePath {
|
|
out = append(out, e)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// dataflowTupleSet renders the EdgeArgOf + EdgeReturnsTo edges as a sorted,
|
|
// newline-joined set of "Kind|From|To" tuples. Two graphs with an equal
|
|
// set are indistinguishable for the dataflow edges this pass owns.
|
|
func dataflowTupleSet(g graph.Store) string {
|
|
var lines []string
|
|
for _, e := range g.AllEdges() {
|
|
if e == nil {
|
|
continue
|
|
}
|
|
if e.Kind != graph.EdgeArgOf && e.Kind != graph.EdgeReturnsTo {
|
|
continue
|
|
}
|
|
lines = append(lines, string(e.Kind)+"|"+e.From+"|"+e.To)
|
|
}
|
|
sort.Strings(lines)
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
// countKinds counts arg_of and returns_to edges in the graph.
|
|
func countKinds(g graph.Store) (argOf, returnsTo int) {
|
|
for _, e := range g.AllEdges() {
|
|
if e == nil {
|
|
continue
|
|
}
|
|
switch e.Kind {
|
|
case graph.EdgeArgOf:
|
|
argOf++
|
|
case graph.EdgeReturnsTo:
|
|
returnsTo++
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// rewriteOccurred reports whether the materialise pass actually moved an
|
|
// edge: an arg_of now points at a #param: node, or a returns_to no longer
|
|
// originates from an unresolved/placeholder caller (its From was lifted to
|
|
// the resolved callee, observable as a From that is itself the To of a
|
|
// resolved EdgeArgOf's owner — pragmatically we detect the arg_of lift,
|
|
// which is unambiguous).
|
|
func rewriteOccurred(g graph.Store) bool {
|
|
for _, e := range g.AllEdges() {
|
|
if e == nil {
|
|
continue
|
|
}
|
|
if e.Kind == graph.EdgeArgOf && strings.Contains(e.To, "#param:") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// hasSyntheticArgFrom reports whether any arg_of edge's From is a
|
|
// synthetic placeholder (unresolved::/external::) rather than a real file
|
|
// node — the shape that a node-membership-only scope would skip.
|
|
func hasSyntheticArgFrom(g graph.Store) bool {
|
|
for _, e := range g.AllEdges() {
|
|
if e == nil || e.Kind != graph.EdgeArgOf {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(e.From, "unresolved::") || strings.HasPrefix(e.From, "external::") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// dumpDataflow renders the arg_of/returns_to edges (with the Meta keys the
|
|
// rewrites read) for failure diagnostics.
|
|
func dumpDataflow(g graph.Store) string {
|
|
var lines []string
|
|
for _, e := range g.AllEdges() {
|
|
if e == nil || (e.Kind != graph.EdgeArgOf && e.Kind != graph.EdgeReturnsTo) {
|
|
continue
|
|
}
|
|
lines = append(lines, string(e.Kind)+" "+e.From+" -> "+e.To+
|
|
" meta{arg_position="+metaVal(e.Meta, "arg_position")+
|
|
" returns_to_call="+metaVal(e.Meta, "returns_to_call")+
|
|
" call_line="+metaVal(e.Meta, "call_line")+
|
|
" callee_target="+metaVal(e.Meta, "callee_target")+"}")
|
|
}
|
|
sort.Strings(lines)
|
|
return "\n " + strings.Join(lines, "\n ")
|
|
}
|
|
|
|
func metaVal(m map[string]any, k string) string {
|
|
if m == nil {
|
|
return "<nil-meta>"
|
|
}
|
|
v, ok := m[k]
|
|
if !ok {
|
|
return "<absent>"
|
|
}
|
|
switch x := v.(type) {
|
|
case string:
|
|
return x
|
|
case bool:
|
|
if x {
|
|
return "true"
|
|
}
|
|
return "false"
|
|
case int:
|
|
return strconv.Itoa(x)
|
|
case int64:
|
|
return strconv.Itoa(int(x))
|
|
case float64:
|
|
return strconv.Itoa(int(x))
|
|
default:
|
|
return "?"
|
|
}
|
|
}
|
|
|
|
// diffTupleSets renders a unified line-diff of two sorted tuple sets.
|
|
func diffTupleSets(global, scoped string) string {
|
|
g := map[string]struct{}{}
|
|
for _, l := range strings.Split(global, "\n") {
|
|
if l != "" {
|
|
g[l] = struct{}{}
|
|
}
|
|
}
|
|
s := map[string]struct{}{}
|
|
for _, l := range strings.Split(scoped, "\n") {
|
|
if l != "" {
|
|
s[l] = struct{}{}
|
|
}
|
|
}
|
|
var onlyGlobal, onlyScoped []string
|
|
for l := range g {
|
|
if _, ok := s[l]; !ok {
|
|
onlyGlobal = append(onlyGlobal, l)
|
|
}
|
|
}
|
|
for l := range s {
|
|
if _, ok := g[l]; !ok {
|
|
onlyScoped = append(onlyScoped, l)
|
|
}
|
|
}
|
|
sort.Strings(onlyGlobal)
|
|
sort.Strings(onlyScoped)
|
|
var b strings.Builder
|
|
b.WriteString("only in WHOLE-GRAPH pass (missing from scoped):\n")
|
|
for _, l := range onlyGlobal {
|
|
b.WriteString(" - " + l + "\n")
|
|
}
|
|
b.WriteString("only in SCOPED pass (missing from whole-graph):\n")
|
|
for _, l := range onlyScoped {
|
|
b.WriteString(" + " + l + "\n")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// cloneGraph builds a fresh in-memory graph that is structurally identical
|
|
// to src, deep-copying every node and edge (including Meta) so a pass run
|
|
// on the clone cannot mutate src or the sibling clone.
|
|
func cloneGraph(src graph.Store) graph.Store {
|
|
dst := graph.New()
|
|
srcNodes := src.AllNodes()
|
|
srcEdges := src.AllEdges()
|
|
nodes := make([]*graph.Node, 0, len(srcNodes))
|
|
for _, n := range srcNodes {
|
|
if n == nil {
|
|
continue
|
|
}
|
|
nc := *n
|
|
nc.Meta = cloneMeta(n.Meta)
|
|
nodes = append(nodes, &nc)
|
|
}
|
|
edges := make([]*graph.Edge, 0, len(srcEdges))
|
|
for _, e := range srcEdges {
|
|
if e == nil {
|
|
continue
|
|
}
|
|
ec := *e
|
|
ec.Meta = cloneMeta(e.Meta)
|
|
edges = append(edges, &ec)
|
|
}
|
|
dst.AddBatch(nodes, edges)
|
|
return dst
|
|
}
|
|
|
|
func cloneMeta(m map[string]any) map[string]any {
|
|
if m == nil {
|
|
return nil
|
|
}
|
|
c := make(map[string]any, len(m))
|
|
for k, v := range m {
|
|
c[k] = v
|
|
}
|
|
return c
|
|
}
|