Skip to content

Commit 30b67b6

Browse files
[OPT-430] feat(mcms): add analyze-proposal-v2 command (#800)
Adds a new `analyze-proposal-v2` MCMS command that uses the new proposal analysis engine and renderer flow for timelock proposals, replacing legacy analysis behavior over time. Also introduces extension hooks for registering custom analyzers through both modular command config (`commands.MCMS`) and legacy `BuildMCMSv2Cmd` function options, so domain teams can plug in their own analysis logic. Jira: https://smartcontract-it.atlassian.net/browse/OPT-430
1 parent 7717632 commit 30b67b6

File tree

10 files changed

+377
-4
lines changed

10 files changed

+377
-4
lines changed

.changeset/loud-llamas-exist.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"chainlink-deployments-framework": minor
3+
---
4+
5+
feat(mcms): new analyze-proposal-v2 command

engine/cld/commands/commands.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import (
3535
"github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/commands/mcms"
3636
"github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/commands/state"
3737
"github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain"
38+
proposalanalyzer "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/analyzer"
3839
"github.com/smartcontractkit/chainlink-deployments-framework/experimental/analyzer"
3940
"github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger"
4041
)
@@ -88,6 +89,9 @@ type MCMSConfig struct {
8889
// ProposalContextProvider creates proposal context for analysis.
8990
// This is domain-specific and must be provided by the user.
9091
ProposalContextProvider analyzer.ProposalContextProvider
92+
93+
// ProposalAnalyzers are custom analyzers registered into analyze-proposal-v2.
94+
ProposalAnalyzers []proposalanalyzer.BaseAnalyzer
9195
}
9296

9397
// MCMS creates the mcms command group for proposal analysis and conversion.
@@ -96,5 +100,6 @@ func (c *Commands) MCMS(dom domain.Domain, cfg MCMSConfig) (*cobra.Command, erro
96100
Logger: c.lggr,
97101
Domain: dom,
98102
ProposalContextProvider: cfg.ProposalContextProvider,
103+
ProposalAnalyzers: cfg.ProposalAnalyzers,
99104
})
100105
}

engine/cld/commands/commands_test.go

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import (
99

1010
fdeployment "github.com/smartcontractkit/chainlink-deployments-framework/deployment"
1111
"github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain"
12+
proposalanalyzer "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/analyzer"
13+
experimentalanalyzer "github.com/smartcontractkit/chainlink-deployments-framework/experimental/analyzer"
1214
"github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger"
1315
)
1416

@@ -66,5 +68,64 @@ func TestCommands_State_MissingViewState(t *testing.T) {
6668

6769
require.Error(t, err)
6870
assert.Nil(t, cmd)
69-
assert.Contains(t, err.Error(), "ViewState")
71+
require.ErrorContains(t, err, "ViewState")
72+
}
73+
74+
func TestCommands_MCMS_Success(t *testing.T) {
75+
t.Parallel()
76+
77+
lggr := logger.Nop()
78+
cmds := New(lggr)
79+
dom := domain.NewDomain(t.TempDir(), "testdomain")
80+
proposalCtxProvider := func(_ fdeployment.Environment) (experimentalanalyzer.ProposalContext, error) {
81+
return nil, nil //nolint:nilnil
82+
}
83+
84+
cmd, err := cmds.MCMS(dom, MCMSConfig{
85+
ProposalContextProvider: proposalCtxProvider,
86+
})
87+
88+
require.NoError(t, err)
89+
require.NotNil(t, cmd)
90+
assert.Equal(t, "mcms", cmd.Use)
91+
92+
// Ensure v2 command is exposed through the commands factory path.
93+
subCmd, _, findErr := cmd.Find([]string{"analyze-proposal-v2"})
94+
require.NoError(t, findErr)
95+
require.NotNil(t, subCmd)
96+
assert.Equal(t, "analyze-proposal-v2", subCmd.Use)
97+
}
98+
99+
func TestCommands_MCMS_ForwardsProposalAnalyzers(t *testing.T) {
100+
t.Parallel()
101+
102+
lggr := logger.Nop()
103+
cmds := New(lggr)
104+
dom := domain.NewDomain(t.TempDir(), "testdomain")
105+
proposalCtxProvider := func(_ fdeployment.Environment) (experimentalanalyzer.ProposalContext, error) {
106+
return nil, nil //nolint:nilnil
107+
}
108+
109+
cmd, err := cmds.MCMS(dom, MCMSConfig{
110+
ProposalContextProvider: proposalCtxProvider,
111+
ProposalAnalyzers: []proposalanalyzer.BaseAnalyzer{nil},
112+
})
113+
114+
require.Error(t, err)
115+
assert.Nil(t, cmd)
116+
require.ErrorContains(t, err, "ProposalAnalyzers[0] cannot be nil")
117+
}
118+
119+
func TestCommands_MCMS_MissingProposalContextProvider(t *testing.T) {
120+
t.Parallel()
121+
122+
lggr := logger.Nop()
123+
cmds := New(lggr)
124+
dom := domain.NewDomain(t.TempDir(), "testdomain")
125+
126+
cmd, err := cmds.MCMS(dom, MCMSConfig{})
127+
128+
require.Error(t, err)
129+
assert.Nil(t, cmd)
130+
require.ErrorContains(t, err, "missing required fields: ProposalContextProvider")
70131
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
package mcms
2+
3+
import (
4+
"bytes"
5+
"errors"
6+
"fmt"
7+
"os"
8+
"strings"
9+
10+
"github.com/spf13/cobra"
11+
12+
"github.com/smartcontractkit/mcms/types"
13+
14+
"github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/commands/flags"
15+
"github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/commands/text"
16+
"github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis"
17+
proposalanalysisanalyzer "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/analyzer"
18+
analysisdecoder "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/decoder"
19+
analysisrenderer "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/renderer"
20+
)
21+
22+
var (
23+
analyzeProposalV2Short = "Analyze proposal using the v2 analysis framework"
24+
25+
analyzeProposalV2Long = text.LongDesc(`
26+
Analyzes a proposal and renders a human-readable report using the new
27+
proposal analysis framework.
28+
29+
This command only supports timelock proposals and is intended to replace
30+
the legacy analyze-proposal command.
31+
`)
32+
33+
analyzeProposalV2Example = text.Examples(`
34+
# Analyze a proposal and print to stdout
35+
myapp mcms analyze-proposal-v2 -e staging -p ./proposal.json
36+
37+
# Analyze and save to a file in markdown format
38+
myapp mcms analyze-proposal-v2 -e staging -p ./proposal.json -o analysis.md
39+
`)
40+
)
41+
42+
type analyzeProposalV2Flags struct {
43+
environment string
44+
proposalPath string
45+
chainSelector uint64
46+
output string
47+
format string
48+
}
49+
50+
func newAnalyzeProposalV2Cmd(cfg Config) *cobra.Command {
51+
cmd := &cobra.Command{
52+
Use: "analyze-proposal-v2",
53+
Short: analyzeProposalV2Short,
54+
Long: analyzeProposalV2Long,
55+
Example: analyzeProposalV2Example,
56+
RunE: func(cmd *cobra.Command, _ []string) error {
57+
f := analyzeProposalV2Flags{
58+
environment: flags.MustString(cmd.Flags().GetString("environment")),
59+
proposalPath: flags.MustString(cmd.Flags().GetString("proposal")),
60+
chainSelector: flags.MustUint64(cmd.Flags().GetUint64("selector")),
61+
output: flags.MustString(cmd.Flags().GetString("output")),
62+
format: flags.MustString(cmd.Flags().GetString("format")),
63+
}
64+
65+
return runAnalyzeProposalV2(cmd, cfg, f)
66+
},
67+
}
68+
69+
flags.Environment(cmd)
70+
flags.Proposal(cmd)
71+
flags.ChainSelector(cmd, false)
72+
73+
cmd.Flags().StringP("output", "o", "", "Output file to write analysis result")
74+
cmd.Flags().String("format", analysisrenderer.IDMarkdown, "Output format: markdown")
75+
76+
return cmd
77+
}
78+
79+
func runAnalyzeProposalV2(cmd *cobra.Command, cfg Config, f analyzeProposalV2Flags) error {
80+
ctx := cmd.Context()
81+
deps := cfg.deps()
82+
83+
proposalCfg, err := LoadProposalConfig(ctx, cfg.Logger, cfg.Domain, deps, cfg.ProposalContextProvider,
84+
ProposalFlags{
85+
ProposalPath: f.proposalPath,
86+
ProposalKind: string(types.KindTimelockProposal),
87+
Environment: f.environment,
88+
ChainSelector: f.chainSelector,
89+
},
90+
acceptExpiredProposal,
91+
)
92+
if err != nil {
93+
return fmt.Errorf("error creating config: %w", err)
94+
}
95+
96+
if proposalCfg.TimelockProposal == nil {
97+
return errors.New("expected proposal be a timelock proposal")
98+
}
99+
100+
rendererID, err := normalizeRendererFormat(f.format)
101+
if err != nil {
102+
return err
103+
}
104+
105+
markdownRenderer, err := analysisrenderer.NewMarkdownRenderer()
106+
if err != nil {
107+
return fmt.Errorf("create markdown renderer: %w", err)
108+
}
109+
110+
engine := proposalanalysis.NewAnalyzerEngine()
111+
if registerErr := engine.RegisterRenderer(markdownRenderer); registerErr != nil {
112+
return fmt.Errorf("register renderer: %w", registerErr)
113+
}
114+
if analyzerErr := registerProposalAnalyzers(engine, cfg.ProposalAnalyzers); analyzerErr != nil {
115+
return analyzerErr
116+
}
117+
118+
var evmABIMappings map[string]string
119+
var solanaDecoders map[string]analysisdecoder.DecodeInstructionFn
120+
if proposalCfg.ProposalCtx != nil {
121+
if proposalCfg.ProposalCtx.GetEVMRegistry() != nil {
122+
evmABIMappings = proposalCfg.ProposalCtx.GetEVMRegistry().GetAllABIs()
123+
}
124+
if proposalCfg.ProposalCtx.GetSolanaDecoderRegistry() != nil {
125+
solanaDecoders = proposalCfg.ProposalCtx.GetSolanaDecoderRegistry().Decoders()
126+
}
127+
}
128+
129+
analyzedProposal, err := engine.Run(ctx, proposalanalysis.RunRequest{
130+
Domain: cfg.Domain,
131+
Environment: &proposalCfg.Env,
132+
DecoderConfig: analysisdecoder.Config{
133+
EVMABIMappings: evmABIMappings,
134+
SolanaDecoders: solanaDecoders,
135+
},
136+
}, proposalCfg.TimelockProposal)
137+
if err != nil {
138+
return fmt.Errorf("run analysis engine: %w", err)
139+
}
140+
141+
var out bytes.Buffer
142+
if err := engine.RenderTo(&out, rendererID, analysisrenderer.RenderRequest{
143+
Domain: cfg.Domain.Key(),
144+
EnvironmentName: proposalCfg.EnvStr,
145+
}, analyzedProposal); err != nil {
146+
return fmt.Errorf("render analysis output: %w", err)
147+
}
148+
149+
if f.output == "" {
150+
cmd.Println(out.String())
151+
return nil
152+
}
153+
154+
if err := os.WriteFile(f.output, out.Bytes(), 0o600); err != nil {
155+
return err
156+
}
157+
158+
return nil
159+
}
160+
161+
func registerProposalAnalyzers(engine proposalanalysis.AnalyzerEngine, analyzers []proposalanalysisanalyzer.BaseAnalyzer) error {
162+
for _, analyzer := range analyzers {
163+
if err := engine.RegisterAnalyzer(analyzer); err != nil {
164+
return fmt.Errorf("register proposal analyzer %q: %w", analyzer.ID(), err)
165+
}
166+
}
167+
168+
return nil
169+
}
170+
171+
func normalizeRendererFormat(format string) (string, error) {
172+
switch strings.ToLower(strings.TrimSpace(format)) {
173+
case "", analysisrenderer.IDMarkdown, "md":
174+
return analysisrenderer.IDMarkdown, nil
175+
default:
176+
return "", fmt.Errorf("unknown format %q: only markdown is supported for analyze-proposal-v2", format)
177+
}
178+
}

engine/cld/commands/mcms/command.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ package mcms
22

33
import (
44
"errors"
5+
"fmt"
56
"strings"
67

78
"github.com/spf13/cobra"
89

910
"github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/commands/text"
1011
"github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain"
12+
proposalanalyzer "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/analyzer"
1113
"github.com/smartcontractkit/chainlink-deployments-framework/experimental/analyzer"
1214
"github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger"
1315
)
@@ -34,6 +36,9 @@ type Config struct {
3436
// ProposalContextProvider creates proposal context for analysis. Required.
3537
ProposalContextProvider analyzer.ProposalContextProvider
3638

39+
// ProposalAnalyzers are custom analyzers registered into the v2 proposal analysis engine.
40+
ProposalAnalyzers []proposalanalyzer.BaseAnalyzer
41+
3742
// Deps holds optional dependencies that can be overridden.
3843
// If fields are nil, production defaults are used.
3944
Deps Deps
@@ -56,6 +61,9 @@ func (c Config) Validate() error {
5661
if len(missing) > 0 {
5762
return errors.New("mcms.Config: missing required fields: " + strings.Join(missing, ", "))
5863
}
64+
if err := validateProposalAnalyzers(c.ProposalAnalyzers); err != nil {
65+
return err
66+
}
5967

6068
return nil
6169
}
@@ -83,8 +91,19 @@ func NewCommand(cfg Config) (*cobra.Command, error) {
8391

8492
cmd.AddCommand(newErrorDecodeCmd(cfg))
8593
cmd.AddCommand(newAnalyzeProposalCmd(cfg))
94+
cmd.AddCommand(newAnalyzeProposalV2Cmd(cfg))
8695
cmd.AddCommand(newConvertUpfCmd(cfg))
8796
cmd.AddCommand(newExecuteForkCmd(cfg))
8897

8998
return cmd, nil
9099
}
100+
101+
func validateProposalAnalyzers(analyzers []proposalanalyzer.BaseAnalyzer) error {
102+
for i, analyzer := range analyzers {
103+
if analyzer == nil {
104+
return fmt.Errorf("mcms.Config: ProposalAnalyzers[%d] cannot be nil", i)
105+
}
106+
}
107+
108+
return nil
109+
}

0 commit comments

Comments
 (0)