From 93bd7fb2bf8d7641219070d13f0e85b9d3a7d48b Mon Sep 17 00:00:00 2001 From: Graham Goh Date: Mon, 9 Feb 2026 16:33:22 +1100 Subject: [PATCH 1/8] formatter impl --- .../proposalanalysis/formatter/formatter.go | 70 ++++++++++++ .../formatter/formatter_test.go | 100 ++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 engine/cld/mcms/proposalanalysis/formatter/formatter.go create mode 100644 engine/cld/mcms/proposalanalysis/formatter/formatter_test.go diff --git a/engine/cld/mcms/proposalanalysis/formatter/formatter.go b/engine/cld/mcms/proposalanalysis/formatter/formatter.go new file mode 100644 index 00000000..126423ff --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/formatter/formatter.go @@ -0,0 +1,70 @@ +package formatter + +import ( + "context" + "fmt" + + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/types" +) + +// FormatterRequest encapsulates the context passed to formatter methods. +type FormatterRequest struct { + Domain string + EnvironmentName string +} + +// Formatter transforms an AnalyzedProposal into a specific output format +type Formatter interface { + ID() string + Format(ctx context.Context, req FormatterRequest, proposal types.AnalyzedProposal) ([]byte, error) +} + +// FormatterRegistry manages formatter registration and lookup +type FormatterRegistry struct { + formatters map[string]Formatter +} + +// NewFormatterRegistry creates a new formatter registry +func NewFormatterRegistry() *FormatterRegistry { + return &FormatterRegistry{ + formatters: make(map[string]Formatter), + } +} + +// Register adds a formatter to the registry. +// Returns an error if: +// - formatter is nil +// - formatter ID is empty +// - a formatter with the same ID is already registered +func (r *FormatterRegistry) Register(formatter Formatter) error { + if formatter == nil { + return fmt.Errorf("formatter cannot be nil") + } + + id := formatter.ID() + if id == "" { + return fmt.Errorf("formatter ID cannot be empty") + } + + if _, exists := r.formatters[id]; exists { + return fmt.Errorf("formatter with ID %q is already registered", id) + } + + r.formatters[id] = formatter + return nil +} + +// Get retrieves a formatter by ID +func (r *FormatterRegistry) Get(id string) (Formatter, bool) { + f, ok := r.formatters[id] + return f, ok +} + +// List returns all registered formatter IDs +func (r *FormatterRegistry) List() []string { + ids := make([]string, 0, len(r.formatters)) + for id := range r.formatters { + ids = append(ids, id) + } + return ids +} diff --git a/engine/cld/mcms/proposalanalysis/formatter/formatter_test.go b/engine/cld/mcms/proposalanalysis/formatter/formatter_test.go new file mode 100644 index 00000000..8e3f234c --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/formatter/formatter_test.go @@ -0,0 +1,100 @@ +package formatter + +import ( + "context" + "testing" + + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Mock formatter for testing +type mockFormatter struct { + id string +} + +func (m *mockFormatter) ID() string { + return m.id +} + +func (m *mockFormatter) Format(ctx context.Context, req FormatterRequest, proposal types.AnalyzedProposal) ([]bytes, error) { + return []byte("mock output"), nil +} + +func TestFormatterRegistry(t *testing.T) { + t.Run("Register and Get formatter", func(t *testing.T) { + registry := NewFormatterRegistry() + formatter := &mockFormatter{id: "test-formatter"} + + err := registry.Register(formatter) + require.NoError(t, err) + + retrieved, ok := registry.Get("test-formatter") + assert.True(t, ok) + assert.Equal(t, formatter, retrieved) + }) + + t.Run("Register nil formatter returns error", func(t *testing.T) { + registry := NewFormatterRegistry() + + err := registry.Register(nil) + require.ErrorContains(t, err, "cannot be nil") + }) + + t.Run("Register formatter with empty ID returns error", func(t *testing.T) { + registry := NewFormatterRegistry() + formatter := &mockFormatter{id: ""} + + err := registry.Register(formatter) + require.ErrorContains(t, err, "cannot be empty") + }) + + t.Run("Register duplicate ID returns error", func(t *testing.T) { + registry := NewFormatterRegistry() + formatter1 := &mockFormatter{id: "duplicate"} + formatter2 := &mockFormatter{id: "duplicate"} + + err := registry.Register(formatter1) + require.NoError(t, err) + + err = registry.Register(formatter2) + require.EqualError(t, err, `formatter with ID "duplicate" is already registered`) + + // Verify first formatter is still registered + retrieved, ok := registry.Get("duplicate") + assert.True(t, ok) + assert.Equal(t, formatter1, retrieved) + }) + + t.Run("Get non-existent formatter", func(t *testing.T) { + registry := NewFormatterRegistry() + + retrieved, ok := registry.Get("non-existent") + assert.False(t, ok) + assert.Nil(t, retrieved) + }) + + t.Run("List formatters", func(t *testing.T) { + registry := NewFormatterRegistry() + + formatter1 := &mockFormatter{id: "formatter-1"} + formatter2 := &mockFormatter{id: "formatter-2"} + formatter3 := &mockFormatter{id: "formatter-3"} + + registry.Register(formatter1) + registry.Register(formatter2) + registry.Register(formatter3) + + ids := registry.List() + assert.Len(t, ids, 3) + assert.ElementsMatch(t, []string{"formatter-1", "formatter-2", "formatter-3"}, ids) + }) + + t.Run("List empty registry", func(t *testing.T) { + registry := NewFormatterRegistry() + + ids := registry.List() + assert.Empty(t, ids) + }) +} From 5a9496c80f437496cac73c1ed66195b76af43895 Mon Sep 17 00:00:00 2001 From: Graham Goh Date: Mon, 9 Feb 2026 16:57:12 +1100 Subject: [PATCH 2/8] analyzer --- .../proposalanalysis/analyzer/annotated.go | 117 +++++++++ .../analyzer/annotations_test.go | 132 ++++++++++ .../internal/dependency_graph.go | 189 ++++++++++++++ .../internal/dependency_graph_test.go | 238 ++++++++++++++++++ 4 files changed, 676 insertions(+) create mode 100644 engine/cld/mcms/proposalanalysis/analyzer/annotated.go create mode 100644 engine/cld/mcms/proposalanalysis/analyzer/annotations_test.go create mode 100644 engine/cld/mcms/proposalanalysis/internal/dependency_graph.go create mode 100644 engine/cld/mcms/proposalanalysis/internal/dependency_graph_test.go diff --git a/engine/cld/mcms/proposalanalysis/analyzer/annotated.go b/engine/cld/mcms/proposalanalysis/analyzer/annotated.go new file mode 100644 index 00000000..e51c7156 --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/analyzer/annotated.go @@ -0,0 +1,117 @@ +package analyzer + +import "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/types" + +var _ types.Annotation = &annotation{} + +type annotation struct { + name string + atype string + value any + analyzerID string +} + +func (a annotation) Name() string { + return a.name +} + +func (a annotation) Type() string { + return a.atype +} + +func (a annotation) Value() any { + return a.value +} + +// NewAnnotation creates a new annotation with the given name, type, and value +func NewAnnotation(name, atype string, value any) types.Annotation { + return &annotation{ + name: name, + atype: atype, + value: value, + } +} + +// NewAnnotationWithAnalyzer creates a new annotation with analyzer ID tracking +func NewAnnotationWithAnalyzer(name, atype string, value any, analyzerID string) types.Annotation { + return &annotation{ + name: name, + atype: atype, + value: value, + analyzerID: analyzerID, + } +} + +// --------------------------------------------------------------------- + +var _ types.Annotated = &annotated{} + +type annotated struct { + annotations types.Annotations +} + +func (a *annotated) AddAnnotations(annotations ...types.Annotation) { + a.annotations = append(a.annotations, annotations...) +} + +func (a annotated) Annotations() types.Annotations { + return a.annotations +} + +// GetAnnotationsByName returns all annotations with the given name +func (a annotated) GetAnnotationsByName(name string) types.Annotations { + var result types.Annotations + for _, ann := range a.annotations { + if ann.Name() == name { + result = append(result, ann) + } + } + return result +} + +// GetAnnotationsByType returns all annotations with the given type +func (a annotated) GetAnnotationsByType(atype string) types.Annotations { + var result types.Annotations + for _, ann := range a.annotations { + if ann.Type() == atype { + result = append(result, ann) + } + } + return result +} + +// GetAnnotationsByAnalyzer returns all annotations created by the given analyzer ID +func (a annotated) GetAnnotationsByAnalyzer(analyzerID string) types.Annotations { + var result types.Annotations + for _, ann := range a.annotations { + // Try to cast to our internal annotation type to access analyzerID + if internalAnn, ok := ann.(*annotation); ok { + if internalAnn.analyzerID == analyzerID { + result = append(result, ann) + } + } + } + return result +} + +// helpers + +// NewInfoAnnotation creates an INFO annotation +func NewInfoAnnotation(name string, value any) types.Annotation { + return NewAnnotation(name, "INFO", value) +} + +// NewWarnAnnotation creates a WARN annotation +func NewWarnAnnotation(name string, value any) types.Annotation { + return NewAnnotation(name, "WARN", value) +} + +// NewErrorAnnotation creates an ERROR annotation +func NewErrorAnnotation(name string, value any) types.Annotation { + return NewAnnotation(name, "ERROR", value) +} + +// NewDiffAnnotation creates a DIFF annotation +func NewDiffAnnotation(name string, value any) types.Annotation { + return NewAnnotation(name, "DIFF", value) +} diff --git a/engine/cld/mcms/proposalanalysis/analyzer/annotations_test.go b/engine/cld/mcms/proposalanalysis/analyzer/annotations_test.go new file mode 100644 index 00000000..4e5bfae9 --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/analyzer/annotations_test.go @@ -0,0 +1,132 @@ +package analyzer + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAnnotations(t *testing.T) { + ctx := context.Background() + _ = ctx + + t.Run("NewAnnotation", func(t *testing.T) { + ann := NewAnnotation("test", "INFO", "value") + assert.Equal(t, "test", ann.Name()) + assert.Equal(t, "INFO", ann.Type()) + assert.Equal(t, "value", ann.Value()) + }) + + t.Run("NewAnnotationWithAnalyzer", func(t *testing.T) { + ann := NewAnnotationWithAnalyzer("test", "WARN", "warning", "analyzer-1") + assert.Equal(t, "test", ann.Name()) + assert.Equal(t, "WARN", ann.Type()) + assert.Equal(t, "warning", ann.Value()) + }) + + t.Run("AddAnnotations", func(t *testing.T) { + a := &annotated{} + ann1 := NewAnnotation("ann1", "INFO", "v1") + ann2 := NewAnnotation("ann2", "WARN", "v2") + + a.AddAnnotations(ann1) + assert.Len(t, a.Annotations(), 1) + + a.AddAnnotations(ann2) + assert.Len(t, a.Annotations(), 2) + }) + + t.Run("GetAnnotationsByName", func(t *testing.T) { + a := &annotated{} + ann1 := NewAnnotation("gas-estimate", "INFO", 100) + ann2 := NewAnnotation("security-check", "WARN", "vulnerable") + ann3 := NewAnnotation("gas-estimate", "INFO", 200) + + a.AddAnnotations(ann1, ann2, ann3) + + results := a.GetAnnotationsByName("gas-estimate") + assert.Len(t, results, 2) + assert.Equal(t, "gas-estimate", results[0].Name()) + assert.Equal(t, "gas-estimate", results[1].Name()) + + results = a.GetAnnotationsByName("security-check") + assert.Len(t, results, 1) + assert.Equal(t, "security-check", results[0].Name()) + + results = a.GetAnnotationsByName("nonexistent") + assert.Len(t, results, 0) + }) + + t.Run("GetAnnotationsByType", func(t *testing.T) { + a := &annotated{} + ann1 := NewAnnotation("ann1", "INFO", "v1") + ann2 := NewAnnotation("ann2", "WARN", "v2") + ann3 := NewAnnotation("ann3", "INFO", "v3") + ann4 := NewAnnotation("ann4", "ERROR", "v4") + + a.AddAnnotations(ann1, ann2, ann3, ann4) + + results := a.GetAnnotationsByType("INFO") + assert.Len(t, results, 2) + + results = a.GetAnnotationsByType("WARN") + assert.Len(t, results, 1) + + results = a.GetAnnotationsByType("ERROR") + assert.Len(t, results, 1) + + results = a.GetAnnotationsByType("DIFF") + assert.Len(t, results, 0) + }) + + t.Run("GetAnnotationsByAnalyzer", func(t *testing.T) { + a := &annotated{} + ann1 := NewAnnotationWithAnalyzer("ann1", "INFO", "v1", "analyzer-1") + ann2 := NewAnnotationWithAnalyzer("ann2", "WARN", "v2", "analyzer-2") + ann3 := NewAnnotationWithAnalyzer("ann3", "INFO", "v3", "analyzer-1") + ann4 := NewAnnotation("ann4", "ERROR", "v4") // No analyzer ID + + a.AddAnnotations(ann1, ann2, ann3, ann4) + + results := a.GetAnnotationsByAnalyzer("analyzer-1") + assert.Len(t, results, 2) + + results = a.GetAnnotationsByAnalyzer("analyzer-2") + assert.Len(t, results, 1) + + results = a.GetAnnotationsByAnalyzer("analyzer-3") + assert.Len(t, results, 0) + }) + + t.Run("Combined queries", func(t *testing.T) { + a := &annotated{} + ann1 := NewAnnotationWithAnalyzer("gas-estimate", "INFO", 100, "gas-analyzer") + ann2 := NewAnnotationWithAnalyzer("gas-estimate", "WARN", 500, "gas-analyzer") + ann3 := NewAnnotationWithAnalyzer("security", "WARN", "issue", "security-analyzer") + + a.AddAnnotations(ann1, ann2, ann3) + + // Get all gas-estimate annotations + gasAnnotations := a.GetAnnotationsByName("gas-estimate") + assert.Len(t, gasAnnotations, 2) + + // Get all WARN annotations + warnings := a.GetAnnotationsByType("WARN") + assert.Len(t, warnings, 2) + + // Get all annotations from gas-analyzer + gasAnalyzerAnnotations := a.GetAnnotationsByAnalyzer("gas-analyzer") + assert.Len(t, gasAnalyzerAnnotations, 2) + }) +} + +func TestAnnotationsImplementInterfaces(t *testing.T) { + t.Run("annotation implements Annotation", func(t *testing.T) { + var _ analyzer.Annotation = &annotation{} + }) + + t.Run("annotated implements Annotated", func(t *testing.T) { + var _ analyzer.Annotated = &annotated{} + }) +} diff --git a/engine/cld/mcms/proposalanalysis/internal/dependency_graph.go b/engine/cld/mcms/proposalanalysis/internal/dependency_graph.go new file mode 100644 index 00000000..151ced29 --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/internal/dependency_graph.go @@ -0,0 +1,189 @@ +package internal + +import ( + "fmt" + + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/types" +) + +// dependencyGraph represents a directed acyclic graph of analyzer dependencies +type dependencyGraph struct { + nodes map[string]*graphNode +} + +type graphNode struct { + analyzer types.BaseAnalyzer + dependencies []*graphNode + dependents []*graphNode +} + +// NewDependencyGraph creates a new dependency graph from a list of analyzers +func NewDependencyGraph(analyzers []types.BaseAnalyzer) (*dependencyGraph, error) { + graph := &dependencyGraph{ + nodes: make(map[string]*graphNode), + } + + // First pass: create nodes for all analyzers + for _, a := range analyzers { + if a == nil { + continue + } + id := a.ID() + if id == "" { + return nil, fmt.Errorf("analyzer must have a non-empty ID") + } + if _, exists := graph.nodes[id]; exists { + return nil, fmt.Errorf("duplicate analyzer ID: %s", id) + } + graph.nodes[id] = &graphNode{ + analyzer: a, + dependencies: []*graphNode{}, + dependents: []*graphNode{}, + } + } + + // Second pass: build dependency edges + for _, node := range graph.nodes { + depIDs := node.analyzer.Dependencies() + for _, depID := range depIDs { + if depID == "" { + continue + } + depNode, exists := graph.nodes[depID] + if !exists { + return nil, fmt.Errorf("analyzer %s depends on unknown analyzer %s", node.analyzer.ID(), depID) + } + node.dependencies = append(node.dependencies, depNode) + depNode.dependents = append(depNode.dependents, node) + } + } + + // Detect cycles + if err := graph.detectCycles(); err != nil { + return nil, err + } + + return graph, nil +} + +// detectCycles checks for circular dependencies using DFS +func (g *dependencyGraph) detectCycles() error { + visited := make(map[string]bool) + recStack := make(map[string]bool) + + for id, node := range g.nodes { + if !visited[id] { + if err := g.detectCyclesDFS(node, visited, recStack, []string{}); err != nil { + return err + } + } + } + + return nil +} + +func (g *dependencyGraph) detectCyclesDFS(node *graphNode, visited, recStack map[string]bool, path []string) error { + id := node.analyzer.ID() + visited[id] = true + recStack[id] = true + path = append(path, id) + + for _, dep := range node.dependencies { + depID := dep.analyzer.ID() + if !visited[depID] { + if err := g.detectCyclesDFS(dep, visited, recStack, path); err != nil { + return err + } + } else if recStack[depID] { + // Found a cycle + cyclePath := append(path, depID) + return fmt.Errorf("circular dependency detected: %v", cyclePath) + } + } + + recStack[id] = false + return nil +} + +// TopologicalSort returns analyzers in execution order (dependencies first) +func (g *dependencyGraph) TopologicalSort() ([]types.BaseAnalyzer, error) { + result := []types.BaseAnalyzer{} + visited := make(map[string]bool) + temp := make(map[string]bool) + + var visit func(*graphNode) error + visit = func(node *graphNode) error { + id := node.analyzer.ID() + if temp[id] { + return fmt.Errorf("cycle detected at %s", id) + } + if visited[id] { + return nil + } + + temp[id] = true + for _, dep := range node.dependencies { + if err := visit(dep); err != nil { + return err + } + } + temp[id] = false + visited[id] = true + result = append(result, node.analyzer) + return nil + } + + for _, node := range g.nodes { + if !visited[node.analyzer.ID()] { + if err := visit(node); err != nil { + return nil, err + } + } + } + + return result, nil +} + +// getLevels returns analyzers grouped by execution level (for parallel execution) +func (g *dependencyGraph) getLevels() [][]types.BaseAnalyzer { + inDegree := make(map[string]int) + for id, node := range g.nodes { + inDegree[id] = len(node.dependencies) + } + + var levels [][]types.BaseAnalyzer + remaining := len(g.nodes) + + for remaining > 0 { + var currentLevel []types.BaseAnalyzer + for id, node := range g.nodes { + if inDegree[id] == 0 { + currentLevel = append(currentLevel, node.analyzer) + } + } + + if len(currentLevel) == 0 { + // Should not happen if cycle detection worked + break + } + + levels = append(levels, currentLevel) + + // Remove nodes in current level and update in-degrees + for _, a := range currentLevel { + id := a.ID() + inDegree[id] = -1 // Mark as processed + remaining-- + + node := g.nodes[id] + for _, dependent := range node.dependents { + depID := dependent.analyzer.ID() + if inDegree[depID] > 0 { + inDegree[depID]-- + } + } + } + } + + return levels +} diff --git a/engine/cld/mcms/proposalanalysis/internal/dependency_graph_test.go b/engine/cld/mcms/proposalanalysis/internal/dependency_graph_test.go new file mode 100644 index 00000000..1c259e59 --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/internal/dependency_graph_test.go @@ -0,0 +1,238 @@ +package internal + +import ( + "context" + "testing" + + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Mock analyzer for testing +type mockAnalyzer struct { + id string + dependencies []string +} + +func (m *mockAnalyzer) ID() string { + return m.id +} + +func (m *mockAnalyzer) Dependencies() []string { + return m.dependencies +} + +func TestNewDependencyGraph(t *testing.T) { + ctx := context.Background() + _ = ctx + + t.Run("empty graph", func(t *testing.T) { + graph, err := NewDependencyGraph([]types.BaseAnalyzer{}) + require.NoError(t, err) + assert.NotNil(t, graph) + assert.Empty(t, graph.nodes) + }) + + t.Run("single analyzer", func(t *testing.T) { + a1 := &mockAnalyzer{id: "a1"} + graph, err := NewDependencyGraph([]types.BaseAnalyzer{a1}) + require.NoError(t, err) + assert.Len(t, graph.nodes, 1) + assert.Contains(t, graph.nodes, "a1") + }) + + t.Run("duplicate ID error", func(t *testing.T) { + a1 := &mockAnalyzer{id: "a1"} + a2 := &mockAnalyzer{id: "a1"} + _, err := NewDependencyGraph([]types.BaseAnalyzer{a1, a2}) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate analyzer ID") + }) + + t.Run("empty ID error", func(t *testing.T) { + a1 := &mockAnalyzer{id: ""} + _, err := NewDependencyGraph([]types.BaseAnalyzer{a1}) + require.Error(t, err) + assert.Contains(t, err.Error(), "non-empty ID") + }) + + t.Run("unknown dependency error", func(t *testing.T) { + a1 := &mockAnalyzer{id: "a1", dependencies: []string{"unknown"}} + _, err := NewDependencyGraph([]types.BaseAnalyzer{a1}) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown analyzer") + }) +} + +func TestTopologicalSort(t *testing.T) { + ctx := context.Background() + _ = ctx + + t.Run("linear dependency chain", func(t *testing.T) { + // a1 -> a2 -> a3 + a1 := &mockAnalyzer{id: "a1"} + a2 := &mockAnalyzer{id: "a2", dependencies: []string{"a1"}} + a3 := &mockAnalyzer{id: "a3", dependencies: []string{"a2"}} + + graph, err := NewDependencyGraph([]types.BaseAnalyzer{a3, a1, a2}) + require.NoError(t, err) + + sorted, err := graph.TopologicalSort() + require.NoError(t, err) + require.Len(t, sorted, 3) + + // a1 should come before a2, a2 before a3 + ids := make([]string, len(sorted)) + for i, a := range sorted { + ids[i] = a.ID() + } + assert.Equal(t, []string{"a1", "a2", "a3"}, ids) + }) + + t.Run("diamond dependency", func(t *testing.T) { + // a1 + // / \ + // a2 a3 + // \ / + // a4 + a1 := &mockAnalyzer{id: "a1"} + a2 := &mockAnalyzer{id: "a2", dependencies: []string{"a1"}} + a3 := &mockAnalyzer{id: "a3", dependencies: []string{"a1"}} + a4 := &mockAnalyzer{id: "a4", dependencies: []string{"a2", "a3"}} + + graph, err := NewDependencyGraph([]types.BaseAnalyzer{a4, a2, a3, a1}) + require.NoError(t, err) + + sorted, err := graph.TopologicalSort() + require.NoError(t, err) + require.Len(t, sorted, 4) + + // Build position map + pos := make(map[string]int) + for i, a := range sorted { + pos[a.ID()] = i + } + + // Assert ordering constraints + assert.Less(t, pos["a1"], pos["a2"]) + assert.Less(t, pos["a1"], pos["a3"]) + assert.Less(t, pos["a2"], pos["a4"]) + assert.Less(t, pos["a3"], pos["a4"]) + }) + + t.Run("independent analyzers", func(t *testing.T) { + a1 := &mockAnalyzer{id: "a1"} + a2 := &mockAnalyzer{id: "a2"} + a3 := &mockAnalyzer{id: "a3"} + + graph, err := NewDependencyGraph([]types.BaseAnalyzer{a1, a2, a3}) + require.NoError(t, err) + + sorted, err := graph.TopologicalSort() + require.NoError(t, err) + assert.Len(t, sorted, 3) + }) +} + +func TestDetectCycles(t *testing.T) { + ctx := context.Background() + _ = ctx + + t.Run("simple cycle", func(t *testing.T) { + // a1 -> a2 -> a1 (cycle) + a1 := &mockAnalyzer{id: "a1", dependencies: []string{"a2"}} + a2 := &mockAnalyzer{id: "a2", dependencies: []string{"a1"}} + + _, err := NewDependencyGraph([]types.BaseAnalyzer{a1, a2}) + require.Error(t, err) + assert.Contains(t, err.Error(), "circular dependency") + }) + + t.Run("self dependency", func(t *testing.T) { + a1 := &mockAnalyzer{id: "a1", dependencies: []string{"a1"}} + + _, err := NewDependencyGraph([]types.BaseAnalyzer{a1}) + require.Error(t, err) + assert.Contains(t, err.Error(), "circular dependency") + }) + + t.Run("complex cycle", func(t *testing.T) { + // a1 -> a2 -> a3 -> a1 (cycle) + a1 := &mockAnalyzer{id: "a1", dependencies: []string{"a3"}} + a2 := &mockAnalyzer{id: "a2", dependencies: []string{"a1"}} + a3 := &mockAnalyzer{id: "a3", dependencies: []string{"a2"}} + + _, err := NewDependencyGraph([]types.BaseAnalyzer{a1, a2, a3}) + require.Error(t, err) + assert.Contains(t, err.Error(), "circular dependency") + }) +} + +func TestGetLevels(t *testing.T) { + ctx := context.Background() + _ = ctx + + t.Run("linear chain has sequential levels", func(t *testing.T) { + // a1 -> a2 -> a3 + a1 := &mockAnalyzer{id: "a1"} + a2 := &mockAnalyzer{id: "a2", dependencies: []string{"a1"}} + a3 := &mockAnalyzer{id: "a3", dependencies: []string{"a2"}} + + graph, err := NewDependencyGraph([]types.BaseAnalyzer{a1, a2, a3}) + require.NoError(t, err) + + levels := graph.getLevels() + require.Len(t, levels, 3) + assert.Len(t, levels[0], 1) + assert.Equal(t, "a1", levels[0][0].ID()) + assert.Len(t, levels[1], 1) + assert.Equal(t, "a2", levels[1][0].ID()) + assert.Len(t, levels[2], 1) + assert.Equal(t, "a3", levels[2][0].ID()) + }) + + t.Run("diamond allows parallel execution", func(t *testing.T) { + // a1 + // / \ + // a2 a3 + // \ / + // a4 + a1 := &mockAnalyzer{id: "a1"} + a2 := &mockAnalyzer{id: "a2", dependencies: []string{"a1"}} + a3 := &mockAnalyzer{id: "a3", dependencies: []string{"a1"}} + a4 := &mockAnalyzer{id: "a4", dependencies: []string{"a2", "a3"}} + + graph, err := NewDependencyGraph([]types.BaseAnalyzer{a1, a2, a3, a4}) + require.NoError(t, err) + + levels := graph.getLevels() + require.Len(t, levels, 3) + + // Level 0: a1 + assert.Len(t, levels[0], 1) + assert.Equal(t, "a1", levels[0][0].ID()) + + // Level 1: a2 and a3 (can run in parallel) + assert.Len(t, levels[1], 2) + ids := []string{levels[1][0].ID(), levels[1][1].ID()} + assert.ElementsMatch(t, []string{"a2", "a3"}, ids) + + // Level 2: a4 + assert.Len(t, levels[2], 1) + assert.Equal(t, "a4", levels[2][0].ID()) + }) + + t.Run("independent analyzers in same level", func(t *testing.T) { + a1 := &mockAnalyzer{id: "a1"} + a2 := &mockAnalyzer{id: "a2"} + a3 := &mockAnalyzer{id: "a3"} + + graph, err := NewDependencyGraph([]types.BaseAnalyzer{a1, a2, a3}) + require.NoError(t, err) + + levels := graph.getLevels() + require.Len(t, levels, 1) + assert.Len(t, levels[0], 3) + }) +} From 31dee2065c72b2c5284df682ec3a7a80128b2254 Mon Sep 17 00:00:00 2001 From: Graham Goh Date: Mon, 9 Feb 2026 17:04:28 +1100 Subject: [PATCH 3/8] decoder --- .../mcms/proposalanalysis/decoder/decoder.go | 77 +++++++++++++ .../proposalanalysis/decoder/decoder_test.go | 49 ++++++++ .../decoder/legacy_adapter.go | 108 ++++++++++++++++++ 3 files changed, 234 insertions(+) create mode 100644 engine/cld/mcms/proposalanalysis/decoder/decoder.go create mode 100644 engine/cld/mcms/proposalanalysis/decoder/decoder_test.go create mode 100644 engine/cld/mcms/proposalanalysis/decoder/legacy_adapter.go diff --git a/engine/cld/mcms/proposalanalysis/decoder/decoder.go b/engine/cld/mcms/proposalanalysis/decoder/decoder.go new file mode 100644 index 00000000..efc3afe8 --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/decoder/decoder.go @@ -0,0 +1,77 @@ +package decoder + +import ( + "context" + "fmt" + + "github.com/smartcontractkit/mcms" + + "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/types" + experimentalanalyzer "github.com/smartcontractkit/chainlink-deployments-framework/experimental/analyzer" +) + +// ProposalDecoder decodes MCMS proposals into structured DecodedTimelockProposal +type ProposalDecoder interface { + Decode(ctx context.Context, env deployment.Environment, proposal *mcms.TimelockProposal) (types.DecodedTimelockProposal, error) +} + +// legacyDecoder adapts the legacy experimental/analyzer package to the new decoder interface +type legacyDecoder struct { + proposalContext experimentalanalyzer.ProposalContext +} + +// NewLegacyDecoder creates a decoder that wraps legacy experimental/analyzer decoding logic. +// Use functional options to configure: +// - WithProposalContext: provide a custom ProposalContext (otherwise default is created) +func NewLegacyDecoder(opts ...DecoderOption) ProposalDecoder { + decoder := &legacyDecoder{} + + for _, opt := range opts { + opt(decoder) + } + + return decoder +} + +// DecoderOption is a functional option for configuring the decoder +type DecoderOption func(*legacyDecoder) + +// WithProposalContext injects a custom ProposalContext for decoding. +// If not provided, a default context will be created during decoding. +func WithProposalContext(ctx experimentalanalyzer.ProposalContext) DecoderOption { + return func(d *legacyDecoder) { + d.proposalContext = ctx + } +} + +func (d *legacyDecoder) Decode( + ctx context.Context, + env deployment.Environment, + proposal *mcms.TimelockProposal, +) (types.DecodedTimelockProposal, error) { + // Create proposal context for legacy experimental analyzer + // Use the provided context if available, otherwise create a default one + var proposalCtx experimentalanalyzer.ProposalContext + + if d.proposalContext != nil { + proposalCtx = d.proposalContext + } else { + var err error + proposalCtx, err = experimentalanalyzer.NewDefaultProposalContext(env) + if err != nil { + return nil, fmt.Errorf("failed to create proposal context: %w", err) + } + } + + // Build the report using legacy experimental analyzer + report, err := experimentalanalyzer.BuildTimelockReport(ctx, proposalCtx, env, proposal) + if err != nil { + return nil, fmt.Errorf("failed to build timelock report: %w", err) + } + + // Convert to our DecodedTimelockProposal interface + return &decodedTimelockProposal{ + report: report, + }, nil +} diff --git a/engine/cld/mcms/proposalanalysis/decoder/decoder_test.go b/engine/cld/mcms/proposalanalysis/decoder/decoder_test.go new file mode 100644 index 00000000..b013fed3 --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/decoder/decoder_test.go @@ -0,0 +1,49 @@ +package decoder_test + +import ( + "testing" + + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/decoder" + experimentalanalyzer "github.com/smartcontractkit/chainlink-deployments-framework/experimental/analyzer" + "github.com/stretchr/testify/require" +) + +// TestDecoderOptions verifies that decoder options work correctly +func TestDecoderOptions(t *testing.T) { + t.Run("can create decoder with no options", func(t *testing.T) { + d := decoder.NewLegacyDecoder() + require.NotNil(t, d) + }) + + t.Run("can inject custom proposal context", func(t *testing.T) { + customContext := &mockProposalContext{} + + d := decoder.NewLegacyDecoder( + decoder.WithProposalContext(customContext), + ) + require.NotNil(t, d) + }) +} + +// mockProposalContext is a minimal mock for testing +type mockProposalContext struct{} + +func (m *mockProposalContext) GetEVMRegistry() experimentalanalyzer.EVMABIRegistry { + return nil +} + +func (m *mockProposalContext) GetSolanaDecoderRegistry() experimentalanalyzer.SolanaDecoderRegistry { + return nil +} + +func (m *mockProposalContext) FieldsContext(chainSelector uint64) *experimentalanalyzer.FieldContext { + return nil +} + +func (m *mockProposalContext) GetRenderer() experimentalanalyzer.Renderer { + return nil +} + +func (m *mockProposalContext) SetRenderer(renderer experimentalanalyzer.Renderer) { + // no-op +} diff --git a/engine/cld/mcms/proposalanalysis/decoder/legacy_adapter.go b/engine/cld/mcms/proposalanalysis/decoder/legacy_adapter.go new file mode 100644 index 00000000..63bf1ed6 --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/decoder/legacy_adapter.go @@ -0,0 +1,108 @@ +package decoder + +import ( + "encoding/json" + + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/types" + experimentalanalyzer "github.com/smartcontractkit/chainlink-deployments-framework/experimental/analyzer" +) + +// decodedTimelockProposal adapts legacy experimental/analyzer report to our interface +type decodedTimelockProposal struct { + report *experimentalanalyzer.ProposalReport +} + +func (d *decodedTimelockProposal) BatchOperations() types.DecodedBatchOperations { + batches := make(types.DecodedBatchOperations, len(d.report.Batches)) + for i, batch := range d.report.Batches { + batches[i] = &decodedBatchOperation{ + batch: batch, + } + } + return batches +} + +// decodedBatchOperation adapts legacy experimental batch report +type decodedBatchOperation struct { + batch experimentalanalyzer.BatchReport +} + +func (d *decodedBatchOperation) ChainSelector() uint64 { + return d.batch.ChainSelector +} + +func (d *decodedBatchOperation) Calls() types.DecodedCalls { + // Flatten all calls from all operations in the batch + var allCalls types.DecodedCalls + for _, op := range d.batch.Operations { + for _, call := range op.Calls { + allCalls = append(allCalls, &decodedCall{call: call}) + } + } + return allCalls +} + +// decodedCall adapts legacy experimental decoded call +type decodedCall struct { + call *experimentalanalyzer.DecodedCall +} + +func (d *decodedCall) To() string { + return d.call.Address +} + +func (d *decodedCall) Name() string { + return d.call.Method +} + +func (d *decodedCall) Inputs() types.DecodedParameters { + return convertNamedFields(d.call.Inputs) +} + +func (d *decodedCall) Outputs() types.DecodedParameters { + return convertNamedFields(d.call.Outputs) +} + +func (d *decodedCall) Data() []byte { + // Not directly available in legacy experimental analyzer, return empty + return []byte{} +} + +func (d *decodedCall) AdditionalFields() json.RawMessage { + // Not directly available in legacy experimental analyzer, return empty + return json.RawMessage("{}") +} + +// decodedParameter adapts legacy experimental named field +type decodedParameter struct { + field experimentalanalyzer.NamedField +} + +func (d *decodedParameter) Name() string { + return d.field.Name +} + +func (d *decodedParameter) Value() any { + return convertFieldValue(d.field.Value) +} + +// convertNamedFields converts legacy experimental NamedFields to DecodedParameters +func convertNamedFields(fields []experimentalanalyzer.NamedField) types.DecodedParameters { + params := make(types.DecodedParameters, len(fields)) + for i, field := range fields { + params[i] = &decodedParameter{field: field} + } + return params +} + +// convertFieldValue recursively converts legacy experimental FieldValue to simple types +func convertFieldValue(fv experimentalanalyzer.FieldValue) any { + if fv == nil { + return nil + } + + // Try to render the field value to a string + // The legacy experimental analyzer's FieldValue interface doesn't expose internal structure, + // so we use the rendering method + return fv.GetType() +} From 4edaca9d424ba03b9b742adecf4d27a45d9cbcfe Mon Sep 17 00:00:00 2001 From: Graham Goh Date: Mon, 9 Feb 2026 17:15:13 +1100 Subject: [PATCH 4/8] types --- .../proposalanalysis/formatter/formatter.go | 21 +- .../cld/mcms/proposalanalysis/types/types.go | 187 ++++++++++++++++++ 2 files changed, 191 insertions(+), 17 deletions(-) create mode 100644 engine/cld/mcms/proposalanalysis/types/types.go diff --git a/engine/cld/mcms/proposalanalysis/formatter/formatter.go b/engine/cld/mcms/proposalanalysis/formatter/formatter.go index 126423ff..13f660de 100644 --- a/engine/cld/mcms/proposalanalysis/formatter/formatter.go +++ b/engine/cld/mcms/proposalanalysis/formatter/formatter.go @@ -1,33 +1,20 @@ package formatter import ( - "context" "fmt" "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/types" ) -// FormatterRequest encapsulates the context passed to formatter methods. -type FormatterRequest struct { - Domain string - EnvironmentName string -} - -// Formatter transforms an AnalyzedProposal into a specific output format -type Formatter interface { - ID() string - Format(ctx context.Context, req FormatterRequest, proposal types.AnalyzedProposal) ([]byte, error) -} - // FormatterRegistry manages formatter registration and lookup type FormatterRegistry struct { - formatters map[string]Formatter + formatters map[string]types.Formatter } // NewFormatterRegistry creates a new formatter registry func NewFormatterRegistry() *FormatterRegistry { return &FormatterRegistry{ - formatters: make(map[string]Formatter), + formatters: make(map[string]types.Formatter), } } @@ -36,7 +23,7 @@ func NewFormatterRegistry() *FormatterRegistry { // - formatter is nil // - formatter ID is empty // - a formatter with the same ID is already registered -func (r *FormatterRegistry) Register(formatter Formatter) error { +func (r *FormatterRegistry) Register(formatter types.Formatter) error { if formatter == nil { return fmt.Errorf("formatter cannot be nil") } @@ -55,7 +42,7 @@ func (r *FormatterRegistry) Register(formatter Formatter) error { } // Get retrieves a formatter by ID -func (r *FormatterRegistry) Get(id string) (Formatter, bool) { +func (r *FormatterRegistry) Get(id string) (types.Formatter, bool) { f, ok := r.formatters[id] return f, ok } diff --git a/engine/cld/mcms/proposalanalysis/types/types.go b/engine/cld/mcms/proposalanalysis/types/types.go new file mode 100644 index 00000000..0d93fb6f --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/types/types.go @@ -0,0 +1,187 @@ +package types + +import ( + "context" + "encoding/json" + + "github.com/smartcontractkit/mcms" + + "github.com/smartcontractkit/chainlink-deployments-framework/chain" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldfdomain "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain" + experimentalanalyzer "github.com/smartcontractkit/chainlink-deployments-framework/experimental/analyzer" +) + +// ----- annotation ----- + +type Annotation interface { + Name() string + Type() string + Value() any +} + +type Annotations []Annotation + +type Annotated interface { + AddAnnotations(annotations ...Annotation) + Annotations() Annotations + GetAnnotationsByName(name string) Annotations + GetAnnotationsByType(atype string) Annotations + GetAnnotationsByAnalyzer(analyzerID string) Annotations +} + +// ----- decoded ----- + +type DecodedTimelockProposal interface { + BatchOperations() DecodedBatchOperations +} + +type DecodedBatchOperations []DecodedBatchOperation + +type DecodedBatchOperation interface { + ChainSelector() uint64 + Calls() DecodedCalls +} + +type DecodedCalls []DecodedCall + +type DecodedCall interface { // DecodedCall or DecodedTransaction? + To() string // review: current analyzer uses "Address" + Name() string // review: current analyzer uses "Method" + Inputs() DecodedParameters + Outputs() DecodedParameters + Data() []byte + AdditionalFields() json.RawMessage +} + +type DecodedParameters []DecodedParameter + +type DecodedParameter interface { + Name() string + Value() any +} + +// ----- analyzed ----- + +type AnalyzedProposal interface { + Annotated + BatchOperations() AnalyzedBatchOperations +} + +type AnalyzedBatchOperation interface { + Annotated + Calls() AnalyzedCalls +} + +type AnalyzedBatchOperations []AnalyzedBatchOperation + +type AnalyzedCalls []AnalyzedCall + +type AnalyzedCall interface { + Annotated + Name() string + Inputs() AnalyzedParameters + Outputs() AnalyzedParameters +} + +type AnalyzedParameters []AnalyzedParameter + +type AnalyzedParameter interface { + Annotated + Name() string + Type() string // reflect.Type? + Value() any // reflect.Value? +} + +// ----- contexts ----- + +type AnalyzerContext interface { + Proposal() AnalyzedProposal + BatchOperation() AnalyzedBatchOperation + Call() AnalyzedCall + + // GetEVMRegistry returns the EVM ABI registry for chain-specific decoding + // Returns nil if no custom registry was provided + GetEVMRegistry() experimentalanalyzer.EVMABIRegistry + + // GetSolanaRegistry returns the Solana decoder registry for chain-specific decoding + // Returns nil if no custom registry was provided + GetSolanaRegistry() experimentalanalyzer.SolanaDecoderRegistry + + // GetAnnotationsFrom returns annotations from a specific analyzer at the current context level. + // For ProposalAnalyzers, this queries the proposal; for CallAnalyzers, the call; etc. + // This is useful for accessing results from dependency analyzers. + // Returns empty slice if the analyzer ID is not found or no annotations exist. + GetAnnotationsFrom(analyzerID string) Annotations +} + +type ExecutionContext interface { + Domain() cldfdomain.Domain + EnvironmentName() string + BlockChains() chain.BlockChains + DataStore() datastore.DataStore + // Environment() Environment +} + +// AnalyzerRequest encapsulates the analyzer and execution contexts passed to analyzer methods. +type AnalyzerRequest struct { + AnalyzerContext AnalyzerContext + ExecutionContext ExecutionContext +} + +// ----- analyzers ----- + +type BaseAnalyzer interface { + ID() string + Dependencies() []string // Returns IDs of dependent analyzers +} + +type ProposalAnalyzer interface { + BaseAnalyzer + CanAnalyze(ctx context.Context, req AnalyzerRequest, proposal DecodedTimelockProposal) bool + Analyze(ctx context.Context, req AnalyzerRequest, proposal DecodedTimelockProposal) (Annotations, error) +} + +type BatchOperationAnalyzer interface { + BaseAnalyzer + CanAnalyze(ctx context.Context, req AnalyzerRequest, operation DecodedBatchOperation) bool + Analyze(ctx context.Context, req AnalyzerRequest, operation DecodedBatchOperation) (Annotations, error) +} + +type CallAnalyzer interface { + BaseAnalyzer + CanAnalyze(ctx context.Context, req AnalyzerRequest, call DecodedCall) bool + Analyze(ctx context.Context, req AnalyzerRequest, call DecodedCall) (Annotations, error) +} + +type ParameterAnalyzer interface { + BaseAnalyzer + CanAnalyze(ctx context.Context, req AnalyzerRequest, param DecodedParameter) bool + Analyze(ctx context.Context, req AnalyzerRequest, param DecodedParameter) (Annotations, error) +} + +// ----- formatter ----- + +// FormatterRequest encapsulates the context passed to formatter methods. +type FormatterRequest struct { + Domain string + EnvironmentName string +} + +// Formatter transforms an AnalyzedProposal into a specific output format +type Formatter interface { + ID() string + Format(ctx context.Context, req FormatterRequest, proposal AnalyzedProposal) ([]byte, error) +} + +// ----- engine ----- + +type AnalyzerEngine interface { + Run(ctx context.Context, domain cldfdomain.Domain, environmentName string, proposal *mcms.TimelockProposal) (AnalyzedProposal, error) + + RegisterAnalyzer(analyzer BaseAnalyzer) error + + RegisterFormatter(formatter Formatter) error + + Format(ctx context.Context, formatterID string, proposal AnalyzedProposal) (string, error) +} From 0827476e59a7113c089f5b4fb273b8e7cbb8df85 Mon Sep 17 00:00:00 2001 From: Graham Goh Date: Tue, 10 Feb 2026 22:37:05 +1100 Subject: [PATCH 5/8] update annotation --- .../proposalanalysis/analyzer/annotated.go | 22 ------------------- .../analyzer/annotations_test.go | 10 --------- 2 files changed, 32 deletions(-) diff --git a/engine/cld/mcms/proposalanalysis/analyzer/annotated.go b/engine/cld/mcms/proposalanalysis/analyzer/annotated.go index e51c7156..c23ca5d7 100644 --- a/engine/cld/mcms/proposalanalysis/analyzer/annotated.go +++ b/engine/cld/mcms/proposalanalysis/analyzer/annotated.go @@ -93,25 +93,3 @@ func (a annotated) GetAnnotationsByAnalyzer(analyzerID string) types.Annotations } return result } - -// helpers - -// NewInfoAnnotation creates an INFO annotation -func NewInfoAnnotation(name string, value any) types.Annotation { - return NewAnnotation(name, "INFO", value) -} - -// NewWarnAnnotation creates a WARN annotation -func NewWarnAnnotation(name string, value any) types.Annotation { - return NewAnnotation(name, "WARN", value) -} - -// NewErrorAnnotation creates an ERROR annotation -func NewErrorAnnotation(name string, value any) types.Annotation { - return NewAnnotation(name, "ERROR", value) -} - -// NewDiffAnnotation creates a DIFF annotation -func NewDiffAnnotation(name string, value any) types.Annotation { - return NewAnnotation(name, "DIFF", value) -} diff --git a/engine/cld/mcms/proposalanalysis/analyzer/annotations_test.go b/engine/cld/mcms/proposalanalysis/analyzer/annotations_test.go index 4e5bfae9..1e1a53cc 100644 --- a/engine/cld/mcms/proposalanalysis/analyzer/annotations_test.go +++ b/engine/cld/mcms/proposalanalysis/analyzer/annotations_test.go @@ -120,13 +120,3 @@ func TestAnnotations(t *testing.T) { assert.Len(t, gasAnalyzerAnnotations, 2) }) } - -func TestAnnotationsImplementInterfaces(t *testing.T) { - t.Run("annotation implements Annotation", func(t *testing.T) { - var _ analyzer.Annotation = &annotation{} - }) - - t.Run("annotated implements Annotated", func(t *testing.T) { - var _ analyzer.Annotated = &annotated{} - }) -} From 2dca2b0361fc3e2089b4b93cf925fd87e22c4bd1 Mon Sep 17 00:00:00 2001 From: Graham Goh Date: Tue, 10 Feb 2026 23:33:01 +1100 Subject: [PATCH 6/8] engine --- .../proposalanalysis/analyzer/annotated.go | 14 +- .../analyzer/annotations_test.go | 10 +- .../mcms/proposalanalysis/analyzer_context.go | 51 ++ engine/cld/mcms/proposalanalysis/engine.go | 547 ++++++++++++++++++ .../mcms/proposalanalysis/engine_options.go | 64 ++ .../cld/mcms/proposalanalysis/engine_test.go | 64 ++ .../proposalanalysis/execution_context.go | 33 ++ .../formatter/formatter_test.go | 18 +- .../cld/mcms/proposalanalysis/types/types.go | 6 +- 9 files changed, 791 insertions(+), 16 deletions(-) create mode 100644 engine/cld/mcms/proposalanalysis/analyzer_context.go create mode 100644 engine/cld/mcms/proposalanalysis/engine.go create mode 100644 engine/cld/mcms/proposalanalysis/engine_options.go create mode 100644 engine/cld/mcms/proposalanalysis/engine_test.go create mode 100644 engine/cld/mcms/proposalanalysis/execution_context.go diff --git a/engine/cld/mcms/proposalanalysis/analyzer/annotated.go b/engine/cld/mcms/proposalanalysis/analyzer/annotated.go index c23ca5d7..c1790eaf 100644 --- a/engine/cld/mcms/proposalanalysis/analyzer/annotated.go +++ b/engine/cld/mcms/proposalanalysis/analyzer/annotated.go @@ -44,22 +44,22 @@ func NewAnnotationWithAnalyzer(name, atype string, value any, analyzerID string) // --------------------------------------------------------------------- -var _ types.Annotated = &annotated{} +var _ types.Annotated = &Annotated{} -type annotated struct { +type Annotated struct { annotations types.Annotations } -func (a *annotated) AddAnnotations(annotations ...types.Annotation) { +func (a *Annotated) AddAnnotations(annotations ...types.Annotation) { a.annotations = append(a.annotations, annotations...) } -func (a annotated) Annotations() types.Annotations { +func (a Annotated) Annotations() types.Annotations { return a.annotations } // GetAnnotationsByName returns all annotations with the given name -func (a annotated) GetAnnotationsByName(name string) types.Annotations { +func (a Annotated) GetAnnotationsByName(name string) types.Annotations { var result types.Annotations for _, ann := range a.annotations { if ann.Name() == name { @@ -70,7 +70,7 @@ func (a annotated) GetAnnotationsByName(name string) types.Annotations { } // GetAnnotationsByType returns all annotations with the given type -func (a annotated) GetAnnotationsByType(atype string) types.Annotations { +func (a Annotated) GetAnnotationsByType(atype string) types.Annotations { var result types.Annotations for _, ann := range a.annotations { if ann.Type() == atype { @@ -81,7 +81,7 @@ func (a annotated) GetAnnotationsByType(atype string) types.Annotations { } // GetAnnotationsByAnalyzer returns all annotations created by the given analyzer ID -func (a annotated) GetAnnotationsByAnalyzer(analyzerID string) types.Annotations { +func (a Annotated) GetAnnotationsByAnalyzer(analyzerID string) types.Annotations { var result types.Annotations for _, ann := range a.annotations { // Try to cast to our internal annotation type to access analyzerID diff --git a/engine/cld/mcms/proposalanalysis/analyzer/annotations_test.go b/engine/cld/mcms/proposalanalysis/analyzer/annotations_test.go index 1e1a53cc..321e8571 100644 --- a/engine/cld/mcms/proposalanalysis/analyzer/annotations_test.go +++ b/engine/cld/mcms/proposalanalysis/analyzer/annotations_test.go @@ -26,7 +26,7 @@ func TestAnnotations(t *testing.T) { }) t.Run("AddAnnotations", func(t *testing.T) { - a := &annotated{} + a := &Annotated{} ann1 := NewAnnotation("ann1", "INFO", "v1") ann2 := NewAnnotation("ann2", "WARN", "v2") @@ -38,7 +38,7 @@ func TestAnnotations(t *testing.T) { }) t.Run("GetAnnotationsByName", func(t *testing.T) { - a := &annotated{} + a := &Annotated{} ann1 := NewAnnotation("gas-estimate", "INFO", 100) ann2 := NewAnnotation("security-check", "WARN", "vulnerable") ann3 := NewAnnotation("gas-estimate", "INFO", 200) @@ -59,7 +59,7 @@ func TestAnnotations(t *testing.T) { }) t.Run("GetAnnotationsByType", func(t *testing.T) { - a := &annotated{} + a := &Annotated{} ann1 := NewAnnotation("ann1", "INFO", "v1") ann2 := NewAnnotation("ann2", "WARN", "v2") ann3 := NewAnnotation("ann3", "INFO", "v3") @@ -81,7 +81,7 @@ func TestAnnotations(t *testing.T) { }) t.Run("GetAnnotationsByAnalyzer", func(t *testing.T) { - a := &annotated{} + a := &Annotated{} ann1 := NewAnnotationWithAnalyzer("ann1", "INFO", "v1", "analyzer-1") ann2 := NewAnnotationWithAnalyzer("ann2", "WARN", "v2", "analyzer-2") ann3 := NewAnnotationWithAnalyzer("ann3", "INFO", "v3", "analyzer-1") @@ -100,7 +100,7 @@ func TestAnnotations(t *testing.T) { }) t.Run("Combined queries", func(t *testing.T) { - a := &annotated{} + a := &Annotated{} ann1 := NewAnnotationWithAnalyzer("gas-estimate", "INFO", 100, "gas-analyzer") ann2 := NewAnnotationWithAnalyzer("gas-estimate", "WARN", 500, "gas-analyzer") ann3 := NewAnnotationWithAnalyzer("security", "WARN", "issue", "security-analyzer") diff --git a/engine/cld/mcms/proposalanalysis/analyzer_context.go b/engine/cld/mcms/proposalanalysis/analyzer_context.go new file mode 100644 index 00000000..19c3099e --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/analyzer_context.go @@ -0,0 +1,51 @@ +package proposalanalysis + +import ( + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/types" + experimentalanalyzer "github.com/smartcontractkit/chainlink-deployments-framework/experimental/analyzer" +) + +var _ types.AnalyzerContext = &analyzerContext{} + +type analyzerContext struct { + proposal types.AnalyzedProposal + batchOperation types.AnalyzedBatchOperation + call types.AnalyzedCall + evmRegistry experimentalanalyzer.EVMABIRegistry + solanaRegistry experimentalanalyzer.SolanaDecoderRegistry +} + +func (ac *analyzerContext) Proposal() types.AnalyzedProposal { + return ac.proposal +} + +func (ac *analyzerContext) BatchOperation() types.AnalyzedBatchOperation { + return ac.batchOperation +} + +func (ac *analyzerContext) Call() types.AnalyzedCall { + return ac.call +} + +func (ac *analyzerContext) GetEVMRegistry() experimentalanalyzer.EVMABIRegistry { + return ac.evmRegistry +} + +func (ac *analyzerContext) GetSolanaRegistry() experimentalanalyzer.SolanaDecoderRegistry { + return ac.solanaRegistry +} + +// GetAnnotationsFrom returns annotations from a specific analyzer +func (ac *analyzerContext) GetAnnotationsFrom(analyzerID string) types.Annotations { + var annotations types.Annotations + if ac.call != nil { + annotations = append(annotations, ac.call.GetAnnotationsByAnalyzer(analyzerID)...) + } + if ac.batchOperation != nil { + annotations = append(annotations, ac.batchOperation.GetAnnotationsByAnalyzer(analyzerID)...) + } + if ac.proposal != nil { + annotations = append(annotations, ac.proposal.GetAnnotationsByAnalyzer(analyzerID)...) + } + return annotations +} diff --git a/engine/cld/mcms/proposalanalysis/engine.go b/engine/cld/mcms/proposalanalysis/engine.go new file mode 100644 index 00000000..84b42fab --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/engine.go @@ -0,0 +1,547 @@ +package proposalanalysis + +import ( + "context" + "fmt" + "io" + "maps" + "slices" + + "github.com/samber/lo" + "github.com/smartcontractkit/mcms" + mcmstypes "github.com/smartcontractkit/mcms/types" + + cldfdomain "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain" + cldfenvironment "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/environment" + analyzer "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/analyzer" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/decoder" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/formatter" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/internal" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/types" + experimentalanalyzer "github.com/smartcontractkit/chainlink-deployments-framework/experimental/analyzer" +) + +type analyzerEngine struct { + proposalAnalyzers []types.ProposalAnalyzer + batchOperationAnalyzers []types.BatchOperationAnalyzer + callAnalyzers []types.CallAnalyzer + parameterAnalyzers []types.ParameterAnalyzer + + decoder decoder.ProposalDecoder + formatterRegistry *formatter.FormatterRegistry + evmRegistry experimentalanalyzer.EVMABIRegistry + solanaRegistry experimentalanalyzer.SolanaDecoderRegistry + executionContext types.ExecutionContext // Store for formatters +} + +var _ types.AnalyzerEngine = &analyzerEngine{} + +// NewAnalyzerEngine creates a new analyzer engine +// Options can be provided to customize the engine behavior, such as injecting registries +func NewAnalyzerEngine(opts ...EngineOption) types.AnalyzerEngine { + // Apply options to get configuration + cfg := ApplyEngineOptions(opts...) + + engine := &analyzerEngine{ + decoder: decoder.NewLegacyDecoder(), + formatterRegistry: formatter.NewFormatterRegistry(), + evmRegistry: cfg.GetEVMRegistry(), + solanaRegistry: cfg.GetSolanaRegistry(), + } + return engine +} + +func (ae *analyzerEngine) Run( + ctx context.Context, + domain cldfdomain.Domain, + environmentName string, + proposal *mcms.TimelockProposal, +) (types.AnalyzedProposal, error) { + mcmsChainSelectors := slices.Sorted(maps.Keys(proposal.ChainMetadata)) + chainSelectors := lo.Map(mcmsChainSelectors, func(s mcmstypes.ChainSelector, _ int) uint64 { return uint64(s) }) + env, err := cldfenvironment.Load(ctx, domain, environmentName, + cldfenvironment.OnlyLoadChainsFor(chainSelectors), + // cldfenvironment.WithLogger(lggr), + cldfenvironment.WithoutJD()) + if err != nil { + return nil, fmt.Errorf("failed to load environment: %w", err) + } + + // Decode proposal + decodedProposal, err := ae.decoder.Decode(ctx, env, proposal) + if err != nil { + return nil, fmt.Errorf("failed to decode timelock proposal: %w", err) + } + + actx := &analyzerContext{ + evmRegistry: ae.evmRegistry, + solanaRegistry: ae.solanaRegistry, + } + ectx := executionContext{ + domain: domain, + environmentName: environmentName, + blockChains: env.BlockChains, + dataStore: env.DataStore, + } + + // Store execution context for formatters + ae.executionContext = &ectx + + analyzedProposal, err := ae.analyzeProposal(ctx, actx, ectx, decodedProposal) + if err != nil { + return nil, fmt.Errorf("failed to analyze timelock proposal: %w", err) + } + + return analyzedProposal, nil +} + +// Format writes the formatted proposal output to the provided io.Writer. +func (ae *analyzerEngine) Format( + ctx context.Context, + w io.Writer, + formatterID string, + proposal types.AnalyzedProposal, +) error { + f, exists := ae.formatterRegistry.Get(formatterID) + if !exists { + return fmt.Errorf("formatter %s not registered", formatterID) + } + + if ae.executionContext == nil { + return fmt.Errorf("execution context not available - ensure Run() was called before Format()") + } + + req := types.FormatterRequest{ + Domain: ae.executionContext.Domain().String(), + EnvironmentName: ae.executionContext.EnvironmentName(), + } + + return f.Format(ctx, w, req, proposal) +} + +func (ae *analyzerEngine) RegisterAnalyzer(baseAnalyzer types.BaseAnalyzer) error { + if baseAnalyzer == nil { + return fmt.Errorf("analyzer cannot be nil") + } + + id := baseAnalyzer.ID() + if id == "" { + return fmt.Errorf("analyzer ID cannot be empty") + } + + // Check for duplicate IDs across all analyzer types + if ae.hasAnalyzerID(id) { + return fmt.Errorf("analyzer with ID %q is already registered", id) + } + + switch a := baseAnalyzer.(type) { + case types.ProposalAnalyzer: + ae.proposalAnalyzers = append(ae.proposalAnalyzers, a) + case types.BatchOperationAnalyzer: + ae.batchOperationAnalyzers = append(ae.batchOperationAnalyzers, a) + case types.CallAnalyzer: + ae.callAnalyzers = append(ae.callAnalyzers, a) + case types.ParameterAnalyzer: + ae.parameterAnalyzers = append(ae.parameterAnalyzers, a) + default: + return fmt.Errorf("unknown analyzer type") + } + + return nil +} + +// hasAnalyzerID checks if an analyzer with the given ID is already registered +func (ae *analyzerEngine) hasAnalyzerID(id string) bool { + // Check proposal analyzers + for _, a := range ae.proposalAnalyzers { + if a.ID() == id { + return true + } + } + + // Check batch operation analyzers + for _, a := range ae.batchOperationAnalyzers { + if a.ID() == id { + return true + } + } + + // Check call analyzers + for _, a := range ae.callAnalyzers { + if a.ID() == id { + return true + } + } + + // Check parameter analyzers + for _, a := range ae.parameterAnalyzers { + if a.ID() == id { + return true + } + } + + return false +} + +func (ae *analyzerEngine) RegisterFormatter(f types.Formatter) error { + return ae.formatterRegistry.Register(f) +} + +// trackAnnotations wraps annotations with analyzer ID tracking. +// This allows annotations to be queried by analyzer ID using GetAnnotationsByAnalyzer. +func trackAnnotations(annotations types.Annotations, analyzerID string) types.Annotations { + tracked := make(types.Annotations, 0, len(annotations)) + for _, ann := range annotations { + tracked = append(tracked, analyzer.NewAnnotationWithAnalyzer( + ann.Name(), + ann.Type(), + ann.Value(), + analyzerID, + )) + } + return tracked +} + +func (ae *analyzerEngine) analyzeProposal( + ctx context.Context, + actx *analyzerContext, + ectx executionContext, + decodedProposal types.DecodedTimelockProposal, +) (types.AnalyzedProposal, error) { + proposal := &analyzedProposal{ + Annotated: &analyzer.Annotated{}, + decodedProposal: decodedProposal, + } + actx.proposal = proposal + + // STEP 1: Analyze batch operations first (bottom-up approach) + // This allows proposal analyzers to access annotations from batch operations + batchOps := make(types.AnalyzedBatchOperations, 0) + for _, batchOp := range decodedProposal.BatchOperations() { + analyzedBatchOp, err := ae.analyzeBatchOperation(ctx, actx, ectx, batchOp) + if err != nil { + // log error but continue + continue + } + batchOps = append(batchOps, analyzedBatchOp) + } + proposal.batchOperations = batchOps + + // STEP 2: Now run proposal analyzers + // They can access annotations from batch operations via AnalyzerContext + baseAnalyzers := make([]types.BaseAnalyzer, len(ae.proposalAnalyzers)) + for i, a := range ae.proposalAnalyzers { + baseAnalyzers[i] = a + } + + graph, err := internal.NewDependencyGraph(baseAnalyzers) + if err != nil { + return nil, fmt.Errorf("failed to build dependency graph for proposal analyzers: %w", err) + } + + sorted, err := graph.TopologicalSort() + if err != nil { + return nil, fmt.Errorf("failed to sort proposal analyzers: %w", err) + } + + // Execute proposal analyzers in dependency order + for _, baseAnalyzer := range sorted { + proposalAnalyzer := baseAnalyzer.(types.ProposalAnalyzer) + + // Create analyzer request + req := types.AnalyzerRequest{ + AnalyzerContext: actx, + ExecutionContext: ectx, + } + + // Check if analyzer can analyze this proposal + if !proposalAnalyzer.CanAnalyze(ctx, req, decodedProposal) { + continue + } + + annotations, err := proposalAnalyzer.Analyze(ctx, req, decodedProposal) + if err != nil { + // log error but continue with other analyzers + continue + } + // Track which analyzer created the annotations + trackedAnnotations := trackAnnotations(annotations, proposalAnalyzer.ID()) + proposal.AddAnnotations(trackedAnnotations...) + } + + return proposal, nil +} + +func (ae *analyzerEngine) analyzeBatchOperation( + ctx context.Context, + actx *analyzerContext, + ectx executionContext, + decodedBatchOperation types.DecodedBatchOperation, +) (types.AnalyzedBatchOperation, error) { + batchOp := &analyzedBatchOperation{ + Annotated: &analyzer.Annotated{}, + decodedBatchOperation: decodedBatchOperation, + } + actx.batchOperation = batchOp + + // STEP 1: Analyze calls first (bottom-up approach) + // This allows batch operation analyzers to access annotations from calls + calls := make(types.AnalyzedCalls, 0) + for _, call := range decodedBatchOperation.Calls() { + analyzedCall, err := ae.analyzeCall(ctx, actx, ectx, call) + if err != nil { + // log error but continue + continue + } + calls = append(calls, analyzedCall) + } + batchOp.calls = calls + + // STEP 2: Now run batch operation analyzers + // They can access annotations from calls via AnalyzerContext + baseAnalyzers := make([]types.BaseAnalyzer, len(ae.batchOperationAnalyzers)) + for i, a := range ae.batchOperationAnalyzers { + baseAnalyzers[i] = a + } + + graph, err := internal.NewDependencyGraph(baseAnalyzers) + if err != nil { + return nil, fmt.Errorf("failed to build dependency graph for batch operation analyzers: %w", err) + } + + sorted, err := graph.TopologicalSort() + if err != nil { + return nil, fmt.Errorf("failed to sort batch operation analyzers: %w", err) + } + + // Execute batch operation analyzers + for _, baseAnalyzer := range sorted { + batchOpAnalyzer := baseAnalyzer.(types.BatchOperationAnalyzer) + + // Create analyzer request + req := types.AnalyzerRequest{ + AnalyzerContext: actx, + ExecutionContext: ectx, + } + + // Check if analyzer can analyze this batch operation + if !batchOpAnalyzer.CanAnalyze(ctx, req, decodedBatchOperation) { + continue + } + + annotations, err := batchOpAnalyzer.Analyze(ctx, req, decodedBatchOperation) + if err != nil { + // log error but continue + continue + } + trackedAnnotations := trackAnnotations(annotations, batchOpAnalyzer.ID()) + batchOp.AddAnnotations(trackedAnnotations...) + } + + return batchOp, nil +} + +func (ae *analyzerEngine) analyzeCall( + ctx context.Context, + actx *analyzerContext, + ectx executionContext, + decodedCall types.DecodedCall, +) (types.AnalyzedCall, error) { + call := &analyzedCall{ + Annotated: &analyzer.Annotated{}, + decodedCall: decodedCall, + } + actx.call = call + + // STEP 1: Analyze parameters first (bottom-up approach) + // This allows call analyzers to access annotations from parameters + inputs := make(types.AnalyzedParameters, 0) + for _, param := range decodedCall.Inputs() { + analyzedParam, err := ae.analyzeParameter(ctx, actx, ectx, param) + if err != nil { + // log error but continue + continue + } + inputs = append(inputs, analyzedParam) + } + + outputs := make(types.AnalyzedParameters, 0) + for _, param := range decodedCall.Outputs() { + analyzedParam, err := ae.analyzeParameter(ctx, actx, ectx, param) + if err != nil { + // log error but continue + continue + } + outputs = append(outputs, analyzedParam) + } + + call.inputs = inputs + call.outputs = outputs + + // STEP 2: Now run call analyzers + // They can access annotations from parameters via AnalyzerContext + baseAnalyzers := make([]types.BaseAnalyzer, len(ae.callAnalyzers)) + for i, a := range ae.callAnalyzers { + baseAnalyzers[i] = a + } + + graph, err := internal.NewDependencyGraph(baseAnalyzers) + if err != nil { + return nil, fmt.Errorf("failed to build dependency graph for call analyzers: %w", err) + } + + sorted, err := graph.TopologicalSort() + if err != nil { + return nil, fmt.Errorf("failed to sort call analyzers: %w", err) + } + + // Execute call analyzers + for _, baseAnalyzer := range sorted { + callAnalyzer := baseAnalyzer.(types.CallAnalyzer) + + // Create analyzer request + req := types.AnalyzerRequest{ + AnalyzerContext: actx, + ExecutionContext: ectx, + } + + // Check if analyzer can analyze this call + if !callAnalyzer.CanAnalyze(ctx, req, decodedCall) { + continue + } + + annotations, err := callAnalyzer.Analyze(ctx, req, decodedCall) + if err != nil { + // log error but continue + continue + } + trackedAnnotations := trackAnnotations(annotations, callAnalyzer.ID()) + call.AddAnnotations(trackedAnnotations...) + } + + return call, nil +} + +func (ae *analyzerEngine) analyzeParameter( + ctx context.Context, + actx *analyzerContext, + ectx executionContext, + decodedParameter types.DecodedParameter, +) (types.AnalyzedParameter, error) { + param := &analyzedParameter{ + Annotated: &analyzer.Annotated{}, + decodedParameter: decodedParameter, + } + + // Build dependency graph for parameter analyzers + baseAnalyzers := make([]types.BaseAnalyzer, len(ae.parameterAnalyzers)) + for i, a := range ae.parameterAnalyzers { + baseAnalyzers[i] = a + } + + graph, err := internal.NewDependencyGraph(baseAnalyzers) + if err != nil { + return nil, fmt.Errorf("failed to build dependency graph for parameter analyzers: %w", err) + } + + sorted, err := graph.TopologicalSort() + if err != nil { + return nil, fmt.Errorf("failed to sort parameter analyzers: %w", err) + } + + // Execute parameter analyzers + for _, baseAnalyzer := range sorted { + paramAnalyzer := baseAnalyzer.(types.ParameterAnalyzer) + + // Create analyzer request + req := types.AnalyzerRequest{ + AnalyzerContext: actx, + ExecutionContext: ectx, + } + + // Check if analyzer can analyze this parameter + if !paramAnalyzer.CanAnalyze(ctx, req, decodedParameter) { + continue + } + + annotations, err := paramAnalyzer.Analyze(ctx, req, decodedParameter) + if err != nil { + // log error but continue + continue + } + trackedAnnotations := trackAnnotations(annotations, paramAnalyzer.ID()) + param.AddAnnotations(trackedAnnotations...) + } + + return param, nil +} + +var _ types.AnalyzedProposal = &analyzedProposal{} + +type analyzedProposal struct { + *analyzer.Annotated + decodedProposal types.DecodedTimelockProposal + batchOperations types.AnalyzedBatchOperations +} + +func (a analyzedProposal) BatchOperations() types.AnalyzedBatchOperations { + return a.batchOperations +} + +// --------------------------------------------------------------------- + +var _ types.AnalyzedBatchOperation = &analyzedBatchOperation{} + +type analyzedBatchOperation struct { + *analyzer.Annotated + decodedBatchOperation types.DecodedBatchOperation + calls types.AnalyzedCalls +} + +func (a analyzedBatchOperation) Calls() types.AnalyzedCalls { + return a.calls +} + +// --------------------------------------------------------------------- + +var _ types.AnalyzedCall = &analyzedCall{} + +type analyzedCall struct { + *analyzer.Annotated + decodedCall types.DecodedCall + inputs types.AnalyzedParameters + outputs types.AnalyzedParameters +} + +func (a analyzedCall) Name() string { + return a.decodedCall.Name() +} + +func (a analyzedCall) Inputs() types.AnalyzedParameters { + return a.inputs +} + +func (a analyzedCall) Outputs() types.AnalyzedParameters { + return a.outputs +} + +// --------------------------------------------------------------------- + +var _ types.AnalyzedParameter = &analyzedParameter{} + +type analyzedParameter struct { + *analyzer.Annotated + decodedParameter types.DecodedParameter +} + +func (a analyzedParameter) Name() string { + return a.decodedParameter.Name() +} + +func (a analyzedParameter) Type() string { + return a.decodedParameter.Type() +} + +func (a analyzedParameter) Value() any { + return a.decodedParameter.Value() +} diff --git a/engine/cld/mcms/proposalanalysis/engine_options.go b/engine/cld/mcms/proposalanalysis/engine_options.go new file mode 100644 index 00000000..da95754c --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/engine_options.go @@ -0,0 +1,64 @@ +package proposalanalysis + +import ( + experimentalanalyzer "github.com/smartcontractkit/chainlink-deployments-framework/experimental/analyzer" +) + +// EngineOption configures the analyzer engine using the functional options pattern +type EngineOption func(*engineConfig) + +// engineConfig holds configuration for the analyzer engine +type engineConfig struct { + evmRegistry experimentalanalyzer.EVMABIRegistry + solanaRegistry experimentalanalyzer.SolanaDecoderRegistry +} + +// WithEVMRegistry allows injecting an EVM ABI registry into the analyzer engine +// The registry will be made available to all analyzers through the AnalyzerContext +// +// Example: +// +// evmRegistry, _ := experimentalanalyzer.NewEnvironmentEVMRegistry(env, map[string]string{ +// "MyContract": "/path/to/abi.json", +// }) +// engine := internal.NewAnalyzerEngine(analyzer.WithEVMRegistry(evmRegistry)) +func WithEVMRegistry(registry experimentalanalyzer.EVMABIRegistry) EngineOption { + return func(cfg *engineConfig) { + cfg.evmRegistry = registry + } +} + +// WithSolanaRegistry allows injecting a Solana decoder registry into the analyzer engine +// The registry will be made available to all analyzers through the AnalyzerContext +// +// Example: +// +// solanaRegistry, _ := experimentalanalyzer.NewEnvironmentSolanaRegistry(env, map[string]DecodeInstructionFn{ +// "MyProgram": myDecoder, +// }) +// engine := internal.NewAnalyzerEngine(analyzer.WithSolanaRegistry(solanaRegistry)) +func WithSolanaRegistry(registry experimentalanalyzer.SolanaDecoderRegistry) EngineOption { + return func(cfg *engineConfig) { + cfg.solanaRegistry = registry + } +} + +// ApplyEngineOptions applies all engine options and returns the configuration +// This is used internally by the engine implementation +func ApplyEngineOptions(opts ...EngineOption) *engineConfig { + cfg := &engineConfig{} + for _, opt := range opts { + opt(cfg) + } + return cfg +} + +// GetEVMRegistry returns the EVM registry from the config +func (cfg *engineConfig) GetEVMRegistry() experimentalanalyzer.EVMABIRegistry { + return cfg.evmRegistry +} + +// GetSolanaRegistry returns the Solana registry from the config +func (cfg *engineConfig) GetSolanaRegistry() experimentalanalyzer.SolanaDecoderRegistry { + return cfg.solanaRegistry +} diff --git a/engine/cld/mcms/proposalanalysis/engine_test.go b/engine/cld/mcms/proposalanalysis/engine_test.go new file mode 100644 index 00000000..b4f71c5a --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/engine_test.go @@ -0,0 +1,64 @@ +package proposalanalysis + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/analyzer" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/types" +) + +func TestTrackAnnotations(t *testing.T) { + t.Run("tracks annotations with analyzer ID", func(t *testing.T) { + // Create some test annotations + ann1 := analyzer.NewAnnotation("test1", "INFO", "value1") + ann2 := analyzer.NewAnnotation("test2", "WARN", "value2") + annotations := types.Annotations{ann1, ann2} + + // Track them with an analyzer ID + tracked := TrackAnnotations(annotations, "test-analyzer") + + // Verify we got the same number of annotations back + assert.Len(t, tracked, 2) + + // Verify the annotations maintain their original properties + assert.Equal(t, "test1", tracked[0].Name()) + assert.Equal(t, "INFO", tracked[0].Type()) + assert.Equal(t, "value1", tracked[0].Value()) + + assert.Equal(t, "test2", tracked[1].Name()) + assert.Equal(t, "WARN", tracked[1].Type()) + assert.Equal(t, "value2", tracked[1].Value()) + + // Verify they can be retrieved by analyzer ID + annotated := &analyzer.Annotated{} + annotated.AddAnnotations(tracked...) + + retrieved := annotated.GetAnnotationsByAnalyzer("test-analyzer") + assert.Len(t, retrieved, 2) + assert.Equal(t, "test1", retrieved[0].Name()) + assert.Equal(t, "test2", retrieved[1].Name()) + }) + + t.Run("handles empty annotations slice", func(t *testing.T) { + annotations := types.Annotations{} + tracked := TrackAnnotations(annotations, "test-analyzer") + + assert.Len(t, tracked, 0) + assert.NotNil(t, tracked) + }) + + t.Run("preserves annotation values of different types", func(t *testing.T) { + ann1 := analyzer.NewAnnotation("int-value", "INFO", 42) + ann2 := analyzer.NewAnnotation("bool-value", "INFO", true) + ann3 := analyzer.NewAnnotation("slice-value", "INFO", []string{"a", "b", "c"}) + annotations := types.Annotations{ann1, ann2, ann3} + + tracked := TrackAnnotations(annotations, "multi-type-analyzer") + + assert.Equal(t, 42, tracked[0].Value()) + assert.Equal(t, true, tracked[1].Value()) + assert.Equal(t, []string{"a", "b", "c"}, tracked[2].Value()) + }) +} diff --git a/engine/cld/mcms/proposalanalysis/execution_context.go b/engine/cld/mcms/proposalanalysis/execution_context.go new file mode 100644 index 00000000..f7d4fa2e --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/execution_context.go @@ -0,0 +1,33 @@ +package proposalanalysis + +import ( + "github.com/smartcontractkit/chainlink-deployments-framework/chain" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldfdomain "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/types" +) + +var _ types.ExecutionContext = &executionContext{} + +type executionContext struct { + domain cldfdomain.Domain + environmentName string + blockChains chain.BlockChains + dataStore datastore.DataStore +} + +func (ec executionContext) Domain() cldfdomain.Domain { + return ec.domain +} + +func (ec executionContext) EnvironmentName() string { + return ec.environmentName +} + +func (ec executionContext) BlockChains() chain.BlockChains { + return ec.blockChains +} + +func (ec executionContext) DataStore() datastore.DataStore { + return ec.dataStore +} diff --git a/engine/cld/mcms/proposalanalysis/formatter/formatter_test.go b/engine/cld/mcms/proposalanalysis/formatter/formatter_test.go index 8e3f234c..bc0ad907 100644 --- a/engine/cld/mcms/proposalanalysis/formatter/formatter_test.go +++ b/engine/cld/mcms/proposalanalysis/formatter/formatter_test.go @@ -1,7 +1,9 @@ package formatter import ( + "bytes" "context" + "io" "testing" "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/types" @@ -18,8 +20,9 @@ func (m *mockFormatter) ID() string { return m.id } -func (m *mockFormatter) Format(ctx context.Context, req FormatterRequest, proposal types.AnalyzedProposal) ([]bytes, error) { - return []byte("mock output"), nil +func (m *mockFormatter) Format(ctx context.Context, w io.Writer, req types.FormatterRequest, proposal types.AnalyzedProposal) error { + _, err := w.Write([]byte("mock output")) + return err } func TestFormatterRegistry(t *testing.T) { @@ -97,4 +100,15 @@ func TestFormatterRegistry(t *testing.T) { ids := registry.List() assert.Empty(t, ids) }) + + t.Run("Format writes to io.Writer", func(t *testing.T) { + formatter := &mockFormatter{id: "test-formatter"} + ctx := context.Background() + + // Example: Write to a bytes.Buffer + var buf bytes.Buffer + err := formatter.Format(ctx, &buf, types.FormatterRequest{}, nil) + require.NoError(t, err) + assert.Equal(t, "mock output", buf.String()) + }) } diff --git a/engine/cld/mcms/proposalanalysis/types/types.go b/engine/cld/mcms/proposalanalysis/types/types.go index 0d93fb6f..f2cebbc1 100644 --- a/engine/cld/mcms/proposalanalysis/types/types.go +++ b/engine/cld/mcms/proposalanalysis/types/types.go @@ -3,6 +3,7 @@ package types import ( "context" "encoding/json" + "io" "github.com/smartcontractkit/mcms" @@ -58,6 +59,7 @@ type DecodedParameters []DecodedParameter type DecodedParameter interface { Name() string + Type() string Value() any } @@ -171,7 +173,7 @@ type FormatterRequest struct { // Formatter transforms an AnalyzedProposal into a specific output format type Formatter interface { ID() string - Format(ctx context.Context, req FormatterRequest, proposal AnalyzedProposal) ([]byte, error) + Format(ctx context.Context, w io.Writer, req FormatterRequest, proposal AnalyzedProposal) error } // ----- engine ----- @@ -183,5 +185,5 @@ type AnalyzerEngine interface { RegisterFormatter(formatter Formatter) error - Format(ctx context.Context, formatterID string, proposal AnalyzedProposal) (string, error) + Format(ctx context.Context, w io.Writer, formatterID string, proposal AnalyzedProposal) error } From 2240e92f1646c2fbdfad81d9e79ed002d4edd142 Mon Sep 17 00:00:00 2001 From: Graham Goh Date: Tue, 10 Feb 2026 23:48:32 +1100 Subject: [PATCH 7/8] logger --- engine/cld/mcms/proposalanalysis/engine.go | 21 +++--- .../mcms/proposalanalysis/engine_options.go | 25 +++++++ .../proposalanalysis/engine_options_test.go | 41 +++++++++++ .../cld/mcms/proposalanalysis/engine_test.go | 70 ++++++------------- 4 files changed, 98 insertions(+), 59 deletions(-) create mode 100644 engine/cld/mcms/proposalanalysis/engine_options_test.go diff --git a/engine/cld/mcms/proposalanalysis/engine.go b/engine/cld/mcms/proposalanalysis/engine.go index 84b42fab..7c90c229 100644 --- a/engine/cld/mcms/proposalanalysis/engine.go +++ b/engine/cld/mcms/proposalanalysis/engine.go @@ -18,6 +18,7 @@ import ( "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/formatter" "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/internal" "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/types" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" experimentalanalyzer "github.com/smartcontractkit/chainlink-deployments-framework/experimental/analyzer" ) @@ -32,12 +33,13 @@ type analyzerEngine struct { evmRegistry experimentalanalyzer.EVMABIRegistry solanaRegistry experimentalanalyzer.SolanaDecoderRegistry executionContext types.ExecutionContext // Store for formatters + logger logger.Logger } var _ types.AnalyzerEngine = &analyzerEngine{} // NewAnalyzerEngine creates a new analyzer engine -// Options can be provided to customize the engine behavior, such as injecting registries +// Options can be provided to customize the engine behavior, such as injecting registries and logger func NewAnalyzerEngine(opts ...EngineOption) types.AnalyzerEngine { // Apply options to get configuration cfg := ApplyEngineOptions(opts...) @@ -47,6 +49,7 @@ func NewAnalyzerEngine(opts ...EngineOption) types.AnalyzerEngine { formatterRegistry: formatter.NewFormatterRegistry(), evmRegistry: cfg.GetEVMRegistry(), solanaRegistry: cfg.GetSolanaRegistry(), + logger: cfg.GetLogger(), } return engine } @@ -220,7 +223,7 @@ func (ae *analyzerEngine) analyzeProposal( for _, batchOp := range decodedProposal.BatchOperations() { analyzedBatchOp, err := ae.analyzeBatchOperation(ctx, actx, ectx, batchOp) if err != nil { - // log error but continue + ae.logger.Errorw("Failed to analyze batch operation", "chainSelector", batchOp.ChainSelector(), "error", err) continue } batchOps = append(batchOps, analyzedBatchOp) @@ -261,7 +264,7 @@ func (ae *analyzerEngine) analyzeProposal( annotations, err := proposalAnalyzer.Analyze(ctx, req, decodedProposal) if err != nil { - // log error but continue with other analyzers + ae.logger.Errorw("Proposal analyzer failed", "analyzerID", proposalAnalyzer.ID(), "error", err) continue } // Track which analyzer created the annotations @@ -290,7 +293,7 @@ func (ae *analyzerEngine) analyzeBatchOperation( for _, call := range decodedBatchOperation.Calls() { analyzedCall, err := ae.analyzeCall(ctx, actx, ectx, call) if err != nil { - // log error but continue + ae.logger.Errorw("Failed to analyze call", "callName", call.Name(), "error", err) continue } calls = append(calls, analyzedCall) @@ -331,7 +334,7 @@ func (ae *analyzerEngine) analyzeBatchOperation( annotations, err := batchOpAnalyzer.Analyze(ctx, req, decodedBatchOperation) if err != nil { - // log error but continue + ae.logger.Errorw("Batch operation analyzer failed", "analyzerID", batchOpAnalyzer.ID(), "chainSelector", decodedBatchOperation.ChainSelector(), "error", err) continue } trackedAnnotations := trackAnnotations(annotations, batchOpAnalyzer.ID()) @@ -359,7 +362,7 @@ func (ae *analyzerEngine) analyzeCall( for _, param := range decodedCall.Inputs() { analyzedParam, err := ae.analyzeParameter(ctx, actx, ectx, param) if err != nil { - // log error but continue + ae.logger.Errorw("Failed to analyze input parameter", "paramName", param.Name(), "paramType", param.Type(), "error", err) continue } inputs = append(inputs, analyzedParam) @@ -369,7 +372,7 @@ func (ae *analyzerEngine) analyzeCall( for _, param := range decodedCall.Outputs() { analyzedParam, err := ae.analyzeParameter(ctx, actx, ectx, param) if err != nil { - // log error but continue + ae.logger.Errorw("Failed to analyze output parameter", "paramName", param.Name(), "paramType", param.Type(), "error", err) continue } outputs = append(outputs, analyzedParam) @@ -412,7 +415,7 @@ func (ae *analyzerEngine) analyzeCall( annotations, err := callAnalyzer.Analyze(ctx, req, decodedCall) if err != nil { - // log error but continue + ae.logger.Errorw("Call analyzer failed", "analyzerID", callAnalyzer.ID(), "callName", decodedCall.Name(), "error", err) continue } trackedAnnotations := trackAnnotations(annotations, callAnalyzer.ID()) @@ -466,7 +469,7 @@ func (ae *analyzerEngine) analyzeParameter( annotations, err := paramAnalyzer.Analyze(ctx, req, decodedParameter) if err != nil { - // log error but continue + ae.logger.Errorw("Parameter analyzer failed", "analyzerID", paramAnalyzer.ID(), "paramName", decodedParameter.Name(), "paramType", decodedParameter.Type(), "error", err) continue } trackedAnnotations := trackAnnotations(annotations, paramAnalyzer.ID()) diff --git a/engine/cld/mcms/proposalanalysis/engine_options.go b/engine/cld/mcms/proposalanalysis/engine_options.go index da95754c..1c54e9dc 100644 --- a/engine/cld/mcms/proposalanalysis/engine_options.go +++ b/engine/cld/mcms/proposalanalysis/engine_options.go @@ -1,6 +1,7 @@ package proposalanalysis import ( + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" experimentalanalyzer "github.com/smartcontractkit/chainlink-deployments-framework/experimental/analyzer" ) @@ -11,6 +12,7 @@ type EngineOption func(*engineConfig) type engineConfig struct { evmRegistry experimentalanalyzer.EVMABIRegistry solanaRegistry experimentalanalyzer.SolanaDecoderRegistry + logger logger.Logger } // WithEVMRegistry allows injecting an EVM ABI registry into the analyzer engine @@ -62,3 +64,26 @@ func (cfg *engineConfig) GetEVMRegistry() experimentalanalyzer.EVMABIRegistry { func (cfg *engineConfig) GetSolanaRegistry() experimentalanalyzer.SolanaDecoderRegistry { return cfg.solanaRegistry } + +// WithLogger allows injecting a logger into the analyzer engine +// The logger will be used for logging errors and debug information during analysis +// If not provided, the engine will use a no-op logger +// +// Example: +// +// lggr, _ := logger.New() +// engine := proposalanalysis.NewAnalyzerEngine(proposalanalysis.WithLogger(lggr)) +func WithLogger(lggr logger.Logger) EngineOption { + return func(cfg *engineConfig) { + cfg.logger = lggr + } +} + +// GetLogger returns the logger from the config +// Returns a no-op logger if none was provided +func (cfg *engineConfig) GetLogger() logger.Logger { + if cfg.logger == nil { + return logger.Nop() + } + return cfg.logger +} diff --git a/engine/cld/mcms/proposalanalysis/engine_options_test.go b/engine/cld/mcms/proposalanalysis/engine_options_test.go new file mode 100644 index 00000000..ac6334ed --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/engine_options_test.go @@ -0,0 +1,41 @@ +package proposalanalysis + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" +) + +func TestEngineOptions(t *testing.T) { + t.Run("WithLogger option sets logger", func(t *testing.T) { + lggr := logger.Test(t) + cfg := ApplyEngineOptions(WithLogger(lggr)) + + assert.NotNil(t, cfg.GetLogger()) + }) + + t.Run("GetLogger returns nop logger when not set", func(t *testing.T) { + cfg := ApplyEngineOptions() + + lggr := cfg.GetLogger() + assert.NotNil(t, lggr) + // Verify it's a nop logger by checking it doesn't panic when called + lggr.Info("test message") + lggr.Errorw("test error", "key", "value") + }) + + t.Run("multiple options can be combined", func(t *testing.T) { + lggr := logger.Test(t) + cfg := ApplyEngineOptions( + WithLogger(lggr), + WithEVMRegistry(nil), + WithSolanaRegistry(nil), + ) + + assert.NotNil(t, cfg.GetLogger()) + assert.Nil(t, cfg.GetEVMRegistry()) + assert.Nil(t, cfg.GetSolanaRegistry()) + }) +} diff --git a/engine/cld/mcms/proposalanalysis/engine_test.go b/engine/cld/mcms/proposalanalysis/engine_test.go index b4f71c5a..939d9284 100644 --- a/engine/cld/mcms/proposalanalysis/engine_test.go +++ b/engine/cld/mcms/proposalanalysis/engine_test.go @@ -4,61 +4,31 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" - "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/analyzer" - "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/types" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" ) -func TestTrackAnnotations(t *testing.T) { - t.Run("tracks annotations with analyzer ID", func(t *testing.T) { - // Create some test annotations - ann1 := analyzer.NewAnnotation("test1", "INFO", "value1") - ann2 := analyzer.NewAnnotation("test2", "WARN", "value2") - annotations := types.Annotations{ann1, ann2} - - // Track them with an analyzer ID - tracked := TrackAnnotations(annotations, "test-analyzer") - - // Verify we got the same number of annotations back - assert.Len(t, tracked, 2) - - // Verify the annotations maintain their original properties - assert.Equal(t, "test1", tracked[0].Name()) - assert.Equal(t, "INFO", tracked[0].Type()) - assert.Equal(t, "value1", tracked[0].Value()) - - assert.Equal(t, "test2", tracked[1].Name()) - assert.Equal(t, "WARN", tracked[1].Type()) - assert.Equal(t, "value2", tracked[1].Value()) - - // Verify they can be retrieved by analyzer ID - annotated := &analyzer.Annotated{} - annotated.AddAnnotations(tracked...) - - retrieved := annotated.GetAnnotationsByAnalyzer("test-analyzer") - assert.Len(t, retrieved, 2) - assert.Equal(t, "test1", retrieved[0].Name()) - assert.Equal(t, "test2", retrieved[1].Name()) +func TestEngineWithLogger(t *testing.T) { + t.Run("engine accepts logger from options", func(t *testing.T) { + lggr := logger.Test(t) + engine := NewAnalyzerEngine(WithLogger(lggr)) + + assert.NotNil(t, engine) + // Verify the logger is set by checking the concrete type + concreteEngine, ok := engine.(*analyzerEngine) + require.True(t, ok) + assert.NotNil(t, concreteEngine.logger) + assert.Equal(t, "TestEngineWithLogger/engine_accepts_logger_from_options", concreteEngine.logger.Name()) }) - t.Run("handles empty annotations slice", func(t *testing.T) { - annotations := types.Annotations{} - tracked := TrackAnnotations(annotations, "test-analyzer") - - assert.Len(t, tracked, 0) - assert.NotNil(t, tracked) - }) - - t.Run("preserves annotation values of different types", func(t *testing.T) { - ann1 := analyzer.NewAnnotation("int-value", "INFO", 42) - ann2 := analyzer.NewAnnotation("bool-value", "INFO", true) - ann3 := analyzer.NewAnnotation("slice-value", "INFO", []string{"a", "b", "c"}) - annotations := types.Annotations{ann1, ann2, ann3} - - tracked := TrackAnnotations(annotations, "multi-type-analyzer") + t.Run("engine uses nop logger when not provided", func(t *testing.T) { + engine := NewAnalyzerEngine() - assert.Equal(t, 42, tracked[0].Value()) - assert.Equal(t, true, tracked[1].Value()) - assert.Equal(t, []string{"a", "b", "c"}, tracked[2].Value()) + assert.NotNil(t, engine) + // Verify the logger is set (will be Nop logger) + concreteEngine, ok := engine.(*analyzerEngine) + require.True(t, ok) + assert.NotNil(t, concreteEngine.logger) }) } From bd9c24adfcc33b3770d19910f1d0567bb7bb9bec Mon Sep 17 00:00:00 2001 From: Graham Goh Date: Tue, 10 Feb 2026 23:52:23 +1100 Subject: [PATCH 8/8] with timeout --- engine/cld/mcms/proposalanalysis/engine.go | 53 +++++++++++++++---- .../mcms/proposalanalysis/engine_options.go | 37 +++++++++++-- .../proposalanalysis/engine_options_test.go | 32 +++++++++++ 3 files changed, 110 insertions(+), 12 deletions(-) diff --git a/engine/cld/mcms/proposalanalysis/engine.go b/engine/cld/mcms/proposalanalysis/engine.go index 7c90c229..3241f9a2 100644 --- a/engine/cld/mcms/proposalanalysis/engine.go +++ b/engine/cld/mcms/proposalanalysis/engine.go @@ -6,6 +6,7 @@ import ( "io" "maps" "slices" + "time" "github.com/samber/lo" "github.com/smartcontractkit/mcms" @@ -34,12 +35,13 @@ type analyzerEngine struct { solanaRegistry experimentalanalyzer.SolanaDecoderRegistry executionContext types.ExecutionContext // Store for formatters logger logger.Logger + analyzerTimeout time.Duration } var _ types.AnalyzerEngine = &analyzerEngine{} // NewAnalyzerEngine creates a new analyzer engine -// Options can be provided to customize the engine behavior, such as injecting registries and logger +// Options can be provided to customize the engine behavior, such as injecting registries, logger, and timeouts func NewAnalyzerEngine(opts ...EngineOption) types.AnalyzerEngine { // Apply options to get configuration cfg := ApplyEngineOptions(opts...) @@ -50,6 +52,7 @@ func NewAnalyzerEngine(opts ...EngineOption) types.AnalyzerEngine { evmRegistry: cfg.GetEVMRegistry(), solanaRegistry: cfg.GetSolanaRegistry(), logger: cfg.GetLogger(), + analyzerTimeout: cfg.GetAnalyzerTimeout(), } return engine } @@ -262,9 +265,17 @@ func (ae *analyzerEngine) analyzeProposal( continue } - annotations, err := proposalAnalyzer.Analyze(ctx, req, decodedProposal) + // Execute analyzer with timeout + analyzerCtx, cancel := context.WithTimeout(ctx, ae.analyzerTimeout) + annotations, err := proposalAnalyzer.Analyze(analyzerCtx, req, decodedProposal) + cancel() // Always cancel to free resources + if err != nil { - ae.logger.Errorw("Proposal analyzer failed", "analyzerID", proposalAnalyzer.ID(), "error", err) + if analyzerCtx.Err() == context.DeadlineExceeded { + ae.logger.Errorw("Proposal analyzer timed out", "analyzerID", proposalAnalyzer.ID(), "timeout", ae.analyzerTimeout) + } else { + ae.logger.Errorw("Proposal analyzer failed", "analyzerID", proposalAnalyzer.ID(), "error", err) + } continue } // Track which analyzer created the annotations @@ -332,9 +343,17 @@ func (ae *analyzerEngine) analyzeBatchOperation( continue } - annotations, err := batchOpAnalyzer.Analyze(ctx, req, decodedBatchOperation) + // Execute analyzer with timeout + analyzerCtx, cancel := context.WithTimeout(ctx, ae.analyzerTimeout) + annotations, err := batchOpAnalyzer.Analyze(analyzerCtx, req, decodedBatchOperation) + cancel() // Always cancel to free resources + if err != nil { - ae.logger.Errorw("Batch operation analyzer failed", "analyzerID", batchOpAnalyzer.ID(), "chainSelector", decodedBatchOperation.ChainSelector(), "error", err) + if analyzerCtx.Err() == context.DeadlineExceeded { + ae.logger.Errorw("Batch operation analyzer timed out", "analyzerID", batchOpAnalyzer.ID(), "chainSelector", decodedBatchOperation.ChainSelector(), "timeout", ae.analyzerTimeout) + } else { + ae.logger.Errorw("Batch operation analyzer failed", "analyzerID", batchOpAnalyzer.ID(), "chainSelector", decodedBatchOperation.ChainSelector(), "error", err) + } continue } trackedAnnotations := trackAnnotations(annotations, batchOpAnalyzer.ID()) @@ -413,9 +432,17 @@ func (ae *analyzerEngine) analyzeCall( continue } - annotations, err := callAnalyzer.Analyze(ctx, req, decodedCall) + // Execute analyzer with timeout + analyzerCtx, cancel := context.WithTimeout(ctx, ae.analyzerTimeout) + annotations, err := callAnalyzer.Analyze(analyzerCtx, req, decodedCall) + cancel() // Always cancel to free resources + if err != nil { - ae.logger.Errorw("Call analyzer failed", "analyzerID", callAnalyzer.ID(), "callName", decodedCall.Name(), "error", err) + if analyzerCtx.Err() == context.DeadlineExceeded { + ae.logger.Errorw("Call analyzer timed out", "analyzerID", callAnalyzer.ID(), "callName", decodedCall.Name(), "timeout", ae.analyzerTimeout) + } else { + ae.logger.Errorw("Call analyzer failed", "analyzerID", callAnalyzer.ID(), "callName", decodedCall.Name(), "error", err) + } continue } trackedAnnotations := trackAnnotations(annotations, callAnalyzer.ID()) @@ -467,9 +494,17 @@ func (ae *analyzerEngine) analyzeParameter( continue } - annotations, err := paramAnalyzer.Analyze(ctx, req, decodedParameter) + // Execute analyzer with timeout + analyzerCtx, cancel := context.WithTimeout(ctx, ae.analyzerTimeout) + annotations, err := paramAnalyzer.Analyze(analyzerCtx, req, decodedParameter) + cancel() // Always cancel to free resources + if err != nil { - ae.logger.Errorw("Parameter analyzer failed", "analyzerID", paramAnalyzer.ID(), "paramName", decodedParameter.Name(), "paramType", decodedParameter.Type(), "error", err) + if analyzerCtx.Err() == context.DeadlineExceeded { + ae.logger.Errorw("Parameter analyzer timed out", "analyzerID", paramAnalyzer.ID(), "paramName", decodedParameter.Name(), "paramType", decodedParameter.Type(), "timeout", ae.analyzerTimeout) + } else { + ae.logger.Errorw("Parameter analyzer failed", "analyzerID", paramAnalyzer.ID(), "paramName", decodedParameter.Name(), "paramType", decodedParameter.Type(), "error", err) + } continue } trackedAnnotations := trackAnnotations(annotations, paramAnalyzer.ID()) diff --git a/engine/cld/mcms/proposalanalysis/engine_options.go b/engine/cld/mcms/proposalanalysis/engine_options.go index 1c54e9dc..bc3fc45c 100644 --- a/engine/cld/mcms/proposalanalysis/engine_options.go +++ b/engine/cld/mcms/proposalanalysis/engine_options.go @@ -1,18 +1,24 @@ package proposalanalysis import ( + "time" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" experimentalanalyzer "github.com/smartcontractkit/chainlink-deployments-framework/experimental/analyzer" ) +// Default timeout for analyzer execution +const DefaultAnalyzerTimeout = 5 * time.Minute + // EngineOption configures the analyzer engine using the functional options pattern type EngineOption func(*engineConfig) // engineConfig holds configuration for the analyzer engine type engineConfig struct { - evmRegistry experimentalanalyzer.EVMABIRegistry - solanaRegistry experimentalanalyzer.SolanaDecoderRegistry - logger logger.Logger + evmRegistry experimentalanalyzer.EVMABIRegistry + solanaRegistry experimentalanalyzer.SolanaDecoderRegistry + logger logger.Logger + analyzerTimeout time.Duration } // WithEVMRegistry allows injecting an EVM ABI registry into the analyzer engine @@ -87,3 +93,28 @@ func (cfg *engineConfig) GetLogger() logger.Logger { } return cfg.logger } + +// WithAnalyzerTimeout allows configuring the timeout for analyzer execution +// Each analyzer will be given this amount of time to complete before being cancelled +// This is important for analyzers that make network calls or other long-running operations +// Default is 5 minutes if not specified +// +// Example: +// +// engine := proposalanalysis.NewAnalyzerEngine( +// proposalanalysis.WithAnalyzerTimeout(2 * time.Minute), +// ) +func WithAnalyzerTimeout(timeout time.Duration) EngineOption { + return func(cfg *engineConfig) { + cfg.analyzerTimeout = timeout + } +} + +// GetAnalyzerTimeout returns the analyzer timeout from the config +// Returns DefaultAnalyzerTimeout (5 minutes) if none was provided +func (cfg *engineConfig) GetAnalyzerTimeout() time.Duration { + if cfg.analyzerTimeout == 0 { + return DefaultAnalyzerTimeout + } + return cfg.analyzerTimeout +} diff --git a/engine/cld/mcms/proposalanalysis/engine_options_test.go b/engine/cld/mcms/proposalanalysis/engine_options_test.go index ac6334ed..9a9eb182 100644 --- a/engine/cld/mcms/proposalanalysis/engine_options_test.go +++ b/engine/cld/mcms/proposalanalysis/engine_options_test.go @@ -2,6 +2,7 @@ package proposalanalysis import ( "testing" + "time" "github.com/stretchr/testify/assert" @@ -38,4 +39,35 @@ func TestEngineOptions(t *testing.T) { assert.Nil(t, cfg.GetEVMRegistry()) assert.Nil(t, cfg.GetSolanaRegistry()) }) + + t.Run("WithAnalyzerTimeout option sets timeout", func(t *testing.T) { + customTimeout := 2 * time.Minute + cfg := ApplyEngineOptions(WithAnalyzerTimeout(customTimeout)) + + assert.Equal(t, customTimeout, cfg.GetAnalyzerTimeout()) + }) + + t.Run("GetAnalyzerTimeout returns default when not set", func(t *testing.T) { + cfg := ApplyEngineOptions() + + timeout := cfg.GetAnalyzerTimeout() + assert.Equal(t, DefaultAnalyzerTimeout, timeout) + assert.Equal(t, 5*time.Minute, timeout) + }) + + t.Run("all options can be combined including timeout", func(t *testing.T) { + lggr := logger.Test(t) + customTimeout := 1 * time.Minute + cfg := ApplyEngineOptions( + WithLogger(lggr), + WithAnalyzerTimeout(customTimeout), + WithEVMRegistry(nil), + WithSolanaRegistry(nil), + ) + + assert.NotNil(t, cfg.GetLogger()) + assert.Equal(t, customTimeout, cfg.GetAnalyzerTimeout()) + assert.Nil(t, cfg.GetEVMRegistry()) + assert.Nil(t, cfg.GetSolanaRegistry()) + }) }