Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions engine/cld/mcms/proposalanalysis/analyzer/annotated.go
Original file line number Diff line number Diff line change
@@ -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)
}
132 changes: 132 additions & 0 deletions engine/cld/mcms/proposalanalysis/analyzer/annotations_test.go
Original file line number Diff line number Diff line change
@@ -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{}

Check failure on line 126 in engine/cld/mcms/proposalanalysis/analyzer/annotations_test.go

View workflow job for this annotation

GitHub Actions / Tests

undefined: analyzer
})

t.Run("annotated implements Annotated", func(t *testing.T) {
var _ analyzer.Annotated = &annotated{}

Check failure on line 130 in engine/cld/mcms/proposalanalysis/analyzer/annotations_test.go

View workflow job for this annotation

GitHub Actions / Tests

undefined: analyzer
})
}
77 changes: 77 additions & 0 deletions engine/cld/mcms/proposalanalysis/decoder/decoder.go
Original file line number Diff line number Diff line change
@@ -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
}
49 changes: 49 additions & 0 deletions engine/cld/mcms/proposalanalysis/decoder/decoder_test.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading