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
490 lines
14 KiB
Go
490 lines
14 KiB
Go
package analysis
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/zzet/gortex/internal/graph"
|
|
"github.com/zzet/gortex/internal/query"
|
|
"pgregory.net/rapid"
|
|
)
|
|
|
|
// Feature: gortex-enhancements, Property 1: Contract violation detection completeness
|
|
|
|
// --- Generators ---
|
|
|
|
// genParamType generates a random Go parameter type.
|
|
func genParamType() *rapid.Generator[string] {
|
|
return rapid.SampledFrom([]string{
|
|
"string", "int", "bool", "float64", "error",
|
|
"context.Context", "[]byte", "map[string]any",
|
|
"*http.Request", "io.Reader",
|
|
})
|
|
}
|
|
|
|
// genSignature builds a "func(...) ..." string from a list of param types.
|
|
func buildSignature(params []string) string {
|
|
if len(params) == 0 {
|
|
return "func()"
|
|
}
|
|
named := make([]string, len(params))
|
|
for i, p := range params {
|
|
named[i] = fmt.Sprintf("p%d %s", i, p)
|
|
}
|
|
return fmt.Sprintf("func(%s)", strings.Join(named, ", "))
|
|
}
|
|
|
|
// callerGraphResult holds a generated graph with known callers for a target function.
|
|
type callerGraphResult struct {
|
|
Graph *graph.Graph
|
|
Engine *query.Engine
|
|
TargetID string
|
|
OldSig string
|
|
OldParams []string
|
|
CallerIDs []string
|
|
}
|
|
|
|
// genCallerGraph generates a graph with a target function and N callers.
|
|
func genCallerGraph() *rapid.Generator[callerGraphResult] {
|
|
return rapid.Custom(func(t *rapid.T) callerGraphResult {
|
|
g := graph.New()
|
|
|
|
// Generate 1-5 old params for the target function
|
|
numOldParams := rapid.IntRange(1, 5).Draw(t, "numOldParams")
|
|
oldParams := make([]string, numOldParams)
|
|
for i := range numOldParams {
|
|
oldParams[i] = genParamType().Draw(t, fmt.Sprintf("oldParam%d", i))
|
|
}
|
|
oldSig := buildSignature(oldParams)
|
|
|
|
targetID := "pkg/target.go::TargetFunc"
|
|
g.AddNode(&graph.Node{
|
|
ID: targetID,
|
|
Kind: graph.KindFunction,
|
|
Name: "TargetFunc",
|
|
FilePath: "pkg/target.go",
|
|
StartLine: 10,
|
|
EndLine: 30,
|
|
Language: "go",
|
|
Meta: map[string]any{"signature": oldSig},
|
|
})
|
|
|
|
// Generate 1-8 callers
|
|
numCallers := rapid.IntRange(1, 8).Draw(t, "numCallers")
|
|
callerIDs := make([]string, numCallers)
|
|
for i := range numCallers {
|
|
callerID := fmt.Sprintf("pkg/caller%d.go::Caller%d", i, i)
|
|
callerIDs[i] = callerID
|
|
g.AddNode(&graph.Node{
|
|
ID: callerID,
|
|
Kind: graph.KindFunction,
|
|
Name: fmt.Sprintf("Caller%d", i),
|
|
FilePath: fmt.Sprintf("pkg/caller%d.go", i),
|
|
StartLine: 1,
|
|
EndLine: 20,
|
|
Language: "go",
|
|
})
|
|
g.AddEdge(&graph.Edge{
|
|
From: callerID,
|
|
To: targetID,
|
|
Kind: graph.EdgeCalls,
|
|
})
|
|
}
|
|
|
|
engine := query.NewEngine(g)
|
|
return callerGraphResult{
|
|
Graph: g,
|
|
Engine: engine,
|
|
TargetID: targetID,
|
|
OldSig: oldSig,
|
|
OldParams: oldParams,
|
|
CallerIDs: callerIDs,
|
|
}
|
|
})
|
|
}
|
|
|
|
// interfaceGraphResult holds a generated graph with interface implementations.
|
|
type interfaceGraphResult struct {
|
|
Graph *graph.Graph
|
|
Engine *query.Engine
|
|
MethodID string
|
|
InterfaceID string
|
|
ParentTypeID string
|
|
OldSig string
|
|
OldParams []string
|
|
ImplTypeIDs []string
|
|
ImplMethodIDs []string
|
|
}
|
|
|
|
// genInterfaceGraph generates a graph with an interface, a type implementing it,
|
|
// and additional implementor types whose methods should be flagged on signature change.
|
|
func genInterfaceGraph() *rapid.Generator[interfaceGraphResult] {
|
|
return rapid.Custom(func(t *rapid.T) interfaceGraphResult {
|
|
g := graph.New()
|
|
|
|
// Generate 1-4 old params
|
|
numOldParams := rapid.IntRange(1, 4).Draw(t, "numOldParams")
|
|
oldParams := make([]string, numOldParams)
|
|
for i := range numOldParams {
|
|
oldParams[i] = genParamType().Draw(t, fmt.Sprintf("oldParam%d", i))
|
|
}
|
|
oldSig := buildSignature(oldParams)
|
|
|
|
// Interface
|
|
interfaceID := "pkg/iface.go::MyInterface"
|
|
g.AddNode(&graph.Node{
|
|
ID: interfaceID,
|
|
Kind: graph.KindInterface,
|
|
Name: "MyInterface",
|
|
FilePath: "pkg/iface.go",
|
|
Language: "go",
|
|
})
|
|
|
|
// Primary type that implements the interface
|
|
parentTypeID := "pkg/impl.go::MyType"
|
|
g.AddNode(&graph.Node{
|
|
ID: parentTypeID,
|
|
Kind: graph.KindType,
|
|
Name: "MyType",
|
|
FilePath: "pkg/impl.go",
|
|
Language: "go",
|
|
})
|
|
g.AddEdge(&graph.Edge{
|
|
From: parentTypeID,
|
|
To: interfaceID,
|
|
Kind: graph.EdgeImplements,
|
|
})
|
|
|
|
// Method on the primary type (this is the one being changed)
|
|
methodID := "pkg/impl.go::MyType.DoWork"
|
|
g.AddNode(&graph.Node{
|
|
ID: methodID,
|
|
Kind: graph.KindMethod,
|
|
Name: "DoWork",
|
|
FilePath: "pkg/impl.go",
|
|
StartLine: 10,
|
|
EndLine: 20,
|
|
Language: "go",
|
|
Meta: map[string]any{"signature": oldSig},
|
|
})
|
|
g.AddEdge(&graph.Edge{
|
|
From: methodID,
|
|
To: parentTypeID,
|
|
Kind: graph.EdgeMemberOf,
|
|
})
|
|
|
|
// Generate 1-5 additional implementor types
|
|
numImpls := rapid.IntRange(1, 5).Draw(t, "numImpls")
|
|
implTypeIDs := make([]string, numImpls)
|
|
implMethodIDs := make([]string, numImpls)
|
|
for i := range numImpls {
|
|
implTypeID := fmt.Sprintf("pkg/impl%d.go::ImplType%d", i, i)
|
|
implTypeIDs[i] = implTypeID
|
|
g.AddNode(&graph.Node{
|
|
ID: implTypeID,
|
|
Kind: graph.KindType,
|
|
Name: fmt.Sprintf("ImplType%d", i),
|
|
FilePath: fmt.Sprintf("pkg/impl%d.go", i),
|
|
Language: "go",
|
|
})
|
|
g.AddEdge(&graph.Edge{
|
|
From: implTypeID,
|
|
To: interfaceID,
|
|
Kind: graph.EdgeImplements,
|
|
})
|
|
|
|
// Method on this implementor with the OLD signature
|
|
implMethodID := fmt.Sprintf("pkg/impl%d.go::ImplType%d.DoWork", i, i)
|
|
implMethodIDs[i] = implMethodID
|
|
g.AddNode(&graph.Node{
|
|
ID: implMethodID,
|
|
Kind: graph.KindMethod,
|
|
Name: "DoWork",
|
|
FilePath: fmt.Sprintf("pkg/impl%d.go", i),
|
|
StartLine: 5,
|
|
EndLine: 15,
|
|
Language: "go",
|
|
Meta: map[string]any{"signature": oldSig},
|
|
})
|
|
g.AddEdge(&graph.Edge{
|
|
From: implMethodID,
|
|
To: implTypeID,
|
|
Kind: graph.EdgeMemberOf,
|
|
})
|
|
}
|
|
|
|
engine := query.NewEngine(g)
|
|
return interfaceGraphResult{
|
|
Graph: g,
|
|
Engine: engine,
|
|
MethodID: methodID,
|
|
InterfaceID: interfaceID,
|
|
ParentTypeID: parentTypeID,
|
|
OldSig: oldSig,
|
|
OldParams: oldParams,
|
|
ImplTypeIDs: implTypeIDs,
|
|
ImplMethodIDs: implMethodIDs,
|
|
}
|
|
})
|
|
}
|
|
|
|
// --- Property Tests ---
|
|
|
|
// TestPropertyContractViolation_CallerParamCountChange verifies that when the
|
|
// parameter count changes, every caller is reported as a violation.
|
|
func TestPropertyContractViolation_CallerParamCountChange(t *testing.T) {
|
|
rapid.Check(t, func(rt *rapid.T) {
|
|
cg := genCallerGraph().Draw(rt, "callerGraph")
|
|
|
|
// Generate a new signature with a DIFFERENT param count
|
|
newParamCount := rapid.IntRange(0, 6).Draw(rt, "newParamCount")
|
|
// Ensure it's different from old count
|
|
for newParamCount == len(cg.OldParams) {
|
|
newParamCount = rapid.IntRange(0, 6).Draw(rt, "newParamCountRetry")
|
|
}
|
|
newParams := make([]string, newParamCount)
|
|
for i := range newParamCount {
|
|
newParams[i] = genParamType().Draw(rt, fmt.Sprintf("newParam%d", i))
|
|
}
|
|
newSig := buildSignature(newParams)
|
|
|
|
changes := []SignatureChange{{
|
|
SymbolID: cg.TargetID,
|
|
NewSignature: newSig,
|
|
}}
|
|
|
|
result := VerifyChanges(cg.Graph, cg.Engine, changes)
|
|
|
|
// Every caller should be reported as a violation
|
|
violatedCallerIDs := make(map[string]bool)
|
|
for _, v := range result.Violations {
|
|
violatedCallerIDs[v.SymbolID] = true
|
|
}
|
|
|
|
for _, callerID := range cg.CallerIDs {
|
|
if !violatedCallerIDs[callerID] {
|
|
rt.Errorf("caller %s was not reported as a violation when param count changed from %d to %d",
|
|
callerID, len(cg.OldParams), newParamCount)
|
|
}
|
|
}
|
|
|
|
// Should not be clean
|
|
if result.Clean {
|
|
rt.Errorf("result should not be clean when param count changed")
|
|
}
|
|
|
|
// CheckedCallers should match actual caller count
|
|
if result.CheckedCallers != len(cg.CallerIDs) {
|
|
rt.Errorf("CheckedCallers = %d, want %d", result.CheckedCallers, len(cg.CallerIDs))
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestPropertyContractViolation_CallerTypeChange verifies that when a parameter
|
|
// type changes (but count stays the same), every caller is reported as a violation.
|
|
func TestPropertyContractViolation_CallerTypeChange(t *testing.T) {
|
|
rapid.Check(t, func(rt *rapid.T) {
|
|
cg := genCallerGraph().Draw(rt, "callerGraph")
|
|
|
|
// Build new params with same count but at least one different type
|
|
newParams := make([]string, len(cg.OldParams))
|
|
copy(newParams, cg.OldParams)
|
|
|
|
// Pick a random index to change
|
|
changeIdx := rapid.IntRange(0, len(cg.OldParams)-1).Draw(rt, "changeIdx")
|
|
newType := genParamType().Draw(rt, "newType")
|
|
// Ensure it's actually different
|
|
for newType == cg.OldParams[changeIdx] {
|
|
newType = genParamType().Draw(rt, "newTypeRetry")
|
|
}
|
|
newParams[changeIdx] = newType
|
|
newSig := buildSignature(newParams)
|
|
|
|
changes := []SignatureChange{{
|
|
SymbolID: cg.TargetID,
|
|
NewSignature: newSig,
|
|
}}
|
|
|
|
result := VerifyChanges(cg.Graph, cg.Engine, changes)
|
|
|
|
// Every caller should be reported as a violation
|
|
violatedCallerIDs := make(map[string]bool)
|
|
for _, v := range result.Violations {
|
|
violatedCallerIDs[v.SymbolID] = true
|
|
}
|
|
|
|
for _, callerID := range cg.CallerIDs {
|
|
if !violatedCallerIDs[callerID] {
|
|
rt.Errorf("caller %s was not reported as a violation when param type changed at index %d",
|
|
callerID, changeIdx)
|
|
}
|
|
}
|
|
|
|
if result.Clean {
|
|
rt.Errorf("result should not be clean when param type changed")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestPropertyContractViolation_NoChange verifies that when the signature
|
|
// doesn't change, Clean is true and CheckedCallers matches actual count.
|
|
func TestPropertyContractViolation_NoChange(t *testing.T) {
|
|
rapid.Check(t, func(rt *rapid.T) {
|
|
cg := genCallerGraph().Draw(rt, "callerGraph")
|
|
|
|
// Use the same signature — no change
|
|
changes := []SignatureChange{{
|
|
SymbolID: cg.TargetID,
|
|
NewSignature: cg.OldSig,
|
|
}}
|
|
|
|
result := VerifyChanges(cg.Graph, cg.Engine, changes)
|
|
|
|
if !result.Clean {
|
|
rt.Errorf("result should be clean when signature is unchanged, got %d violations", len(result.Violations))
|
|
for _, v := range result.Violations {
|
|
rt.Logf(" violation: %s %s: %s", v.SymbolID, v.Kind, v.Description)
|
|
}
|
|
}
|
|
|
|
if result.CheckedCallers != len(cg.CallerIDs) {
|
|
rt.Errorf("CheckedCallers = %d, want %d", result.CheckedCallers, len(cg.CallerIDs))
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestPropertyContractViolation_InterfaceParamCountChange verifies that when
|
|
// a method's param count changes, all other implementors are flagged.
|
|
func TestPropertyContractViolation_InterfaceParamCountChange(t *testing.T) {
|
|
rapid.Check(t, func(rt *rapid.T) {
|
|
ig := genInterfaceGraph().Draw(rt, "interfaceGraph")
|
|
|
|
// Generate a new signature with a DIFFERENT param count
|
|
newParamCount := rapid.IntRange(0, 5).Draw(rt, "newParamCount")
|
|
for newParamCount == len(ig.OldParams) {
|
|
newParamCount = rapid.IntRange(0, 5).Draw(rt, "newParamCountRetry")
|
|
}
|
|
newParams := make([]string, newParamCount)
|
|
for i := range newParamCount {
|
|
newParams[i] = genParamType().Draw(rt, fmt.Sprintf("newParam%d", i))
|
|
}
|
|
newSig := buildSignature(newParams)
|
|
|
|
changes := []SignatureChange{{
|
|
SymbolID: ig.MethodID,
|
|
NewSignature: newSig,
|
|
}}
|
|
|
|
result := VerifyChanges(ig.Graph, ig.Engine, changes)
|
|
|
|
// Every implementor's method should be reported as an interface_violation
|
|
violatedMethodIDs := make(map[string]bool)
|
|
for _, v := range result.Violations {
|
|
if v.Kind == "interface_violation" {
|
|
violatedMethodIDs[v.SymbolID] = true
|
|
}
|
|
}
|
|
|
|
for _, implMethodID := range ig.ImplMethodIDs {
|
|
if !violatedMethodIDs[implMethodID] {
|
|
rt.Errorf("implementor method %s was not reported as interface_violation when param count changed from %d to %d",
|
|
implMethodID, len(ig.OldParams), newParamCount)
|
|
}
|
|
}
|
|
|
|
// CheckedImpls should match the number of other implementors
|
|
if result.CheckedImpls != len(ig.ImplTypeIDs) {
|
|
rt.Errorf("CheckedImpls = %d, want %d", result.CheckedImpls, len(ig.ImplTypeIDs))
|
|
}
|
|
|
|
if result.Clean {
|
|
rt.Errorf("result should not be clean when interface method param count changed")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestPropertyContractViolation_InterfaceNoChange verifies that when a method's
|
|
// signature doesn't change, no interface violations are reported and CheckedImpls
|
|
// matches the actual implementor count.
|
|
func TestPropertyContractViolation_InterfaceNoChange(t *testing.T) {
|
|
rapid.Check(t, func(rt *rapid.T) {
|
|
ig := genInterfaceGraph().Draw(rt, "interfaceGraph")
|
|
|
|
// Use the same signature — no change
|
|
changes := []SignatureChange{{
|
|
SymbolID: ig.MethodID,
|
|
NewSignature: ig.OldSig,
|
|
}}
|
|
|
|
result := VerifyChanges(ig.Graph, ig.Engine, changes)
|
|
|
|
// No interface violations should be reported
|
|
for _, v := range result.Violations {
|
|
if v.Kind == "interface_violation" {
|
|
rt.Errorf("unexpected interface_violation for %s when signature unchanged: %s",
|
|
v.SymbolID, v.Description)
|
|
}
|
|
}
|
|
|
|
if result.CheckedImpls != len(ig.ImplTypeIDs) {
|
|
rt.Errorf("CheckedImpls = %d, want %d", result.CheckedImpls, len(ig.ImplTypeIDs))
|
|
}
|
|
|
|
if !result.Clean {
|
|
rt.Errorf("result should be clean when signature is unchanged, got %d violations", len(result.Violations))
|
|
}
|
|
})
|
|
}
|
|
|
|
// --- Unit Test for missing symbol ID ---
|
|
|
|
func TestVerifyChanges_MissingSymbolContinues(t *testing.T) {
|
|
g := graph.New()
|
|
|
|
// Add a real function with a caller
|
|
g.AddNode(&graph.Node{
|
|
ID: "pkg/real.go::RealFunc",
|
|
Kind: graph.KindFunction,
|
|
Name: "RealFunc",
|
|
FilePath: "pkg/real.go",
|
|
StartLine: 1,
|
|
EndLine: 10,
|
|
Language: "go",
|
|
Meta: map[string]any{"signature": "func(x int)"},
|
|
})
|
|
g.AddNode(&graph.Node{
|
|
ID: "pkg/caller.go::Caller",
|
|
Kind: graph.KindFunction,
|
|
Name: "Caller",
|
|
FilePath: "pkg/caller.go",
|
|
StartLine: 1,
|
|
EndLine: 10,
|
|
Language: "go",
|
|
})
|
|
g.AddEdge(&graph.Edge{
|
|
From: "pkg/caller.go::Caller",
|
|
To: "pkg/real.go::RealFunc",
|
|
Kind: graph.EdgeCalls,
|
|
})
|
|
|
|
engine := query.NewEngine(g)
|
|
|
|
changes := []SignatureChange{
|
|
{SymbolID: "nonexistent::Missing", NewSignature: "func(x int)"},
|
|
{SymbolID: "pkg/real.go::RealFunc", NewSignature: "func(x int, y string)"},
|
|
}
|
|
|
|
result := VerifyChanges(g, engine, changes)
|
|
|
|
// Should have an error for the missing symbol
|
|
assert.Len(t, result.Errors, 1)
|
|
assert.Contains(t, result.Errors[0], "nonexistent::Missing")
|
|
|
|
// Should still detect violations for the real function
|
|
assert.NotEmpty(t, result.Violations)
|
|
assert.False(t, result.Clean)
|
|
assert.Equal(t, 1, result.CheckedCallers)
|
|
}
|