diff --git a/engine/cld/mcms/analyzer/internal/RENDERER.md b/engine/cld/mcms/analyzer/internal/RENDERER.md new file mode 100644 index 00000000..e5c48de1 --- /dev/null +++ b/engine/cld/mcms/analyzer/internal/RENDERER.md @@ -0,0 +1,236 @@ +# Analyzer Renderer Component + +The renderer component provides a flexible, template-based system for displaying `AnalyzedProposal` instances. + +## Overview + +The renderer uses Go's `text/template` package to render analyzed MCMS proposals in a hierarchical, human-readable format. It leverages template composition to render nested structures: + +``` +AnalyzedProposal + └─ AnalyzedBatchOperation(s) + └─ AnalyzedCall(s) + └─ AnalyzedParameter(s) +``` + +Each level can have annotations that are also rendered. + +## Architecture + +### Template Hierarchy + +The renderer implements a hierarchical template structure: + +1. **`proposal`** - Top-level template for the entire proposal +2. **`batchOperation`** - Template for each batch operation within a proposal +3. **`call`** - Template for each call within a batch operation +4. **`parameter`** - Template for each parameter (input/output) within a call +5. **`annotations`** - Shared template for rendering annotations at any level + +### Template Composition + +Templates use the `{{template "name" .}}` action to embed other templates, creating a composition structure: + +```go +// In the proposal template: +{{range .BatchOperations}} + {{template "batchOperation" .}} +{{end}} + +// In the batchOperation template: +{{range .Calls}} + {{template "call" .}} +{{end}} + +// And so on... +``` + +This approach allows each template to focus on rendering its own level while delegating to child templates for nested structures. + +## Usage + +### Basic Usage with Default Templates + +```go +import "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/analyzer/internal" + +// Create renderer with default templates +renderer, err := internal.NewRenderer() +if err != nil { + return err +} + +// Render an analyzed proposal +output, err := renderer.Render(analyzedProposal) +if err != nil { + return err +} + +fmt.Println(output) +``` + +### Custom Templates + +You can provide custom templates to change the output format: + +```go +customTemplates := map[string]string{ + "proposal": `{{define "proposal"}} +=== My Custom Proposal Format === +Total Batch Operations: {{len .BatchOperations}} +{{range .BatchOperations}}{{template "batchOperation" .}}{{end}} +{{end}}`, + + "batchOperation": `{{define "batchOperation"}} +--- Batch Operation --- +Calls: {{len .Calls}} +{{range .Calls}}{{template "call" .}}{{end}} +{{end}}`, + + // ... more templates ... +} + +renderer, err := internal.NewRendererWithTemplates(customTemplates) +``` + +### Rendering to a Writer + +For better performance with large proposals, render directly to a writer: + +```go +var buf bytes.Buffer +err := renderer.RenderTo(&buf, analyzedProposal) +if err != nil { + return err +} +``` + +## Template Functions + +The renderer provides several helper functions available in templates: + +- **`indent `** - Indents each line of text by the specified number of spaces +- **`trimRight `** - Trims whitespace from the right side +- **`upper `** - Converts text to uppercase +- **`lower `** - Converts text to lowercase +- **`title `** - Converts text to title case +- **`join `** - Joins string items with a separator +- **`repeat `** - Repeats text N times +- **`hasAnnotations `** - Returns true if the object has annotations +- **`severitySymbol `** - Returns a symbol for severity levels (✗, ⚠, ℹ, ⚙) +- **`riskSymbol `** - Returns a symbol for risk levels (🔴, 🟡, 🟢) + +Example usage in templates: + +```go +{{if hasAnnotations .}} + {{severitySymbol "warning"}} Annotations present +{{end}} +``` + +## Default Output Format + +The default templates produce output like: + +``` +╔═══════════════════════════════════════════════════════════════════════════════ +║ ANALYZED PROPOSAL +╚═══════════════════════════════════════════════════════════════════════════════ +Annotations: + - proposal.id [string]: PROP-001 + +Batch Operations: 1 + +───────────────────────────────────────────────────────────────────────────── + BATCH OPERATION +───────────────────────────────────────────────────────────────────────────── +Annotations: + - cld.risk [enum]: low + +Calls: 1 + + ┌─────────────────────────────────────────────────────────────────────────── + │ CALL: transfer + └─────────────────────────────────────────────────────────────────────────── + Annotations: + - cld.severity [enum]: info + + Inputs (2): + • recipient (address): 0x1234567890abcdef + Annotations: + - param.note [string]: important parameter + • amount (uint256): 1000000000000000000 + + Outputs (1): + • success (bool): true +``` + +## Extending the Renderer + +### Adding New Template Functions + +To add custom template functions, modify the `templateFuncs()` function in `renderer.go`: + +```go +func templateFuncs() template.FuncMap { + return template.FuncMap{ + // ... existing functions ... + "myCustomFunc": func(arg string) string { + // Custom logic + return result + }, + } +} +``` + +### Creating Format-Specific Renderers + +You can create specialized renderers for different output formats: + +```go +// JSON renderer +func NewJSONRenderer() (*Renderer, error) { + templates := map[string]string{ + "proposal": `{{define "proposal"}}{"batchOperations": [{{range .BatchOperations}}{{template "batchOperation" .}}{{end}}]}{{end}}`, + // ... more JSON templates ... + } + return NewRendererWithTemplates(templates) +} + +// Markdown renderer +func NewMarkdownRenderer() (*Renderer, error) { + templates := map[string]string{ + "proposal": `{{define "proposal"}}# Analyzed Proposal\n\n{{range .BatchOperations}}{{template "batchOperation" .}}{{end}}{{end}}`, + // ... more Markdown templates ... + } + return NewRendererWithTemplates(templates) +} +``` + +## Testing + +The renderer includes comprehensive tests for: + +- Empty proposals +- Proposals with annotations +- Complete proposals with nested structures +- Multiple batch operations +- Custom templates +- Template functions + +Run tests with: + +```bash +go test ./engine/cld/mcms/analyzer/internal -v -run TestRenderer +``` + +## Future Enhancements + +Potential improvements for the renderer: + +1. **Format-specific renderers** - Pre-built renderers for JSON, Markdown, HTML, etc. +2. **Colorization** - Support for terminal color codes in text output +3. **Truncation options** - Ability to truncate large values or limit nesting depth +4. **Template validation** - Pre-validation of custom templates before use +5. **Streaming support** - Render large proposals in chunks +6. **Template library** - Collection of reusable template snippets diff --git a/engine/cld/mcms/analyzer/internal/analyzer_context.go b/engine/cld/mcms/analyzer/internal/analyzer_context.go new file mode 100644 index 00000000..46d504df --- /dev/null +++ b/engine/cld/mcms/analyzer/internal/analyzer_context.go @@ -0,0 +1,26 @@ +package internal + +import ( + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/analyzer" +) + +// TODO: consider converting into simple struct type +var _ analyzer.AnalyzerContext = &analyzerContext{} + +type analyzerContext struct { + proposal analyzer.AnalyzedProposal + batchOperation analyzer.AnalyzedBatchOperation + call analyzer.AnalyzedCall +} + +func (ac *analyzerContext) Proposal() analyzer.AnalyzedProposal { + return ac.proposal +} + +func (ac *analyzerContext) BatchOperation() analyzer.AnalyzedBatchOperation { + return ac.batchOperation +} + +func (ac *analyzerContext) Call() analyzer.AnalyzedCall { + return ac.call +} diff --git a/engine/cld/mcms/analyzer/internal/annotations.go b/engine/cld/mcms/analyzer/internal/annotations.go new file mode 100644 index 00000000..6dd0f9e2 --- /dev/null +++ b/engine/cld/mcms/analyzer/internal/annotations.go @@ -0,0 +1,79 @@ +package internal + +import ( + "slices" + + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/analyzer" +) + +// TODO: consider converting Annotation into simple type +var _ analyzer.Annotation = &annotation{} + +type annotation struct { + name string + atype string + value any +} + +func (a annotation) Name() string { + return a.name +} + +func (a annotation) Type() string { + return a.atype +} + +func (a annotation) Value() any { + return a.value +} + +func NewAnnotation(name, atype string, value any) annotation { + return annotation{name: name, atype: atype, value: value} +} + +// --------------------------------------------------------------------- + +var _ analyzer.Annotated = &annotated{} + +type annotated struct { + annotations analyzer.Annotations +} + +func (a *annotated) AddAnnotations(annotations ...analyzer.Annotation) { + a.annotations = append(a.annotations, annotations...) +} + +func (a annotated) Annotations() analyzer.Annotations { + return a.annotations +} + +// ----- shared global annotation ----- +// consider moving to a separate "annotations" package and removing "Annotation" prefixes +const ( + AnnotationSeverityName = "cld.severity" // review: core.severity? common.severity? cld:severity? + AnnotationSeverityType = "enum" // string? reflect.Type? + + AnnotationRiskName = "cld.risk" + AnnotationRiskType = "enum" +) + +var ( + AnnotationValidSeverities = []string{"unknown", "debug", "info", "warning", "error"} // review: should we be more strict and implement proper enum types? + AnnotationValidRisks = []string{"unknown", "low", "medium", "high"} // review: should we be more strict and implement proper enum types? +) + +func SeverityAnnotation(value string) annotation { + if !slices.Contains(AnnotationValidSeverities, value) { + value = "unknown" + } + + return NewAnnotation(AnnotationSeverityName, AnnotationSeverityType, value) +} + +func RiskAnnotation(value string) annotation { + if !slices.Contains(AnnotationValidRisks, value) { + value = "unknown" + } + + return NewAnnotation(AnnotationRiskName, AnnotationRiskType, value) +} diff --git a/engine/cld/mcms/analyzer/internal/engine.go b/engine/cld/mcms/analyzer/internal/engine.go new file mode 100644 index 00000000..0818b923 --- /dev/null +++ b/engine/cld/mcms/analyzer/internal/engine.go @@ -0,0 +1,318 @@ +package internal + +import ( + "context" + "errors" + "fmt" + "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" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/analyzer" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/analyzer/internal/logger" +) + +type analyzerEngine struct { + proposalAnalyzers []analyzer.ProposalAnalyzer + batchOperationAnalyzers []analyzer.BatchOperationAnalyzer + callAnalyzers []analyzer.CallAnalyzer + parameterAnalyzers []analyzer.ParameterAnalyzer +} + +var _ analyzer.AnalyzerEngine = &analyzerEngine{} + +func NewAnalyzerEngine() *analyzerEngine { + return &analyzerEngine{} +} + +func (ae *analyzerEngine) Run( + ctx context.Context, + domain cldfdomain.Domain, + environmentName string, + proposal *mcms.TimelockProposal, +) (analyzer.AnalyzedProposal, error) { + // TODO: instantiate and embed logger in ctx (if not embedded already) + + // load environment, + 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) + } + + decodedProposal, err := ae.decodeProposal(ctx, proposal) + if err != nil { + return nil, fmt.Errorf("failed to decode timelock proposal: %w", err) + } + + actx := &analyzerContext{} + ectx := &executionContext{ + domain: domain, + environmentName: environmentName, + blockChains: env.BlockChains, + dataStore: env.DataStore, + } + + analyzedProposal, err := ae.analyzeProposal(ctx, actx, ectx, decodedProposal) + if err != nil { + return nil, fmt.Errorf("failed to analyze timelock proposal: %w", err) + } + + return analyzedProposal, errors.New("not implemented") +} + +func (ae *analyzerEngine) RegisterAnalyzer(baseAnalyzer analyzer.BaseAnalyzer) error { + switch a := baseAnalyzer.(type) { + case analyzer.ProposalAnalyzer: + ae.proposalAnalyzers = append(ae.proposalAnalyzers, a) + case analyzer.BatchOperationAnalyzer: + ae.batchOperationAnalyzers = append(ae.batchOperationAnalyzers, a) + case analyzer.CallAnalyzer: + ae.callAnalyzers = append(ae.callAnalyzers, a) + case analyzer.ParameterAnalyzer: + ae.parameterAnalyzers = append(ae.parameterAnalyzers, a) + default: + return errors.New("unknown analyzer type") + } + + return nil +} + +func (ae *analyzerEngine) RegisterFormatter( /* tbd */ ) error { + return errors.New("not implemented") +} + +func (ae *analyzerEngine) decodeProposal(ctx context.Context, proposal *mcms.TimelockProposal) (analyzer.DecodedTimelockProposal, error) { + // TODO: delegate to decoder component; try to reuse implementation from experimental/analyzer + return nil, errors.New("not implemented") +} + +func (ae *analyzerEngine) analyzeProposal( + ctx context.Context, + actx *analyzerContext, + ectx *executionContext, + decodedProposal analyzer.DecodedTimelockProposal, +) (analyzer.AnalyzedProposal, error) { + lggr := logger.FromContext(ctx) + analyzedProposal := &analyzedProposal{decodedProposal: decodedProposal} + actx.proposal = analyzedProposal + + for _, proposalAnalyzer := range ae.proposalAnalyzers { + // TODO: pre and post execution Analyze + if !proposalAnalyzer.Matches(ctx, actx, decodedProposal) { + continue + } + + annotations, err := proposalAnalyzer.Analyze(ctx, actx, ectx, decodedProposal) + if err != nil { + lggr.Warnf("proposal analyzer %q failed: %w", proposalAnalyzer.ID(), err) + continue + } + actx.proposal.AddAnnotations(annotations...) + } + + for _, batchOp := range decodedProposal.BatchOperations() { + analyzedBatchOperation, err := ae.analyzeBatchOperation(ctx, actx, ectx, batchOp) + if err != nil { + lggr.Warnf("failed to analyze batch operation: %w", err) + continue + } + analyzedProposal.batchOperations = append(analyzedProposal.batchOperations, analyzedBatchOperation) + } + + actx.proposal = nil // clear context + + return analyzedProposal, errors.New("not implemented") +} + +func (ae *analyzerEngine) analyzeBatchOperation( + ctx context.Context, + actx *analyzerContext, + ectx *executionContext, + decodedBatchOperation analyzer.DecodedBatchOperation, +) (analyzer.AnalyzedBatchOperation, error) { + lggr := logger.FromContext(ctx) + analyzedBatchOp := &analyzedBatchOperation{decodedBatchOperation: decodedBatchOperation} + actx.batchOperation = analyzedBatchOp + + for _, batchOperationAnalyzer := range ae.batchOperationAnalyzers { + // TODO: pre and post execution Analyze + if !batchOperationAnalyzer.Matches(ctx, actx, decodedBatchOperation) { + continue + } + + annotations, err := batchOperationAnalyzer.Analyze(ctx, actx, ectx, decodedBatchOperation) + if err != nil { + lggr.Warnf("batch operation analyzer %q failed: %w", batchOperationAnalyzer.ID(), err) + continue + } + analyzedBatchOp.AddAnnotations(annotations...) + } + + for _, call := range decodedBatchOperation.Calls() { + analyzedCall, err := ae.analyzeCall(ctx, actx, ectx, call) + if err != nil { + lggr.Warnf("failed to analyze call: %w", err) + continue + } + analyzedBatchOp.calls = append(analyzedBatchOp.calls, analyzedCall) + } + + actx.batchOperation = nil // clear context + + return analyzedBatchOp, errors.New("not implemented") +} + +func (ae *analyzerEngine) analyzeCall( + ctx context.Context, + actx *analyzerContext, + ectx *executionContext, + decodedCall analyzer.DecodedCall, +) (analyzer.AnalyzedCall, error) { + lggr := logger.FromContext(ctx) + analyzedCall := &analyzedCall{decodedCall: decodedCall} + actx.call = analyzedCall + + for _, callAnalyzer := range ae.callAnalyzers { + // TODO: pre and post execution Analyze + if !callAnalyzer.Matches(ctx, actx, decodedCall) { + continue + } + + annotations, err := callAnalyzer.Analyze(ctx, actx, ectx, decodedCall) + if err != nil { + lggr.Warnf("call analyzer %q failed: %w", callAnalyzer.ID(), err) + continue + } + analyzedCall.AddAnnotations(annotations...) + } + + for _, input := range decodedCall.Inputs() { + analyzedInput, err := ae.analyzeParameter(ctx, actx, ectx, input) + if err != nil { + lggr.Warnf("failed to analyze method input: %w", err) + continue + } + analyzedCall.inputs = append(analyzedCall.inputs, analyzedInput) + } + for _, output := range decodedCall.Outputs() { + analyzedOutput, err := ae.analyzeParameter(ctx, actx, ectx, output) + if err != nil { + lggr.Warnf("failed to analyze method output: %w", err) + continue + } + analyzedCall.outputs = append(analyzedCall.outputs, analyzedOutput) + } + + actx.call = nil // clear context + + return analyzedCall, nil +} + +// TODO: analyzeParameter or (analyzeInput + analyzeOutput)? +func (ae *analyzerEngine) analyzeParameter( + ctx context.Context, + actx *analyzerContext, + ectx *executionContext, + decodedParameter analyzer.DecodedParameter, +) (analyzer.AnalyzedParameter, error) { + lggr := logger.FromContext(ctx) + analyzedParam := &analyzedParameter{decodedParameter: decodedParameter} + + for _, parameterAnalyzer := range ae.parameterAnalyzers { + // TODO: pre and post execution Analyze + if !parameterAnalyzer.Matches(ctx, actx, decodedParameter) { + continue + } + + annotations, err := parameterAnalyzer.Analyze(ctx, actx, ectx, decodedParameter) + if err != nil { + lggr.Warnf("parameter analyzer %q failed: %w", parameterAnalyzer.ID(), err) + continue + } + analyzedParam.AddAnnotations(annotations...) + } + + return analyzedParam, nil +} + +// --------------------------------------------------------------------- + +var _ analyzer.AnalyzedProposal = &analyzedProposal{} + +type analyzedProposal struct { + *annotated + decodedProposal analyzer.DecodedTimelockProposal + batchOperations analyzer.AnalyzedBatchOperations +} + +func (a analyzedProposal) BatchOperations() analyzer.AnalyzedBatchOperations { + return a.batchOperations +} + +// --------------------------------------------------------------------- + +var _ analyzer.AnalyzedBatchOperation = &analyzedBatchOperation{} + +type analyzedBatchOperation struct { + *annotated + decodedBatchOperation analyzer.DecodedBatchOperation + calls analyzer.AnalyzedCalls +} + +func (a analyzedBatchOperation) Calls() analyzer.AnalyzedCalls { + return a.calls +} + +// --------------------------------------------------------------------- + +var _ analyzer.AnalyzedCall = &analyzedCall{} + +type analyzedCall struct { + *annotated + decodedCall analyzer.DecodedCall + inputs analyzer.AnalyzedParameters + outputs analyzer.AnalyzedParameters +} + +func (a analyzedCall) Name() string { + return a.decodedCall.Name() +} + +func (a analyzedCall) Inputs() analyzer.AnalyzedParameters { + return a.inputs +} + +func (a analyzedCall) Outputs() analyzer.AnalyzedParameters { + return a.outputs +} + +// --------------------------------------------------------------------- + +var _ analyzer.AnalyzedParameter = &analyzedParameter{} + +type analyzedParameter struct { + *annotated + decodedParameter analyzer.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/analyzer/internal/example_renderer_test.go b/engine/cld/mcms/analyzer/internal/example_renderer_test.go new file mode 100644 index 00000000..0665bbb9 --- /dev/null +++ b/engine/cld/mcms/analyzer/internal/example_renderer_test.go @@ -0,0 +1,72 @@ +package internal_test + +import ( + "fmt" + + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/analyzer" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/analyzer/internal" +) + +// ExampleRenderer demonstrates how to use the Renderer to display an AnalyzedProposal. +func ExampleRenderer() { + // Create a new renderer with default templates + renderer, err := internal.NewRenderer() + if err != nil { + panic(err) + } + + // Create an analyzed proposal (simplified for example) + // In practice, this would come from the analyzer engine + proposal := createExampleProposal() + + // Render the proposal to a string + output, err := renderer.Render(proposal) + if err != nil { + panic(err) + } + + // Display or write the output + fmt.Println(output) +} + +// ExampleRenderer_customTemplates demonstrates how to use custom templates. +func ExampleRenderer_customTemplates() { + // Define custom templates + customTemplates := map[string]string{ + "proposal": `{{define "proposal"}} +=== CUSTOM PROPOSAL REPORT === +Batch Operations: {{len .BatchOperations}} +{{range .BatchOperations}}{{template "batchOperation" .}}{{end}} +{{end}}`, + "batchOperation": `{{define "batchOperation"}} +Batch Operation - Calls: {{len .Calls}} +{{end}}`, + "call": `{{define "call"}}Call: {{.Name}}{{end}}`, + "parameter": `{{define "parameter"}}{{.Name}}: {{.Value}}{{end}}`, + "annotations": `{{define "annotations"}}{{end}}`, + } + + // Create renderer with custom templates + renderer, err := internal.NewRendererWithTemplates(customTemplates) + if err != nil { + panic(err) + } + + proposal := createExampleProposal() + + // Render with custom templates + output, err := renderer.Render(proposal) + if err != nil { + panic(err) + } + + fmt.Println(output) +} + +// createExampleProposal creates a sample analyzed proposal for examples. +// This is a placeholder - real implementations would use actual data. +func createExampleProposal() analyzer.AnalyzedProposal { + // Note: This is simplified for the example + // In real usage, this would be created by the analyzer engine + return nil +} diff --git a/engine/cld/mcms/analyzer/internal/execution_context.go b/engine/cld/mcms/analyzer/internal/execution_context.go new file mode 100644 index 00000000..1882e5aa --- /dev/null +++ b/engine/cld/mcms/analyzer/internal/execution_context.go @@ -0,0 +1,34 @@ +package internal + +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/analyzer" +) + +// TODO: consider converting into simple struct type +var _ analyzer.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/analyzer/internal/logger/context.go b/engine/cld/mcms/analyzer/internal/logger/context.go new file mode 100644 index 00000000..c978f60d --- /dev/null +++ b/engine/cld/mcms/analyzer/internal/logger/context.go @@ -0,0 +1,24 @@ +package logger + +import ( + "context" + + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" +) + +type contextKey string + +const loggerKey contextKey = "logger" + +func ContextWithLogger(ctx context.Context, lggr logger.Logger) context.Context { + return context.WithValue(ctx, loggerKey, lggr) +} + +func FromContext(ctx context.Context) logger.Logger { + lggr, found := ctx.Value(loggerKey).(logger.Logger) + if !found { + lggr, _ = NewLogger() + } + + return lggr +} diff --git a/engine/cld/mcms/analyzer/internal/logger/context_test.go b/engine/cld/mcms/analyzer/internal/logger/context_test.go new file mode 100644 index 00000000..25920d64 --- /dev/null +++ b/engine/cld/mcms/analyzer/internal/logger/context_test.go @@ -0,0 +1,184 @@ +package logger + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" +) + +func TestWithLogger(t *testing.T) { + t.Parallel() + + t.Run("adds logger to context", func(t *testing.T) { + t.Parallel() + ctx := context.Background() + lggr := logger.Nop() + + newCtx := ContextWithLogger(ctx, lggr) + + require.NotNil(t, newCtx) + assert.NotEqual(t, ctx, newCtx, "should return a new context") + }) + + t.Run("stores logger that can be retrieved", func(t *testing.T) { + t.Parallel() + ctx := context.Background() + lggr := logger.Nop() + + newCtx := ContextWithLogger(ctx, lggr) + retrieved := FromContext(newCtx) + + assert.Equal(t, lggr, retrieved) + }) + + t.Run("can override logger in context", func(t *testing.T) { + t.Parallel() + ctx := context.Background() + lggr1 := logger.Nop() + lggr2 := logger.Nop() + + ctx = ContextWithLogger(ctx, lggr1) + retrieved1 := FromContext(ctx) + assert.Equal(t, lggr1, retrieved1) + + ctx = ContextWithLogger(ctx, lggr2) + retrieved2 := FromContext(ctx) + assert.Equal(t, lggr2, retrieved2) + }) +} + +func TestFromContext(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + setupCtx func() context.Context + expectedLogger logger.Logger + shouldBeNop bool + shouldNotPanic bool + additionalAsserts func(t *testing.T, ctx context.Context, retrieved logger.Logger) + }{ + { + name: "retrieves logger from context", + setupCtx: func() context.Context { + lggr := logger.Nop() + return ContextWithLogger(context.Background(), lggr) + }, + expectedLogger: logger.Nop(), + shouldNotPanic: true, + }, + { + name: "returns Nop logger when no logger in context", + setupCtx: func() context.Context { + return context.Background() + }, + shouldBeNop: true, + shouldNotPanic: true, + additionalAsserts: func(t *testing.T, ctx context.Context, retrieved logger.Logger) { + t.Helper() + + // Verify it's a Nop logger by checking it doesn't panic on operations + assert.NotPanics(t, func() { + retrieved.Info("test message") + retrieved.Error("test error") + }) + }, + }, + { + name: "returns Nop logger for nil context value", + setupCtx: func() context.Context { + return context.WithValue(context.Background(), loggerKey, nil) + }, + shouldBeNop: true, + shouldNotPanic: true, + }, + { + name: "returns Nop logger for wrong type in context", + setupCtx: func() context.Context { + return context.WithValue(context.Background(), loggerKey, "not a logger") + }, + shouldBeNop: true, + shouldNotPanic: true, + additionalAsserts: func(t *testing.T, ctx context.Context, retrieved logger.Logger) { + t.Helper() + + // Should be a Nop logger since the type assertion will fail + assert.NotPanics(t, func() { + retrieved.Info("test message") + }) + }, + }, + { + name: "preserves logger through context chain", + setupCtx: func() context.Context { + lggr := logger.Nop() + ctx := ContextWithLogger(context.Background(), lggr) + // Create a child context with other values + return context.WithValue(ctx, loggerKey, "value") + }, + expectedLogger: logger.Nop(), + shouldNotPanic: true, + additionalAsserts: func(t *testing.T, ctx context.Context, retrieved logger.Logger) { + t.Helper() + + // Verify the other context value is still there + assert.Equal(t, "value", ctx.Value("key")) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx := tc.setupCtx() + retrieved := FromContext(ctx) + require.NotNil(t, retrieved) + + if tc.shouldBeNop { + // Verify it behaves like a Nop logger + assert.NotPanics(t, func() { + retrieved.Info("test") + }) + } + + if tc.shouldNotPanic { + assert.NotPanics(t, func() { + retrieved.Info("test message") + }) + } + + if tc.additionalAsserts != nil { + tc.additionalAsserts(t, ctx, retrieved) + } + }) + } +} + +func TestContextKey(t *testing.T) { + t.Parallel() + + t.Run("loggerKey is unique", func(t *testing.T) { + t.Parallel() + // Verify that our context key doesn't collide with string keys + ctx := context.Background() + lggr := logger.Nop() + + // Add logger with our typed key + ctx = ContextWithLogger(ctx, lggr) + + // Add a value with a string key of the same value + ctx = context.WithValue(ctx, loggerKey, "string value") //nolint + + retrieved := FromContext(ctx) + assert.Equal(t, lggr, retrieved) + + // the string value should also be retrievable + stringValue := ctx.Value("logger") + assert.Equal(t, "string value", stringValue) + }) +} diff --git a/engine/cld/mcms/analyzer/internal/logger/logger.go b/engine/cld/mcms/analyzer/internal/logger/logger.go new file mode 100644 index 00000000..fd3d1761 --- /dev/null +++ b/engine/cld/mcms/analyzer/internal/logger/logger.go @@ -0,0 +1,21 @@ +package logger + +import ( + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" +) + +func NewLogger() (logger.Logger, error) { + lggr, err := logger.NewWith(func(cfg *zap.Config) { + *cfg = zap.NewDevelopmentConfig() + cfg.Level.SetLevel(zapcore.DebugLevel) + cfg.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder + }) + if err != nil { + return nil, err + } + + return lggr, nil +} diff --git a/engine/cld/mcms/analyzer/internal/renderer.go b/engine/cld/mcms/analyzer/internal/renderer.go new file mode 100644 index 00000000..f2ca5724 --- /dev/null +++ b/engine/cld/mcms/analyzer/internal/renderer.go @@ -0,0 +1,139 @@ +package internal + +import ( + "bytes" + "fmt" + "io" + "strings" + "text/template" + + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/analyzer" +) + +// Renderer renders an AnalyzedProposal using Go templates. +type Renderer struct { + tmpl *template.Template +} + +// NewRenderer creates a new renderer with default templates. +func NewRenderer() (*Renderer, error) { + tmpl, err := template.New("root").Funcs(templateFuncs()).Parse("") + if err != nil { + return nil, fmt.Errorf("failed to create template: %w", err) + } + + // Parse all templates + tmpl, err = tmpl.Parse(proposalTemplate) + if err != nil { + return nil, fmt.Errorf("failed to parse proposal template: %w", err) + } + + tmpl, err = tmpl.Parse(batchOperationTemplate) + if err != nil { + return nil, fmt.Errorf("failed to parse batch operation template: %w", err) + } + + tmpl, err = tmpl.Parse(callTemplate) + if err != nil { + return nil, fmt.Errorf("failed to parse call template: %w", err) + } + + tmpl, err = tmpl.Parse(parameterTemplate) + if err != nil { + return nil, fmt.Errorf("failed to parse parameter template: %w", err) + } + + tmpl, err = tmpl.Parse(annotationsTemplate) + if err != nil { + return nil, fmt.Errorf("failed to parse annotations template: %w", err) + } + + return &Renderer{tmpl: tmpl}, nil +} + +// NewRendererWithTemplates creates a new renderer with custom templates. +func NewRendererWithTemplates(templates map[string]string) (*Renderer, error) { + tmpl, err := template.New("root").Funcs(templateFuncs()).Parse("") + if err != nil { + return nil, fmt.Errorf("failed to create template: %w", err) + } + + for name, content := range templates { + tmpl, err = tmpl.New(name).Parse(content) + if err != nil { + return nil, fmt.Errorf("failed to parse template %q: %w", name, err) + } + } + + return &Renderer{tmpl: tmpl}, nil +} + +// Render renders the analyzed proposal to a string. +func (r *Renderer) Render(proposal analyzer.AnalyzedProposal) (string, error) { + var buf bytes.Buffer + if err := r.RenderTo(&buf, proposal); err != nil { + return "", err + } + return buf.String(), nil +} + +// RenderTo renders the analyzed proposal to the given writer. +func (r *Renderer) RenderTo(w io.Writer, proposal analyzer.AnalyzedProposal) error { + if err := r.tmpl.ExecuteTemplate(w, "proposal", proposal); err != nil { + return fmt.Errorf("failed to render proposal: %w", err) + } + return nil +} + +// templateFuncs returns the template functions available in all templates. +func templateFuncs() template.FuncMap { + return template.FuncMap{ + "indent": func(spaces int, text string) string { + indent := strings.Repeat(" ", spaces) + lines := strings.Split(text, "\n") + for i, line := range lines { + if line != "" { + lines[i] = indent + line + } + } + return strings.Join(lines, "\n") + }, + "trimRight": strings.TrimRight, + "upper": strings.ToUpper, + "lower": strings.ToLower, + "title": strings.Title, + "join": func(sep string, items []string) string { + return strings.Join(items, sep) + }, + "repeat": strings.Repeat, + "hasAnnotations": func(annotated analyzer.Annotated) bool { + return annotated != nil && len(annotated.Annotations()) > 0 + }, + "severitySymbol": func(severity string) string { + switch severity { + case "error": + return "✗" + case "warning": + return "⚠" + case "info": + return "ℹ" + case "debug": + return "⚙" + default: + return "?" + } + }, + "riskSymbol": func(risk string) string { + switch risk { + case "high": + return "🔴" + case "medium": + return "🟡" + case "low": + return "🟢" + default: + return "⚪" + } + }, + } +} diff --git a/engine/cld/mcms/analyzer/internal/renderer_test.go b/engine/cld/mcms/analyzer/internal/renderer_test.go new file mode 100644 index 00000000..be6ae070 --- /dev/null +++ b/engine/cld/mcms/analyzer/internal/renderer_test.go @@ -0,0 +1,325 @@ +package internal + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/analyzer" +) + +// Mock implementations for testing +type mockDecodedParameter struct { + name string + ptype string + value any +} + +func (m mockDecodedParameter) Name() string { return m.name } +func (m mockDecodedParameter) Type() string { return m.ptype } +func (m mockDecodedParameter) Value() any { return m.value } + +type mockDecodedCall struct { + name string + inputs analyzer.DecodedParameters + outputs analyzer.DecodedParameters +} + +func (m mockDecodedCall) Name() string { return m.name } +func (m mockDecodedCall) ContractType() string { return "" } +func (m mockDecodedCall) ContractVersion() string { return "" } +func (m mockDecodedCall) To() string { return "" } +func (m mockDecodedCall) Inputs() analyzer.DecodedParameters { return m.inputs } +func (m mockDecodedCall) Outputs() analyzer.DecodedParameters { return m.outputs } +func (m mockDecodedCall) Data() []byte { return nil } +func (m mockDecodedCall) AdditionalFields() json.RawMessage { return nil } + +type mockDecodedBatchOperation struct { + calls analyzer.DecodedCalls +} + +func (m mockDecodedBatchOperation) ChainSelector() uint64 { return 0 } +func (m mockDecodedBatchOperation) Calls() analyzer.DecodedCalls { return m.calls } + +type mockDecodedTimelockProposal struct { + batchOps analyzer.DecodedBatchOperations +} + +func (m mockDecodedTimelockProposal) BatchOperations() analyzer.DecodedBatchOperations { + return m.batchOps +} + +func TestNewRenderer(t *testing.T) { + renderer, err := NewRenderer() + require.NoError(t, err) + require.NotNil(t, renderer) + require.NotNil(t, renderer.tmpl) +} + +func TestRenderer_Render_EmptyProposal(t *testing.T) { + renderer, err := NewRenderer() + require.NoError(t, err) + + proposal := &analyzedProposal{ + annotated: &annotated{}, + decodedProposal: mockDecodedTimelockProposal{}, + batchOperations: nil, + } + + output, err := renderer.Render(proposal) + require.NoError(t, err) + assert.Contains(t, output, "ANALYZED PROPOSAL") + assert.Contains(t, output, "No batch operations found") +} + +func TestRenderer_Render_ProposalWithAnnotations(t *testing.T) { + renderer, err := NewRenderer() + require.NoError(t, err) + + proposal := &analyzedProposal{ + annotated: &annotated{ + annotations: []analyzer.Annotation{ + NewAnnotation("test.annotation", "string", "test value"), + SeverityAnnotation("warning"), + RiskAnnotation("medium"), + }, + }, + decodedProposal: mockDecodedTimelockProposal{}, + batchOperations: nil, + } + + output, err := renderer.Render(proposal) + require.NoError(t, err) + assert.Contains(t, output, "ANALYZED PROPOSAL") + assert.Contains(t, output, "Annotations:") + assert.Contains(t, output, "test.annotation") + assert.Contains(t, output, "test value") + assert.Contains(t, output, "cld.severity") + assert.Contains(t, output, "warning") + assert.Contains(t, output, "cld.risk") + assert.Contains(t, output, "medium") +} + +func TestRenderer_Render_CompleteProposal(t *testing.T) { + renderer, err := NewRenderer() + require.NoError(t, err) + + // Create a complete analyzed proposal + param1 := &analyzedParameter{ + annotated: &annotated{ + annotations: []analyzer.Annotation{ + NewAnnotation("param.note", "string", "important parameter"), + }, + }, + decodedParameter: mockDecodedParameter{ + name: "recipient", + ptype: "address", + value: "0x1234567890abcdef", + }, + } + + param2 := &analyzedParameter{ + annotated: &annotated{}, + decodedParameter: mockDecodedParameter{ + name: "amount", + ptype: "uint256", + value: "1000000000000000000", + }, + } + + outputParam := &analyzedParameter{ + annotated: &annotated{}, + decodedParameter: mockDecodedParameter{ + name: "success", + ptype: "bool", + value: true, + }, + } + + call1 := &analyzedCall{ + annotated: &annotated{ + annotations: []analyzer.Annotation{ + SeverityAnnotation("info"), + }, + }, + decodedCall: mockDecodedCall{ + name: "transfer", + }, + inputs: []analyzer.AnalyzedParameter{param1, param2}, + outputs: []analyzer.AnalyzedParameter{outputParam}, + } + + batchOp := &analyzedBatchOperation{ + annotated: &annotated{ + annotations: []analyzer.Annotation{ + RiskAnnotation("low"), + }, + }, + decodedBatchOperation: mockDecodedBatchOperation{}, + calls: []analyzer.AnalyzedCall{call1}, + } + + proposal := &analyzedProposal{ + annotated: &annotated{ + annotations: []analyzer.Annotation{ + NewAnnotation("proposal.id", "string", "PROP-001"), + }, + }, + decodedProposal: mockDecodedTimelockProposal{}, + batchOperations: []analyzer.AnalyzedBatchOperation{batchOp}, + } + + output, err := renderer.Render(proposal) + require.NoError(t, err) + + // Verify proposal level + assert.Contains(t, output, "ANALYZED PROPOSAL") + assert.Contains(t, output, "proposal.id") + assert.Contains(t, output, "PROP-001") + assert.Contains(t, output, "Batch Operations: 1") + + // Verify batch operation level + assert.Contains(t, output, "BATCH OPERATION") + assert.Contains(t, output, "cld.risk") + assert.Contains(t, output, "low") + assert.Contains(t, output, "Calls: 1") + + // Verify call level + assert.Contains(t, output, "CALL: transfer") + assert.Contains(t, output, "cld.severity") + assert.Contains(t, output, "info") + assert.Contains(t, output, "Inputs (2)") + assert.Contains(t, output, "Outputs (1)") + + // Verify parameter level + assert.Contains(t, output, "recipient (address): 0x1234567890abcdef") + assert.Contains(t, output, "amount (uint256): 1000000000000000000") + assert.Contains(t, output, "success (bool): true") + assert.Contains(t, output, "param.note") + assert.Contains(t, output, "important parameter") +} + +func TestRenderer_Render_MultipleBatchOperations(t *testing.T) { + renderer, err := NewRenderer() + require.NoError(t, err) + + call1 := &analyzedCall{ + annotated: &annotated{}, + decodedCall: mockDecodedCall{name: "setConfig"}, + inputs: nil, + outputs: nil, + } + + call2 := &analyzedCall{ + annotated: &annotated{}, + decodedCall: mockDecodedCall{name: "unpause"}, + inputs: nil, + outputs: nil, + } + + batchOp1 := &analyzedBatchOperation{ + annotated: &annotated{}, + decodedBatchOperation: mockDecodedBatchOperation{}, + calls: []analyzer.AnalyzedCall{call1}, + } + + batchOp2 := &analyzedBatchOperation{ + annotated: &annotated{}, + decodedBatchOperation: mockDecodedBatchOperation{}, + calls: []analyzer.AnalyzedCall{call2}, + } + + proposal := &analyzedProposal{ + annotated: &annotated{}, + decodedProposal: mockDecodedTimelockProposal{}, + batchOperations: []analyzer.AnalyzedBatchOperation{batchOp1, batchOp2}, + } + + output, err := renderer.Render(proposal) + require.NoError(t, err) + + assert.Contains(t, output, "Batch Operations: 2") + assert.Contains(t, output, "CALL: setConfig") + assert.Contains(t, output, "CALL: unpause") + + // Verify both batch operations are rendered + batchOpCount := strings.Count(output, "BATCH OPERATION") + assert.Equal(t, 2, batchOpCount) +} + +func TestNewRendererWithTemplates_CustomTemplate(t *testing.T) { + customTemplates := map[string]string{ + "proposal": `{{define "proposal"}}CUSTOM PROPOSAL: {{len .BatchOperations}} batch ops{{end}}`, + "batchOperation": `{{define "batchOperation"}}CUSTOM BATCH OP{{end}}`, + "call": `{{define "call"}}CUSTOM CALL: {{.Name}}{{end}}`, + "parameter": `{{define "parameter"}}{{.Name}}={{.Value}}{{end}}`, + "annotations": `{{define "annotations"}}ANNOTATIONS{{end}}`, + } + + renderer, err := NewRendererWithTemplates(customTemplates) + require.NoError(t, err) + + proposal := &analyzedProposal{ + annotated: &annotated{}, + decodedProposal: mockDecodedTimelockProposal{}, + batchOperations: nil, + } + + output, err := renderer.Render(proposal) + require.NoError(t, err) + assert.Contains(t, output, "CUSTOM PROPOSAL: 0 batch ops") +} + +func TestTemplateFuncs_Indent(t *testing.T) { + funcs := templateFuncs() + indentFunc := funcs["indent"].(func(int, string) string) + + input := "line1\nline2\nline3" + expected := " line1\n line2\n line3" + result := indentFunc(2, input) + assert.Equal(t, expected, result) +} + +func TestTemplateFuncs_HasAnnotations(t *testing.T) { + funcs := templateFuncs() + hasAnnotationsFunc := funcs["hasAnnotations"].(func(analyzer.Annotated) bool) + + // Test with nil + assert.False(t, hasAnnotationsFunc(nil)) + + // Test with no annotations + annotated1 := &annotated{annotations: nil} + assert.False(t, hasAnnotationsFunc(annotated1)) + + // Test with annotations + annotated2 := &annotated{ + annotations: []analyzer.Annotation{ + NewAnnotation("test", "string", "value"), + }, + } + assert.True(t, hasAnnotationsFunc(annotated2)) +} + +func TestTemplateFuncs_SeverityAndRiskSymbols(t *testing.T) { + funcs := templateFuncs() + severitySymbol := funcs["severitySymbol"].(func(string) string) + riskSymbol := funcs["riskSymbol"].(func(string) string) + + // Test severity symbols + assert.Equal(t, "✗", severitySymbol("error")) + assert.Equal(t, "⚠", severitySymbol("warning")) + assert.Equal(t, "ℹ", severitySymbol("info")) + assert.Equal(t, "⚙", severitySymbol("debug")) + assert.Equal(t, "?", severitySymbol("unknown")) + assert.Equal(t, "?", severitySymbol("invalid")) + + // Test risk symbols + assert.Equal(t, "🔴", riskSymbol("high")) + assert.Equal(t, "🟡", riskSymbol("medium")) + assert.Equal(t, "🟢", riskSymbol("low")) + assert.Equal(t, "⚪", riskSymbol("unknown")) + assert.Equal(t, "⚪", riskSymbol("invalid")) +} diff --git a/engine/cld/mcms/analyzer/internal/templates.go b/engine/cld/mcms/analyzer/internal/templates.go new file mode 100644 index 00000000..c1088383 --- /dev/null +++ b/engine/cld/mcms/analyzer/internal/templates.go @@ -0,0 +1,94 @@ +package internal + +// proposalTemplate is the main template for rendering an AnalyzedProposal. +const proposalTemplate = `{{define "proposal" -}} +╔═══════════════════════════════════════════════════════════════════════════════ +║ ANALYZED PROPOSAL +╚═══════════════════════════════════════════════════════════════════════════════ +{{if hasAnnotations . -}} +{{template "annotations" .}} +{{end -}} +{{- $batchOps := .BatchOperations -}} +{{if $batchOps -}} + +Batch Operations: {{len $batchOps}} +{{range $i, $batchOp := $batchOps -}} +{{template "batchOperation" $batchOp}} +{{end -}} +{{else -}} + +No batch operations found. +{{end -}} +{{end}}` + +// batchOperationTemplate is the template for rendering an AnalyzedBatchOperation. +const batchOperationTemplate = `{{define "batchOperation" -}} + +───────────────────────────────────────────────────────────────────────────── + BATCH OPERATION +───────────────────────────────────────────────────────────────────────────── +{{if hasAnnotations . -}} +{{template "annotations" .}} +{{end -}} +{{- $calls := .Calls -}} +{{if $calls -}} + +Calls: {{len $calls}} +{{range $i, $call := $calls -}} +{{template "call" $call}} +{{end -}} +{{else -}} + +No calls found. +{{end -}} +{{end}}` + +// callTemplate is the template for rendering an AnalyzedCall. +const callTemplate = `{{define "call" -}} + + ┌─────────────────────────────────────────────────────────────────────────── + │ CALL: {{.Name}} + └─────────────────────────────────────────────────────────────────────────── +{{if hasAnnotations . -}} + {{template "annotations" .}} +{{end -}} +{{- $inputs := .Inputs -}} +{{if $inputs -}} + + Inputs ({{len $inputs}}): +{{range $i, $input := $inputs -}} + {{template "parameter" $input}} +{{end -}} +{{else -}} + + No inputs. +{{end -}} +{{- $outputs := .Outputs -}} +{{if $outputs -}} + + Outputs ({{len $outputs}}): +{{range $i, $output := $outputs -}} + {{template "parameter" $output}} +{{end -}} +{{else -}} + + No outputs. +{{end -}} +{{end}}` + +// parameterTemplate is the template for rendering an AnalyzedParameter. +const parameterTemplate = `{{define "parameter" -}} +• {{.Name}} ({{.Type}}): {{.Value}} +{{- if hasAnnotations .}} + {{template "annotations" .}} +{{- end}} +{{- end}}` + +// annotationsTemplate is the template for rendering annotations. +const annotationsTemplate = `{{define "annotations" -}} +{{$annotations := .Annotations -}} +Annotations: +{{range $i, $annotation := $annotations -}} + - {{$annotation.Name}} [{{$annotation.Type}}]: {{$annotation.Value}} +{{end -}} +{{end}}` diff --git a/engine/cld/mcms/analyzer/types.go b/engine/cld/mcms/analyzer/types.go new file mode 100644 index 00000000..908e9f3b --- /dev/null +++ b/engine/cld/mcms/analyzer/types.go @@ -0,0 +1,150 @@ +package analyzer + +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" +) + +// ----- annotation ----- + +type Annotation interface { + Name() string + Type() string // TODO: replace with enum + Value() any +} + +type Annotations []Annotation + +type Annotated interface { + AddAnnotations(annotations ...Annotation) + Annotations() 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? + ContractType() string + ContractVersion() string + 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 + Type() string // reflect.Type? + Value() any // reflect.Value? +} + +// ----- 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 +} + +type ExecutionContext interface { + Domain() cldfdomain.Domain + EnvironmentName() string + BlockChains() chain.BlockChains + DataStore() datastore.DataStore + // Environment() Environment +} + +// ----- analyzers ----- + +type BaseAnalyzer interface { + ID() string + Dependencies() []BaseAnalyzer +} + +type ProposalAnalyzer interface { + BaseAnalyzer + Matches(ctx context.Context, actx AnalyzerContext, proposal DecodedTimelockProposal) bool // TODO: is there a better name? AppliesTo? ShouldAnalyze? + Analyze(ctx context.Context, actx AnalyzerContext, ectx ExecutionContext, proposal DecodedTimelockProposal) (Annotations, error) +} + +type BatchOperationAnalyzer interface { + BaseAnalyzer + Matches(ctx context.Context, actx AnalyzerContext, operation DecodedBatchOperation) bool + Analyze(ctx context.Context, actx AnalyzerContext, ectx ExecutionContext, operation DecodedBatchOperation) (Annotations, error) +} + +type CallAnalyzer interface { + BaseAnalyzer + Matches(ctx context.Context, actx AnalyzerContext, call DecodedCall) bool + Analyze(ctx context.Context, actx AnalyzerContext, ectx ExecutionContext, call DecodedCall) (Annotations, error) +} + +type ParameterAnalyzer interface { + BaseAnalyzer + Matches(ctx context.Context, actx AnalyzerContext, param DecodedParameter) bool + Analyze(ctx context.Context, actx AnalyzerContext, ectx ExecutionContext, param DecodedParameter) (Annotations, error) +} + +// ----- engine/runtime ----- + +type AnalyzerEngine interface { // review: rename to AnalyzerRuntime? AnalyzerService? ...? + Run(ctx context.Context, domain cldfdomain.Domain, environmentName string, proposal *mcms.TimelockProposal) (AnalyzedProposal, error) + + RegisterAnalyzer(analyzer BaseAnalyzer) error // do we need to add a method for each type? like RegisterProposalAnalyzer? + + RegisterFormatter( /* tbd */ ) error +}