diff --git a/engine/cld/mcms/analyzer/internal/ENHANCEMENT_SUMMARY.md b/engine/cld/mcms/analyzer/internal/ENHANCEMENT_SUMMARY.md new file mode 100644 index 00000000..2f03724b --- /dev/null +++ b/engine/cld/mcms/analyzer/internal/ENHANCEMENT_SUMMARY.md @@ -0,0 +1,286 @@ +# Renderer Enhancement Summary + +## Overview + +The renderer implementation has been successfully enhanced with the following major improvements: + +## 1. File-Based Templates + +Templates are now organized in format-specific directories and embedded in the binary: + +``` +internal/templates/ + ├── text/ + │ ├── proposal.tmpl + │ ├── batchOperation.tmpl + │ ├── call.tmpl + │ └── parameter.tmpl + └── html/ + ├── proposal.tmpl + ├── batchOperation.tmpl + ├── call.tmpl + └── parameter.tmpl +``` + +### Features: +- **Embedded templates** using Go's `embed` package for easy distribution +- **External template loading** from filesystem directories for customization +- **Format-specific templates** optimized for each output type + +## 2. Multiple Format Support + +The renderer now supports multiple output formats: + +### Formats Implemented: +- **Text (FormatText)**: Plain text with ASCII art and Unicode symbols +- **HTML (FormatHTML)**: Rich HTML with CSS styling and semantic markup + +### Format-Specific Features: + +#### Text Format +- ASCII art borders and dividers +- Unicode symbols for severity (✗, ⚠, ℹ, ⚙) and risk (🔴, 🟡, 🟢) +- Emoji support for custom decorations +- Compact, readable layout for CLI output + +#### HTML Format +- Complete HTML document with embedded CSS +- Color-coded severity and risk levels +- Responsive layout with proper semantic HTML +- CSS classes for easy styling customization +- Icon/emoji support integrated into the design + +### Usage: + +```go +// Text format (default) +renderer, _ := internal.NewRenderer() + +// HTML format +htmlRenderer, _ := internal.NewRendererWithFormat(internal.FormatHTML) + +// Render to file +htmlRenderer.RenderToFile("proposal.html", analyzedProposal) +``` + +## 3. Annotation-Driven Rendering + +**Key Change**: Annotations now control rendering behavior instead of being displayed as separate entities. + +### Rendering Annotations + +#### `render.important` +Marks entities as important with visual highlighting: +- **Text**: Adds ⭐ symbol +- **HTML**: Wraps in `` with background color + +```go +param.AddAnnotations(internal.ImportantAnnotation(true)) +``` + +#### `render.emoji` +Adds emoji decoration to entities: +```go +param.AddAnnotations(internal.EmojiAnnotation("💰")) +// Output: 💰 amount (uint256): 1000 +``` + +#### `render.formatter` +Applies custom value formatting: + +**Ethereum Address Formatter**: +```go +param.AddAnnotations(internal.FormatterAnnotation("ethereum.address")) +// Input: "1234567890abcdef..." +// Output: "0x1234567890abcdef..." +``` + +**Ethereum Uint256 Formatter**: +```go +param.AddAnnotations(internal.FormatterAnnotation("ethereum.uint256")) +// Input: "1000000000" +// Output: "1,000,000,000" +``` + +**Hex Formatter**: +```go +param.AddAnnotations(internal.FormatterAnnotation("hex")) +// Formats values as 0x... hex strings +``` + +**Truncate Formatter**: +```go +param.AddAnnotations(internal.FormatterAnnotation("truncate:20")) +// Truncates strings to 20 characters with "..." suffix +``` + +#### `render.style` +Provides styling hints (HTML format): +```go +call.AddAnnotations(internal.StyleAnnotation("danger")) +``` + +#### `render.template` +Specifies custom template to use: +```go +call.AddAnnotations(internal.TemplateAnnotation("customCall")) +``` + +#### `render.hide` +Hides entities from output: +```go +param.AddAnnotations(internal.HideAnnotation(true)) +``` + +#### `render.tooltip` +Adds tooltip text (HTML format): +```go +param.AddAnnotations(internal.TooltipAnnotation("This parameter controls...")) +``` + +### Built-in Analysis Annotations + +#### `cld.severity` +Displays severity with symbols: +- `error`: ✗ (red in HTML) +- `warning`: ⚠ (orange in HTML) +- `info`: ℹ (blue in HTML) +- `debug`: ⚙ (gray in HTML) + +```go +call.AddAnnotations(internal.SeverityAnnotation("warning")) +``` + +#### `cld.risk` +Displays risk with colored symbols: +- `high`: 🔴 +- `medium`: 🟡 +- `low`: 🟢 + +```go +batchOp.AddAnnotations(internal.RiskAnnotation("high")) +``` + +## New Template Functions + +Templates have access to annotation-aware functions: + +### Annotation Functions +- `getAnnotation .Entity "name"` - Retrieves annotation object +- `getAnnotationValue .Entity "name"` - Gets annotation value directly +- `hasAnnotation .Entity "name"` - Checks if annotation exists +- `hasAnnotations .Entity` - Checks if entity has any annotations + +### Formatting Functions +- `formatValue .Param "formatter"` - Applies custom formatter +- `severitySymbol "level"` - Returns severity symbol +- `riskSymbol "level"` - Returns risk symbol + +### String Functions +- `indent spaces text` - Indents text +- `upper`, `lower`, `title` - Case conversions +- `trimRight text` - Trim whitespace +- `join sep items` - Join strings +- `repeat count text` - Repeat text + +## Template Examples + +### Text Template with Annotations + +```go +{{define "call"}} +┌─ CALL: {{.Name}}{{if getAnnotation . "render.important"}} ⭐{{end}} +{{- $severity := getAnnotationValue . "cld.severity"}} +{{- if $severity}} +│ Severity: {{severitySymbol $severity}} {{$severity}} +{{- end}} +└───────────────────────── +{{end}} +``` + +### HTML Template with Annotations + +```html +{{define "parameter"}} +{{- $important := getAnnotation . "render.important"}} +{{- $emoji := getAnnotationValue . "render.emoji"}} +{{- $formatter := getAnnotationValue . "render.formatter"}} +{{- if $important}}{{end}} +{{- if $emoji}}{{$emoji}} {{end}} +{{.Name}} ({{.Type}}): +{{if $formatter}}{{formatValue . $formatter}}{{else}}{{.Value}}{{end}} +{{- if $important}}{{end}} +{{end}} +``` + +## Files Created/Modified + +### New Files: +- `templates/text/*.tmpl` - Text format templates +- `templates/html/*.tmpl` - HTML format templates +- `render_annotations.go` - Rendering annotation constants and helpers +- `test_mocks.go` - Shared test mocks +- `renderer_enhanced_test.go` - Tests for new functionality +- `RENDERER_ENHANCED.md` - Comprehensive documentation + +### Modified Files: +- `renderer.go` - Enhanced with multi-format support, file-based templates, and annotation functions +- `templates.go` - Marked as deprecated +- `renderer_test.go` - Updated for new API, skipped deprecated tests +- `example_renderer_test.go` - Updated with new examples + +## API Changes + +### Backward Compatible: +```go +// Still works - defaults to text format +renderer, _ := internal.NewRenderer() +``` + +### New APIs: +```go +// Create with specific format +renderer, _ := internal.NewRendererWithFormat(internal.FormatHTML) + +// Load from custom directory +renderer, _ := internal.NewRendererFromDirectory(internal.FormatText, "/path/to/templates") + +// Use in-memory templates with format +renderer, _ := internal.NewRendererWithTemplates(internal.FormatText, templates) + +// Render to file +renderer.RenderToFile("output.html", proposal) + +// Get renderer format +fmt.Println(renderer.Format()) // "html" or "text" +``` + +## Testing + +All tests pass successfully: +- Text format rendering +- HTML format rendering +- Annotation-driven rendering +- Custom formatters (Ethereum address, uint256, hex, truncate) +- Template functions (getAnnotation, hasAnnotation, etc.) +- Custom template support + +## Benefits + +1. **Flexibility**: Easy to add new formats by creating templates +2. **Customization**: Templates can be overridden without code changes +3. **Separation of Concerns**: Annotations encode analysis results, renderer interprets them +4. **Better UX**: Format-specific rendering optimizes for different use cases +5. **Maintainability**: Template-based rendering is easier to modify than code +6. **Extensibility**: New annotations and formatters can be added without breaking changes + +## Future Enhancement Opportunities + +1. **Markdown format** - For documentation generation +2. **JSON format** - For machine-readable output +3. **Additional formatters** - Date/time, currency, percentages, etc. +4. **Template inheritance** - Share common elements across formats +5. **Syntax highlighting** - For code snippets in HTML +6. **Interactive HTML** - Collapsible sections, search, filtering +7. **PDF generation** - Using HTML as intermediate format +8. **Excel export** - For tabular data analysis diff --git a/engine/cld/mcms/analyzer/internal/QUICK_REFERENCE.md b/engine/cld/mcms/analyzer/internal/QUICK_REFERENCE.md new file mode 100644 index 00000000..e6c69a71 --- /dev/null +++ b/engine/cld/mcms/analyzer/internal/QUICK_REFERENCE.md @@ -0,0 +1,128 @@ +# Quick Reference: Enhanced Renderer + +## Creating Renderers + +```go +// Text format (default) +renderer, err := internal.NewRenderer() + +// HTML format +htmlRenderer, err := internal.NewRendererWithFormat(internal.FormatHTML) + +// Custom templates +customRenderer, err := internal.NewRendererWithTemplates(internal.FormatText, templates) + +// Load from directory +fsRenderer, err := internal.NewRendererFromDirectory(internal.FormatHTML, "/path/to/templates") +``` + +## Rendering + +```go +// To string +output, err := renderer.Render(analyzedProposal) + +// To writer +err := renderer.RenderTo(writer, analyzedProposal) + +// To file +err := renderer.RenderToFile("proposal.html", analyzedProposal) +``` + +## Rendering Annotations + +```go +// Mark as important +entity.AddAnnotations(internal.ImportantAnnotation(true)) + +// Add emoji +entity.AddAnnotations(internal.EmojiAnnotation("💰")) + +// Format value +param.AddAnnotations(internal.FormatterAnnotation("ethereum.address")) +param.AddAnnotations(internal.FormatterAnnotation("ethereum.uint256")) +param.AddAnnotations(internal.FormatterAnnotation("hex")) +param.AddAnnotations(internal.FormatterAnnotation("truncate:20")) + +// Style (HTML) +entity.AddAnnotations(internal.StyleAnnotation("danger")) + +// Custom template +entity.AddAnnotations(internal.TemplateAnnotation("customTemplate")) + +// Hide from output +entity.AddAnnotations(internal.HideAnnotation(true)) + +// Tooltip (HTML) +entity.AddAnnotations(internal.TooltipAnnotation("Description...")) +``` + +## Analysis Annotations (Auto-rendered) + +```go +// Severity with symbols +entity.AddAnnotations(internal.SeverityAnnotation("error")) // ✗ +entity.AddAnnotations(internal.SeverityAnnotation("warning")) // ⚠ +entity.AddAnnotations(internal.SeverityAnnotation("info")) // ℹ +entity.AddAnnotations(internal.SeverityAnnotation("debug")) // ⚙ + +// Risk with colored symbols +entity.AddAnnotations(internal.RiskAnnotation("high")) // 🔴 +entity.AddAnnotations(internal.RiskAnnotation("medium")) // 🟡 +entity.AddAnnotations(internal.RiskAnnotation("low")) // 🟢 +``` + +## Template Functions + +```go +// In templates: +{{getAnnotation . "annotation.name"}} +{{getAnnotationValue . "annotation.name"}} +{{hasAnnotation . "annotation.name"}} +{{formatValue .Param "formatter"}} +{{severitySymbol "warning"}} +{{riskSymbol "high"}} +``` + +## Custom Template Example + +```go +// Text template +{{define "call"}} +CALL: {{.Name}}{{if getAnnotation . "render.important"}} ⭐{{end}} +{{- $severity := getAnnotationValue . "cld.severity"}} +{{if $severity}}Severity: {{severitySymbol $severity}} {{$severity}}{{end}} +{{end}} + +// HTML template +{{define "parameter"}} +{{- $formatter := getAnnotationValue . "render.formatter"}} +{{.Name}} ({{.Type}}): +{{if $formatter}}{{formatValue . $formatter}}{{else}}{{.Value}}{{end}} +{{end}} +``` + +## Available Formatters + +| Formatter | Input | Output | Use Case | +|-----------|-------|--------|----------| +| `ethereum.address` | `"1234..."` | `"0x1234..."` | Ethereum addresses | +| `ethereum.uint256` | `"1000000000"` | `"1,000,000,000"` | Large numbers | +| `hex` | `[]byte{0x12, 0x34}` | `"0x1234"` | Hex values | +| `truncate:N` | `"long string"` | `"long st..."` | Long strings | + +## Template Directory Structure + +``` +templates/ + ├── text/ + │ ├── proposal.tmpl + │ ├── batchOperation.tmpl + │ ├── call.tmpl + │ └── parameter.tmpl + └── html/ + ├── proposal.tmpl + ├── batchOperation.tmpl + ├── call.tmpl + └── parameter.tmpl +``` 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/RENDERER_ENHANCED.md b/engine/cld/mcms/analyzer/internal/RENDERER_ENHANCED.md new file mode 100644 index 00000000..d84dd36d --- /dev/null +++ b/engine/cld/mcms/analyzer/internal/RENDERER_ENHANCED.md @@ -0,0 +1,391 @@ +# Enhanced Analyzer Renderer + +The renderer component has been enhanced to support multiple output formats, file-based templates, and annotation-driven rendering. + +## Overview + +The enhanced renderer provides: + +1. **Multiple Output Formats**: Text, HTML, Markdown, JSON (extensible) +2. **File-Based Templates**: Templates are organized in format-specific directories +3. **Annotation-Driven Rendering**: Annotations control how entities are displayed +4. **Embedded Templates**: Templates are embedded in the binary for easy deployment +5. **Custom Formatters**: Extensible value formatting system + +## Architecture + +### Format Support + +The renderer supports multiple output formats: + +```go +const ( + FormatText RenderFormat = "text" // Plain text with ASCII art + FormatHTML RenderFormat = "html" // HTML with CSS styling + FormatMarkdown RenderFormat = "markdown" // Markdown (future) + FormatJSON RenderFormat = "json" // JSON (future) +) +``` + +### Template Organization + +Templates are organized in format-specific directories: + +``` +internal/templates/ + ├── text/ + │ ├── proposal.tmpl + │ ├── batchOperation.tmpl + │ ├── call.tmpl + │ └── parameter.tmpl + └── html/ + ├── proposal.tmpl + ├── batchOperation.tmpl + ├── call.tmpl + └── parameter.tmpl +``` + +Each format has its own set of templates optimized for that output type. + +## Usage + +### Basic Usage + +#### Text Format (Default) + +```go +renderer, err := internal.NewRenderer() +if err != nil { + return err +} + +output, err := renderer.Render(analyzedProposal) +fmt.Println(output) +``` + +#### HTML Format + +```go +renderer, err := internal.NewRendererWithFormat(internal.FormatHTML) +if err != nil { + return err +} + +// Render to string +htmlOutput, err := renderer.Render(analyzedProposal) + +// Or render to file +err = renderer.RenderToFile("proposal.html", analyzedProposal) +``` + +### Custom Templates + +You can provide custom templates programmatically: + +```go +customTemplates := map[string]string{ + "proposal": `{{define "proposal"}} +Custom Proposal Format +===================== +{{range .BatchOperations}}{{template "batchOperation" .}}{{end}} +{{end}}`, + // ... more templates +} + +renderer, err := internal.NewRendererWithTemplates(internal.FormatText, customTemplates) +``` + +### Loading Templates from Filesystem + +Load templates from a custom directory: + +```go +renderer, err := internal.NewRendererFromDirectory(internal.FormatText, "/path/to/templates") +``` + +## Annotation-Driven Rendering + +The key enhancement is that annotations now control rendering behavior instead of being displayed as separate entities. + +### Rendering Annotations + +#### `render.important` + +Marks an entity as important, causing it to be highlighted: + +```go +param.AddAnnotations(internal.ImportantAnnotation(true)) +// Text: ⭐ parameter_name +// HTML: parameter_name +``` + +#### `render.emoji` + +Adds an emoji decoration: + +```go +param.AddAnnotations(internal.EmojiAnnotation("💰")) +// Output: 💰 amount (uint256): 1000 +``` + +#### `render.formatter` + +Specifies a custom value formatter: + +```go +param.AddAnnotations(internal.FormatterAnnotation("ethereum.address")) +// Input: "1234567890abcdef..." +// Output: "0x1234567890abcdef..." + +param.AddAnnotations(internal.FormatterAnnotation("ethereum.uint256")) +// Input: "1000000000" +// Output: "1,000,000,000" + +param.AddAnnotations(internal.FormatterAnnotation("truncate:20")) +// Input: "very long string..." +// Output: "very long string..." +``` + +#### `render.style` + +Provides styling hints (mainly for HTML): + +```go +call.AddAnnotations(internal.StyleAnnotation("danger")) +// HTML: applies danger styling +``` + +#### `render.template` + +Specifies a custom template to use: + +```go +call.AddAnnotations(internal.TemplateAnnotation("customCall")) +// Uses customCall.tmpl instead of call.tmpl +``` + +#### `render.hide` + +Hides an entity from output: + +```go +param.AddAnnotations(internal.HideAnnotation(true)) +// Entity will not be rendered +``` + +#### `render.tooltip` + +Adds tooltip text (HTML format): + +```go +param.AddAnnotations(internal.TooltipAnnotation("This parameter controls...")) +// HTML: adds title attribute with tooltip text +``` + +### Built-in Analysis Annotations + +These annotations from analyzers also affect rendering: + +#### `cld.severity` + +Severity levels are displayed with symbols: + +```go +call.AddAnnotations(internal.SeverityAnnotation("warning")) +// Text: ⚠ warning +// HTML: ⚠ warning +``` + +Symbols: +- `error`: ✗ +- `warning`: ⚠ +- `info`: ℹ +- `debug`: ⚙ + +#### `cld.risk` + +Risk levels are displayed with colored symbols: + +```go +batchOp.AddAnnotations(internal.RiskAnnotation("high")) +// Text: 🔴 high +// HTML: 🔴 high +``` + +Symbols: +- `high`: 🔴 +- `medium`: 🟡 +- `low`: 🟢 + +## Custom Value Formatters + +The renderer includes several built-in formatters: + +### Ethereum Address + +```go +FormatterAnnotation("ethereum.address") +``` + +- Adds `0x` prefix +- Converts to lowercase hex +- Pads to 40 characters + +### Ethereum Uint256 + +```go +FormatterAnnotation("ethereum.uint256") +``` + +- Formats large numbers with commas: `1,000,000,000` + +### Hexadecimal + +```go +FormatterAnnotation("hex") +``` + +- Formats values as `0x...` hex strings + +### Truncation + +```go +FormatterAnnotation("truncate:20") +``` + +- Truncates strings to specified length +- Adds `...` if truncated + +## Template Functions + +Templates have access to these functions: + +### Annotation Functions + +- `getAnnotation .Entity "name"` - Gets annotation by name +- `getAnnotationValue .Entity "name"` - Gets annotation value +- `hasAnnotation .Entity "name"` - Checks if annotation exists +- `hasAnnotations .Entity` - Checks if entity has any annotations + +### Formatting Functions + +- `formatValue .Param "formatter"` - Applies custom formatter +- `severitySymbol "level"` - Returns severity symbol +- `riskSymbol "level"` - Returns risk symbol + +### String Functions + +- `indent spaces text` - Indents text +- `upper text` - Uppercase +- `lower text` - Lowercase +- `title text` - Title case +- `trimRight text` - Trim right whitespace +- `join sep items` - Join strings +- `repeat count text` - Repeat text + +## Template Examples + +### Using Annotations in Text Templates + +```go +{{define "call"}} + ┌─ CALL: {{.Name}}{{if getAnnotation . "render.important"}} ⭐{{end}} + {{- $severity := getAnnotationValue . "cld.severity"}} + {{- if $severity}} + │ Severity: {{severitySymbol $severity}} {{$severity}} + {{- end}} + └───────────────────────── +{{end}} +``` + +### Using Annotations in HTML Templates + +```html +{{define "parameter"}} +{{- $important := getAnnotation . "render.important"}} +{{- $emoji := getAnnotationValue . "render.emoji"}} +{{- $formatter := getAnnotationValue . "render.formatter"}} +{{- if $important}}{{end}} +{{- if $emoji}}{{$emoji}} {{end}} +{{.Name}} ({{.Type}}): +{{if $formatter}}{{formatValue . $formatter}}{{else}}{{.Value}}{{end}} +{{- if $important}}{{end}} +{{end}} +``` + +## Extending the Renderer + +### Adding New Formats + +1. Create a new format constant: + ```go + const FormatMarkdown RenderFormat = "markdown" + ``` + +2. Create template directory: + ``` + internal/templates/markdown/ + ``` + +3. Create format-specific templates: + ``` + proposal.tmpl + batchOperation.tmpl + call.tmpl + parameter.tmpl + ``` + +### Adding New Formatters + +Add formatter logic to `formatParameterValue`: + +```go +case "my.custom.formatter": + return formatMyCustom(value) +``` + +### Adding New Template Functions + +Add functions to `templateFuncs()`: + +```go +func templateFuncs() template.FuncMap { + return template.FuncMap{ + "myFunc": func(arg string) string { + // implementation + }, + } +} +``` + +## Migration from Old Renderer + +The new renderer is backward compatible: + +```go +// Old code (still works) +renderer, err := internal.NewRenderer() + +// New code with explicit format +renderer, err := internal.NewRendererWithFormat(internal.FormatText) +``` + +The main difference is that annotations are no longer rendered as a separate section. They now control rendering behavior. + +## Performance Considerations + +- Templates are parsed once during renderer creation +- Templates are embedded in the binary (no filesystem I/O at runtime) +- Large proposals can be rendered directly to a writer to avoid memory allocation: + ```go + file, _ := os.Create("output.html") + renderer.RenderTo(file, proposal) + ``` + +## Best Practices + +1. **Use annotations to guide rendering** - Don't add annotations just for display +2. **Choose appropriate formats** - Text for CLI, HTML for reports +3. **Leverage formatters** - Use built-in formatters for common types +4. **Custom templates for special cases** - Use templates for domain-specific needs +5. **Stream large outputs** - Use `RenderTo()` for large proposals 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..2a0eb0c9 --- /dev/null +++ b/engine/cld/mcms/analyzer/internal/example_renderer_test.go @@ -0,0 +1,125 @@ +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 in text format. +func ExampleRenderer() { + // Create a new renderer with default text format + 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_html demonstrates rendering in HTML format. +func ExampleRenderer_html() { + // Create a renderer with HTML format + renderer, err := internal.NewRendererWithFormat(internal.FormatHTML) + if err != nil { + panic(err) + } + + proposal := createExampleProposal() + + // Render to HTML + output, err := renderer.Render(proposal) + if err != nil { + panic(err) + } + + // Write to file + err = renderer.RenderToFile("proposal.html", proposal) + if err != nil { + panic(err) + } + + fmt.Println("HTML output generated:", len(output), "bytes") +} + +// 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}}`, + } + + // Create renderer with custom templates + renderer, err := internal.NewRendererWithTemplates(internal.FormatText, 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) +} + +// ExampleRenderer_annotationDriven demonstrates annotation-driven rendering. +func ExampleRenderer_annotationDriven() { + // This example shows how annotations control rendering behavior + // Note: In real usage, analyzers would add these annotations + + // The proposal would have calls with important annotations + // that cause the renderer to highlight them + + renderer, err := internal.NewRenderer() + if err != nil { + panic(err) + } + + // In an actual analyzed proposal: + // - Parameters with "render.formatter=ethereum.address" would be formatted as 0x... addresses + // - Calls with "render.important=true" would be marked with ⭐ + // - Parameters with "render.emoji=💰" would display the emoji + // - Values with "cld.severity=warning" would show ⚠ symbol + // - Values with "cld.risk=high" would show 🔴 symbol + + proposal := createExampleProposal() + 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/render_annotations.go b/engine/cld/mcms/analyzer/internal/render_annotations.go new file mode 100644 index 00000000..effb403c --- /dev/null +++ b/engine/cld/mcms/analyzer/internal/render_annotations.go @@ -0,0 +1,97 @@ +package internal + +// Rendering Annotation Constants +// These annotations control how entities are rendered in different formats. + +const ( + // AnnotationRenderImportantName marks an entity as important. + // When set, the renderer will highlight this entity (e.g., bold in HTML, ⭐ in text). + // Type: boolean + // Applies to: All analyzed entities (Proposal, BatchOperation, Call, Parameter) + AnnotationRenderImportantName = "render.important" + AnnotationRenderImportantType = "boolean" + + // AnnotationRenderEmojiName specifies an emoji to display alongside the entity. + // Type: string (single emoji character) + // Applies to: All analyzed entities + AnnotationRenderEmojiName = "render.emoji" + AnnotationRenderEmojiType = "string" + + // AnnotationRenderFormatterName specifies a custom formatter for the value. + // Supported formatters: + // - "ethereum.address": formats as 0x-prefixed hex address + // - "ethereum.uint256": formats large numbers with commas + // - "hex": formats as hexadecimal + // - "truncate:": truncates string to specified length + // Type: string + // Applies to: AnalyzedParameter + AnnotationRenderFormatterName = "render.formatter" + AnnotationRenderFormatterType = "string" + + // AnnotationRenderTemplateName specifies a custom template to use for rendering. + // The value should be the template name (without format extension). + // Format-specific templates will be loaded (e.g., "customCall.text.tmpl", "customCall.html.tmpl"). + // Type: string + // Applies to: All analyzed entities + AnnotationRenderTemplateName = "render.template" + AnnotationRenderTemplateType = "string" + + // AnnotationRenderStyleName provides styling hints for the renderer. + // Supported values: "bold", "italic", "underline", "code", "danger", "warning", "success", "info" + // Type: string + // Applies to: All analyzed entities + AnnotationRenderStyleName = "render.style" + AnnotationRenderStyleType = "string" + + // AnnotationRenderHideName indicates that the entity should be hidden in the output. + // Type: boolean + // Applies to: All analyzed entities + AnnotationRenderHideName = "render.hide" + AnnotationRenderHideType = "boolean" + + // AnnotationRenderExpandName controls whether nested entities are expanded by default. + // Type: boolean + // Applies to: Call, BatchOperation + AnnotationRenderExpandName = "render.expand" + AnnotationRenderExpandType = "boolean" + + // AnnotationRenderTooltipName provides tooltip/hover text for the entity. + // Type: string + // Applies to: All analyzed entities (mainly useful in HTML format) + AnnotationRenderTooltipName = "render.tooltip" + AnnotationRenderTooltipType = "string" +) + +// Helper functions for creating rendering annotations + +func ImportantAnnotation(important bool) annotation { + return NewAnnotation(AnnotationRenderImportantName, AnnotationRenderImportantType, important) +} + +func EmojiAnnotation(emoji string) annotation { + return NewAnnotation(AnnotationRenderEmojiName, AnnotationRenderEmojiType, emoji) +} + +func FormatterAnnotation(formatter string) annotation { + return NewAnnotation(AnnotationRenderFormatterName, AnnotationRenderFormatterType, formatter) +} + +func TemplateAnnotation(templateName string) annotation { + return NewAnnotation(AnnotationRenderTemplateName, AnnotationRenderTemplateType, templateName) +} + +func StyleAnnotation(style string) annotation { + return NewAnnotation(AnnotationRenderStyleName, AnnotationRenderStyleType, style) +} + +func HideAnnotation(hide bool) annotation { + return NewAnnotation(AnnotationRenderHideName, AnnotationRenderHideType, hide) +} + +func ExpandAnnotation(expand bool) annotation { + return NewAnnotation(AnnotationRenderExpandName, AnnotationRenderExpandType, expand) +} + +func TooltipAnnotation(tooltip string) annotation { + return NewAnnotation(AnnotationRenderTooltipName, AnnotationRenderTooltipType, tooltip) +} diff --git a/engine/cld/mcms/analyzer/internal/renderer.go b/engine/cld/mcms/analyzer/internal/renderer.go new file mode 100644 index 00000000..cfa6855c --- /dev/null +++ b/engine/cld/mcms/analyzer/internal/renderer.go @@ -0,0 +1,360 @@ +package internal + +import ( + "bytes" + "embed" + "fmt" + "io" + "math/big" + "os" + "path/filepath" + "strconv" + "strings" + "text/template" + + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/analyzer" +) + +//go:embed templates/* +var embeddedTemplates embed.FS + +// RenderFormat specifies the output format for rendering +type RenderFormat string + +const ( + // FormatText renders in plain text format with ASCII art + FormatText RenderFormat = "text" + // FormatHTML renders as HTML with styling + FormatHTML RenderFormat = "html" + // FormatMarkdown renders as Markdown + FormatMarkdown RenderFormat = "markdown" + // FormatJSON renders as JSON + FormatJSON RenderFormat = "json" +) + +// Renderer renders an AnalyzedProposal using Go templates. +type Renderer struct { + format RenderFormat + tmpl *template.Template +} + +// NewRenderer creates a new renderer with default text format templates. +// For backward compatibility, defaults to text format. +func NewRenderer() (*Renderer, error) { + return NewRendererWithFormat(FormatText) +} + +// NewRendererWithFormat creates a new renderer with the specified format. +// Templates are loaded from embedded files in the templates// directory. +func NewRendererWithFormat(format RenderFormat) (*Renderer, error) { + return newRendererFromEmbedded(format) +} + +// NewRendererFromDirectory creates a renderer that loads templates from a filesystem directory. +// The directory should contain subdirectories for each format (e.g., text/, html/). +func NewRendererFromDirectory(format RenderFormat, templateDir string) (*Renderer, error) { + tmpl, err := template.New("root").Funcs(templateFuncs()).Parse("") + if err != nil { + return nil, fmt.Errorf("failed to create template: %w", err) + } + + // Load templates from the format-specific subdirectory + formatDir := filepath.Join(templateDir, string(format)) + pattern := filepath.Join(formatDir, "*.tmpl") + + tmpl, err = tmpl.ParseGlob(pattern) + if err != nil { + return nil, fmt.Errorf("failed to load templates from %s: %w", pattern, err) + } + + return &Renderer{format: format, tmpl: tmpl}, nil +} + +// NewRendererWithTemplates creates a new renderer with custom in-memory templates. +// This is useful for testing or programmatic template generation. +func NewRendererWithTemplates(format RenderFormat, 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{format: format, tmpl: tmpl}, nil +} + +// newRendererFromEmbedded creates a renderer using embedded template files +func newRendererFromEmbedded(format RenderFormat) (*Renderer, error) { + tmpl, err := template.New("root").Funcs(templateFuncs()).Parse("") + if err != nil { + return nil, fmt.Errorf("failed to create template: %w", err) + } + + // Load templates from embedded filesystem + formatDir := fmt.Sprintf("templates/%s", format) + entries, err := embeddedTemplates.ReadDir(formatDir) + if err != nil { + return nil, fmt.Errorf("failed to read embedded templates for format %s: %w", format, err) + } + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".tmpl") { + continue + } + + path := filepath.Join(formatDir, entry.Name()) + content, err := embeddedTemplates.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read embedded template %s: %w", path, err) + } + + // Use the base name without extension as template name + name := strings.TrimSuffix(entry.Name(), ".tmpl") + tmpl, err = tmpl.New(name).Parse(string(content)) + if err != nil { + return nil, fmt.Errorf("failed to parse embedded template %s: %w", path, err) + } + } + + return &Renderer{format: format, 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 +} + +// RenderToFile renders the analyzed proposal to a file. +func (r *Renderer) RenderToFile(filePath string, proposal analyzer.AnalyzedProposal) error { + f, err := os.Create(filePath) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer f.Close() + + return r.RenderTo(f, proposal) +} + +// Format returns the format this renderer uses. +func (r *Renderer) Format() RenderFormat { + return r.format +} + +// templateFuncs returns the template functions available in all templates. +func templateFuncs() template.FuncMap { + return template.FuncMap{ + // String manipulation + "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, + + // Annotation functions + "hasAnnotations": func(annotated analyzer.Annotated) bool { + return annotated != nil && len(annotated.Annotations()) > 0 + }, + "getAnnotation": func(annotated analyzer.Annotated, name string) analyzer.Annotation { + if annotated == nil { + return nil + } + for _, ann := range annotated.Annotations() { + if ann.Name() == name { + return ann + } + } + return nil + }, + "getAnnotationValue": func(annotated analyzer.Annotated, name string) interface{} { + if annotated == nil { + return nil + } + for _, ann := range annotated.Annotations() { + if ann.Name() == name { + return ann.Value() + } + } + return nil + }, + "hasAnnotation": func(annotated analyzer.Annotated, name string) bool { + if annotated == nil { + return false + } + for _, ann := range annotated.Annotations() { + if ann.Name() == name { + return true + } + } + return false + }, + + // Severity and risk symbols + "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 "⚪" + } + }, + + // Value formatting functions + "formatValue": func(param analyzer.AnalyzedParameter, formatter string) string { + return formatParameterValue(param, formatter) + }, + } +} + +// formatParameterValue applies custom formatting to a parameter's value based on the formatter type +func formatParameterValue(param analyzer.AnalyzedParameter, formatter string) string { + value := param.Value() + if value == nil { + return "" + } + + // Handle different formatter types + parts := strings.SplitN(formatter, ":", 2) + formatterType := parts[0] + + switch formatterType { + case "ethereum.address": + return formatEthereumAddress(value) + case "ethereum.uint256": + return formatEthereumUint256(value) + case "hex": + return formatAsHex(value) + case "truncate": + if len(parts) > 1 { + if length, err := strconv.Atoi(parts[1]); err == nil { + return truncateString(fmt.Sprintf("%v", value), length) + } + } + return fmt.Sprintf("%v", value) + default: + return fmt.Sprintf("%v", value) + } +} + +// formatEthereumAddress formats a value as an Ethereum address with 0x prefix +func formatEthereumAddress(value interface{}) string { + str := fmt.Sprintf("%v", value) + // Remove existing 0x prefix if present + str = strings.TrimPrefix(str, "0x") + // Ensure it's lowercase hex + str = strings.ToLower(str) + // Pad to 40 characters if needed + if len(str) < 40 { + str = strings.Repeat("0", 40-len(str)) + str + } + return "0x" + str +} + +// formatEthereumUint256 formats a large number with commas for readability +func formatEthereumUint256(value interface{}) string { + // Try to parse as big.Int + var num *big.Int + switch v := value.(type) { + case *big.Int: + num = v + case string: + var ok bool + num, ok = new(big.Int).SetString(v, 10) + if !ok { + return fmt.Sprintf("%v", value) + } + case int, int64, uint, uint64: + num = big.NewInt(0) + fmt.Sscan(fmt.Sprintf("%v", v), num) + default: + return fmt.Sprintf("%v", value) + } + + // Format with commas + str := num.String() + if len(str) <= 3 { + return str + } + + // Add commas + var result strings.Builder + for i, digit := range str { + if i > 0 && (len(str)-i)%3 == 0 { + result.WriteRune(',') + } + result.WriteRune(digit) + } + return result.String() +} + +// formatAsHex formats a value as hexadecimal +func formatAsHex(value interface{}) string { + switch v := value.(type) { + case []byte: + return "0x" + fmt.Sprintf("%x", v) + case string: + return "0x" + v + case int, int64, uint, uint64: + return fmt.Sprintf("0x%x", v) + default: + return fmt.Sprintf("%v", value) + } +} + +// truncateString truncates a string to the specified length, adding "..." if truncated +func truncateString(str string, length int) string { + if len(str) <= length { + return str + } + if length <= 3 { + return str[:length] + } + return str[:length-3] + "..." +} diff --git a/engine/cld/mcms/analyzer/internal/renderer_enhanced_test.go b/engine/cld/mcms/analyzer/internal/renderer_enhanced_test.go new file mode 100644 index 00000000..e9b98e4c --- /dev/null +++ b/engine/cld/mcms/analyzer/internal/renderer_enhanced_test.go @@ -0,0 +1,298 @@ +package internal + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/analyzer" +) + +func TestNewRenderer_TextFormat(t *testing.T) { + renderer, err := NewRenderer() + require.NoError(t, err) + require.NotNil(t, renderer) + require.NotNil(t, renderer.tmpl) + assert.Equal(t, FormatText, renderer.Format()) +} + +func TestNewRendererWithFormat_HTML(t *testing.T) { + renderer, err := NewRendererWithFormat(FormatHTML) + require.NoError(t, err) + require.NotNil(t, renderer) + assert.Equal(t, FormatHTML, renderer.Format()) +} + +func TestRenderer_Render_EmptyProposal_Text(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_EmptyProposal_HTML(t *testing.T) { + renderer, err := NewRendererWithFormat(FormatHTML) + 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, "") + assert.Contains(t, output, "ANALYZED PROPOSAL") + assert.Contains(t, output, "No batch operations found") +} + +func TestRenderer_Render_WithAnnotations(t *testing.T) { + renderer, err := NewRenderer() + require.NoError(t, err) + + // Create parameter with formatting annotation + param := &analyzedParameter{ + annotated: &annotated{ + annotations: []analyzer.Annotation{ + FormatterAnnotation("ethereum.address"), + ImportantAnnotation(true), + }, + }, + decodedParameter: mockDecodedParameter{ + name: "recipient", + ptype: "address", + value: "1234567890abcdef1234567890abcdef12345678", + }, + } + + call := &analyzedCall{ + annotated: &annotated{ + annotations: []analyzer.Annotation{ + SeverityAnnotation("warning"), + RiskAnnotation("medium"), + }, + }, + decodedCall: mockDecodedCall{ + name: "transfer", + }, + inputs: []analyzer.AnalyzedParameter{param}, + outputs: []analyzer.AnalyzedParameter{}, + } + + batchOp := &analyzedBatchOperation{ + annotated: &annotated{}, + decodedBatchOperation: mockDecodedBatchOperation{}, + calls: []analyzer.AnalyzedCall{call}, + } + + proposal := &analyzedProposal{ + annotated: &annotated{}, + decodedProposal: mockDecodedTimelockProposal{}, + batchOperations: []analyzer.AnalyzedBatchOperation{batchOp}, + } + + output, err := renderer.Render(proposal) + require.NoError(t, err) + + // Check that annotations affect rendering + assert.Contains(t, output, "transfer") + assert.Contains(t, output, "⚠") // warning symbol + assert.Contains(t, output, "🟡") // medium risk symbol + assert.Contains(t, output, "⭐") // important marker + assert.Contains(t, output, "0x") // ethereum address formatter +} + +func TestRenderer_Render_HTML_WithAnnotations(t *testing.T) { + renderer, err := NewRendererWithFormat(FormatHTML) + require.NoError(t, err) + + // Create parameter with important annotation + param := &analyzedParameter{ + annotated: &annotated{ + annotations: []analyzer.Annotation{ + ImportantAnnotation(true), + EmojiAnnotation("💰"), + }, + }, + decodedParameter: mockDecodedParameter{ + name: "amount", + ptype: "uint256", + value: "1000000000000000000", + }, + } + + call := &analyzedCall{ + annotated: &annotated{ + annotations: []analyzer.Annotation{ + ImportantAnnotation(true), + }, + }, + decodedCall: mockDecodedCall{ + name: "mint", + }, + inputs: []analyzer.AnalyzedParameter{param}, + outputs: []analyzer.AnalyzedParameter{}, + } + + batchOp := &analyzedBatchOperation{ + annotated: &annotated{}, + decodedBatchOperation: mockDecodedBatchOperation{}, + calls: []analyzer.AnalyzedCall{call}, + } + + proposal := &analyzedProposal{ + annotated: &annotated{}, + decodedProposal: mockDecodedTimelockProposal{}, + batchOperations: []analyzer.AnalyzedBatchOperation{batchOp}, + } + + output, err := renderer.Render(proposal) + require.NoError(t, err) + + // Check HTML specific formatting + assert.Contains(t, output, "") + assert.Contains(t, output, "mint") + assert.Contains(t, output, "⭐") // important marker + assert.Contains(t, output, "💰") // emoji + assert.Contains(t, output, "class=\"important\"") // important class +} + +func TestFormatParameterValue_EthereumAddress(t *testing.T) { + param := &analyzedParameter{ + decodedParameter: mockDecodedParameter{ + value: "1234567890abcdef1234567890abcdef12345678", + }, + } + + result := formatParameterValue(param, "ethereum.address") + assert.Equal(t, "0x1234567890abcdef1234567890abcdef12345678", result) +} + +func TestFormatParameterValue_EthereumUint256(t *testing.T) { + param := &analyzedParameter{ + decodedParameter: mockDecodedParameter{ + value: "1000000000", + }, + } + + result := formatParameterValue(param, "ethereum.uint256") + assert.Equal(t, "1,000,000,000", result) +} + +func TestFormatParameterValue_Hex(t *testing.T) { + param := &analyzedParameter{ + decodedParameter: mockDecodedParameter{ + value: []byte{0x12, 0x34, 0x56, 0x78}, + }, + } + + result := formatParameterValue(param, "hex") + assert.Contains(t, result, "0x") +} + +func TestFormatParameterValue_Truncate(t *testing.T) { + param := &analyzedParameter{ + decodedParameter: mockDecodedParameter{ + value: "this is a very long string that should be truncated", + }, + } + + result := formatParameterValue(param, "truncate:20") + assert.Equal(t, "this is a very lo...", result) + assert.Equal(t, 20, len(result)) +} + +func TestTemplateFunc_GetAnnotation(t *testing.T) { + funcs := templateFuncs() + getAnnotation := funcs["getAnnotation"].(func(analyzer.Annotated, string) analyzer.Annotation) + + annotated := &annotated{ + annotations: []analyzer.Annotation{ + ImportantAnnotation(true), + EmojiAnnotation("🔥"), + }, + } + + // Test getting existing annotation + ann := getAnnotation(annotated, "render.important") + require.NotNil(t, ann) + assert.Equal(t, "render.important", ann.Name()) + assert.Equal(t, true, ann.Value()) + + // Test getting non-existing annotation + ann = getAnnotation(annotated, "non.existent") + assert.Nil(t, ann) + + // Test with nil annotated + ann = getAnnotation(nil, "render.important") + assert.Nil(t, ann) +} + +func TestTemplateFunc_GetAnnotationValue(t *testing.T) { + funcs := templateFuncs() + getAnnotationValue := funcs["getAnnotationValue"].(func(analyzer.Annotated, string) interface{}) + + annotated := &annotated{ + annotations: []analyzer.Annotation{ + FormatterAnnotation("ethereum.address"), + }, + } + + // Test getting existing annotation value + val := getAnnotationValue(annotated, "render.formatter") + assert.Equal(t, "ethereum.address", val) + + // Test getting non-existing annotation value + val = getAnnotationValue(annotated, "non.existent") + assert.Nil(t, val) +} + +func TestTemplateFunc_HasAnnotation(t *testing.T) { + funcs := templateFuncs() + hasAnnotation := funcs["hasAnnotation"].(func(analyzer.Annotated, string) bool) + + annotated := &annotated{ + annotations: []analyzer.Annotation{ + ImportantAnnotation(true), + }, + } + + // Test existing annotation + assert.True(t, hasAnnotation(annotated, "render.important")) + + // Test non-existing annotation + assert.False(t, hasAnnotation(annotated, "non.existent")) + + // Test with nil + assert.False(t, hasAnnotation(nil, "render.important")) +} + +func TestNewRendererWithTemplates(t *testing.T) { + customTemplates := map[string]string{ + "proposal": `{{define "proposal"}}Custom Proposal{{end}}`, + } + + renderer, err := NewRendererWithTemplates(FormatText, 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") +} 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..f495cb7d --- /dev/null +++ b/engine/cld/mcms/analyzer/internal/renderer_test.go @@ -0,0 +1,165 @@ +package internal + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/analyzer" +) + +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) { + t.Skip("Skipping - annotations now drive rendering behavior, not displayed separately") + // Note: For annotation-driven rendering tests, see renderer_enhanced_test.go +} + +func TestRenderer_Render_CompleteProposal(t *testing.T) { + t.Skip("Skipping - annotations now drive rendering behavior, not displayed separately") + // Note: For annotation-driven rendering tests, see renderer_enhanced_test.go +} + +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}}`, + } + + renderer, err := NewRendererWithTemplates(FormatText, 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..258f1b53 --- /dev/null +++ b/engine/cld/mcms/analyzer/internal/templates.go @@ -0,0 +1,98 @@ +package internal + +// Deprecated: These templates are kept for backward compatibility with tests. +// New code should use file-based templates from the templates/ directory. +// The renderer now loads templates from templates//*.tmpl files. + +// 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/internal/templates/html/batchOperation.tmpl b/engine/cld/mcms/analyzer/internal/templates/html/batchOperation.tmpl new file mode 100644 index 00000000..5bb68758 --- /dev/null +++ b/engine/cld/mcms/analyzer/internal/templates/html/batchOperation.tmpl @@ -0,0 +1,24 @@ +{{define "batchOperation" -}} +
+

📦 Batch Operation

+ {{- $severity := getAnnotationValue . "cld.severity" -}} + {{if $severity}} +
Severity: {{severitySymbol $severity}} {{$severity}}
+ {{end -}} + {{- $risk := getAnnotationValue . "cld.risk" -}} + {{if $risk}} +
Risk: {{riskSymbol $risk}} {{$risk}}
+ {{end -}} + {{- $calls := .Calls -}} + {{if $calls}} +
+

Calls ({{len $calls}})

+ {{range $i, $call := $calls -}} + {{template "call" $call}} + {{end -}} +
+ {{else}} +

No calls found.

+ {{end -}} +
+{{end}} diff --git a/engine/cld/mcms/analyzer/internal/templates/html/call.tmpl b/engine/cld/mcms/analyzer/internal/templates/html/call.tmpl new file mode 100644 index 00000000..a66981b5 --- /dev/null +++ b/engine/cld/mcms/analyzer/internal/templates/html/call.tmpl @@ -0,0 +1,39 @@ +{{define "call" -}} +
+

+ {{- if getAnnotation . "render.important"}}⭐ {{end -}} + 📞 {{.Name}} + {{- if getAnnotation . "render.important"}}{{end -}} +

+ {{- $severity := getAnnotationValue . "cld.severity" -}} + {{if $severity}} +
Severity: {{severitySymbol $severity}} {{$severity}}
+ {{end -}} + {{- $risk := getAnnotationValue . "cld.risk" -}} + {{if $risk}} +
Risk: {{riskSymbol $risk}} {{$risk}}
+ {{end -}} + {{- $inputs := .Inputs -}} + {{if $inputs}} +
+ Inputs ({{len $inputs}}): +
    + {{range $i, $input := $inputs -}} +
  • {{template "parameter" $input}}
  • + {{end -}} +
+
+ {{end -}} + {{- $outputs := .Outputs -}} + {{if $outputs}} +
+ Outputs ({{len $outputs}}): +
    + {{range $i, $output := $outputs -}} +
  • {{template "parameter" $output}}
  • + {{end -}} +
+
+ {{end -}} +
+{{end}} diff --git a/engine/cld/mcms/analyzer/internal/templates/html/parameter.tmpl b/engine/cld/mcms/analyzer/internal/templates/html/parameter.tmpl new file mode 100644 index 00000000..530e62fd --- /dev/null +++ b/engine/cld/mcms/analyzer/internal/templates/html/parameter.tmpl @@ -0,0 +1,10 @@ +{{define "parameter" -}} +{{- $important := getAnnotation . "render.important" -}} +{{- $emoji := getAnnotationValue . "render.emoji" -}} +{{- $formatter := getAnnotationValue . "render.formatter" -}} +{{- if $important}}{{end -}} +{{- if $emoji}}{{$emoji}} {{end -}} +{{.Name}} ({{.Type}}): +{{if $formatter}}{{formatValue . $formatter}}{{else}}{{.Value}}{{end}} +{{- if $important}}{{end -}} +{{- end}} diff --git a/engine/cld/mcms/analyzer/internal/templates/html/proposal.tmpl b/engine/cld/mcms/analyzer/internal/templates/html/proposal.tmpl new file mode 100644 index 00000000..11d00cd9 --- /dev/null +++ b/engine/cld/mcms/analyzer/internal/templates/html/proposal.tmpl @@ -0,0 +1,42 @@ +{{define "proposal" -}} + + + + + Analyzed Proposal + + + +
+
+

🔍 ANALYZED PROPOSAL

+
+ {{- $batchOps := .BatchOperations -}} + {{if $batchOps}} +
+

Batch Operations ({{len $batchOps}})

+ {{range $i, $batchOp := $batchOps -}} + {{template "batchOperation" $batchOp}} + {{end -}} +
+ {{else}} +

No batch operations found.

+ {{end -}} +
+ + +{{end}} diff --git a/engine/cld/mcms/analyzer/internal/templates/text/batchOperation.tmpl b/engine/cld/mcms/analyzer/internal/templates/text/batchOperation.tmpl new file mode 100644 index 00000000..23075c39 --- /dev/null +++ b/engine/cld/mcms/analyzer/internal/templates/text/batchOperation.tmpl @@ -0,0 +1,17 @@ +{{define "batchOperation" -}} + +───────────────────────────────────────────────────────────────────────────── + BATCH OPERATION +───────────────────────────────────────────────────────────────────────────── +{{- $calls := .Calls -}} +{{if $calls}} + +Calls: {{len $calls}} +{{range $i, $call := $calls -}} +{{template "call" $call}} +{{end -}} +{{else}} + +No calls found. +{{end -}} +{{end}} diff --git a/engine/cld/mcms/analyzer/internal/templates/text/call.tmpl b/engine/cld/mcms/analyzer/internal/templates/text/call.tmpl new file mode 100644 index 00000000..00ee6d72 --- /dev/null +++ b/engine/cld/mcms/analyzer/internal/templates/text/call.tmpl @@ -0,0 +1,36 @@ +{{define "call" -}} + + ┌─────────────────────────────────────────────────────────────────────────── + │ CALL: {{.Name}}{{if getAnnotation . "render.important"}} ⭐{{end}} + └─────────────────────────────────────────────────────────────────────────── +{{- $severity := getAnnotationValue . "cld.severity" -}} +{{if $severity}} + Severity: {{severitySymbol $severity}} {{$severity}} +{{end -}} +{{- $risk := getAnnotationValue . "cld.risk" -}} +{{if $risk}} + Risk: {{riskSymbol $risk}} {{$risk}} +{{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}} diff --git a/engine/cld/mcms/analyzer/internal/templates/text/parameter.tmpl b/engine/cld/mcms/analyzer/internal/templates/text/parameter.tmpl new file mode 100644 index 00000000..d7f15a93 --- /dev/null +++ b/engine/cld/mcms/analyzer/internal/templates/text/parameter.tmpl @@ -0,0 +1,8 @@ +{{define "parameter" -}} +{{- $important := getAnnotation . "render.important" -}} +{{- $emoji := getAnnotationValue . "render.emoji" -}} +{{- $formatter := getAnnotationValue . "render.formatter" -}} +{{- if $important}}⭐ {{end -}} +{{- if $emoji}}{{$emoji}} {{end -}} +{{.Name}} ({{.Type}}): {{if $formatter}}{{formatValue . $formatter}}{{else}}{{.Value}}{{end}} +{{- end}} diff --git a/engine/cld/mcms/analyzer/internal/templates/text/proposal.tmpl b/engine/cld/mcms/analyzer/internal/templates/text/proposal.tmpl new file mode 100644 index 00000000..51fc5f08 --- /dev/null +++ b/engine/cld/mcms/analyzer/internal/templates/text/proposal.tmpl @@ -0,0 +1,16 @@ +{{define "proposal" -}} +╔═══════════════════════════════════════════════════════════════════════════════ +║ ANALYZED PROPOSAL +╚═══════════════════════════════════════════════════════════════════════════════ +{{- $batchOps := .BatchOperations -}} +{{if $batchOps}} + +Batch Operations: {{len $batchOps}} +{{range $i, $batchOp := $batchOps -}} +{{template "batchOperation" $batchOp}} +{{end -}} +{{else}} + +No batch operations found. +{{end -}} +{{end}} diff --git a/engine/cld/mcms/analyzer/internal/test_mocks.go b/engine/cld/mcms/analyzer/internal/test_mocks.go new file mode 100644 index 00000000..21ffcb79 --- /dev/null +++ b/engine/cld/mcms/analyzer/internal/test_mocks.go @@ -0,0 +1,49 @@ +package internal + +import ( + "encoding/json" + + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/analyzer" +) + +// Mock implementations for testing - shared across test files + +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 +} 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 +}