diff --git a/.changeset/clever-steaks-end.md b/.changeset/clever-steaks-end.md new file mode 100644 index 000000000..964c23dee --- /dev/null +++ b/.changeset/clever-steaks-end.md @@ -0,0 +1,5 @@ +--- +"chainlink-deployments-framework": minor +--- + +feat(template-input): inject Go struct doc comments into generated YAML templates diff --git a/engine/cld/changeset/common.go b/engine/cld/changeset/common.go index af8ffc853..a1f3a6484 100644 --- a/engine/cld/changeset/common.go +++ b/engine/cld/changeset/common.go @@ -24,6 +24,11 @@ type Configurations struct { // InputType contains the reflect.Type of the input struct for this changeset // This is useful for tools that need to generate templates or analyze the expected input InputType reflect.Type + + // ChangesetType contains the reflect.Type of the changeset operation itself + // (e.g. *MyChangeset), useful for tools that need to inspect the changeset's + // own type — for example to read its doc comment. + ChangesetType reflect.Type } // internalChangeSet provides an opaque type, to force the usage of only the ChangeSetImpl @@ -327,6 +332,7 @@ func (ccs ChangeSetImpl[C]) Configurations() (Configurations, error) { InputChainOverrides: chainOverrides, ConfigResolver: ccs.ConfigResolver, InputType: inputType, + ChangesetType: reflect.TypeOf(ccs.changeset.operation), }, nil } diff --git a/engine/cld/pipeline/template/comments.go b/engine/cld/pipeline/template/comments.go new file mode 100644 index 000000000..675225e03 --- /dev/null +++ b/engine/cld/pipeline/template/comments.go @@ -0,0 +1,305 @@ +package template + +import ( + "go/ast" + "go/parser" + "go/token" + "reflect" + "strings" + "sync" + + "golang.org/x/tools/go/packages" +) + +// commentProvider returns doc-comment lines for struct types and their fields. +// Implementations must be safe for concurrent use. A nil return (or nil slice) +// means no comments are available — callers should treat this as a no-op. +type commentProvider interface { + // StructComments returns the doc-comment lines above a struct type + // declaration (e.g. "// MyChangeset deploys ..."). + StructComments(t reflect.Type) []string + + // FieldComments returns the doc-comment lines above a named field of a + // struct type (e.g. "// ChainSelector is the EVM chain to deploy to."). + FieldComments(t reflect.Type, goFieldName string) []string +} + +// structCommentData holds the extracted doc-comment lines for a single Go +// struct type: the struct-level doc comment (above the type declaration) and +// per-field doc comments keyed by the Go field name (not the yaml/json tag). +type structCommentData struct { + structComments []string + fieldComments map[string][]string +} + +// pkgComments holds all extracted struct comment data for a single Go package, +// keyed by the Go type name (e.g. "InputStruct", "MyConfig"). +type pkgComments struct { + structs map[string]*structCommentData +} + +// commentExtractor implements commentProvider by parsing Go source files with +// golang.org/x/tools/go/packages to read // doc comments above struct fields. +// +// It caches results per package path so that repeated lookups for types in the +// same package only trigger one packages.Load call. All errors are swallowed — +// comment extraction is a best-effort enhancement and must never cause the +// template-input command to fail. +type commentExtractor struct { + mu sync.RWMutex + cache map[string]*pkgComments +} + +// newCommentExtractor returns a ready-to-use commentExtractor. +func newCommentExtractor() *commentExtractor { + return &commentExtractor{ + cache: make(map[string]*pkgComments), + } +} + +// StructComments returns the doc-comment lines above the given struct type +// declaration, or nil if unavailable. Pointer types are dereferenced to their +// element type before lookup. +func (e *commentExtractor) StructComments(t reflect.Type) []string { + if t == nil { + return nil + } + + // Dereference pointer types (e.g. *fixtureChangeset → fixtureChangeset). + if t.Kind() == reflect.Pointer { + t = t.Elem() + } + + if t.Name() == "" || t.PkgPath() == "" { + return nil + } + + pkgData := e.getPkgComments(t.PkgPath()) + if pkgData == nil { + return nil + } + + structData, ok := pkgData.structs[t.Name()] + if !ok { + return nil + } + + return structData.structComments +} + +// FieldComments returns the doc-comment lines for the given field on the given +// struct type, or nil if the type's package could not be loaded, the struct is +// not found, or the field has no doc comment. Pointer types are dereferenced +// to their element type before lookup, consistent with StructComments. +func (e *commentExtractor) FieldComments(t reflect.Type, goFieldName string) []string { + if t == nil || goFieldName == "" { + return nil + } + + // Dereference pointer types (e.g. *MyStruct → MyStruct). + if t.Kind() == reflect.Pointer { + t = t.Elem() + } + + // Only named struct types that belong to a real package have source files + // we can parse. Anonymous structs, primitives, etc. have no comments. + if t.Name() == "" || t.PkgPath() == "" { + return nil + } + + pkgData := e.getPkgComments(t.PkgPath()) + if pkgData == nil { + return nil + } + + structData, ok := pkgData.structs[t.Name()] + if !ok { + return nil + } + + return structData.fieldComments[goFieldName] +} + +// getPkgComments returns the cached pkgComments for the given package path, +// loading it via packages.Load on first access. Returns nil on any error. +func (e *commentExtractor) getPkgComments(pkgPath string) *pkgComments { + // Fast path: read lock for cached entries. + e.mu.RLock() + if data, ok := e.cache[pkgPath]; ok { + e.mu.RUnlock() + return data + } + e.mu.RUnlock() + + // Slow path: load and cache with a write lock. + e.mu.Lock() + defer e.mu.Unlock() + + // Double-check after acquiring write lock. + if data, ok := e.cache[pkgPath]; ok { + return data + } + + data := e.loadPackage(pkgPath) + e.cache[pkgPath] = data // may be nil on error — cached to avoid retrying + + return data +} + +// loadPackage uses packages.Load to find the Go source files for the given +// package, then manually parses each file with parser.ParseComments to extract +// struct field doc comments. Returns nil on any error. +// +// We use NeedFiles (not NeedSyntax) because packages.Load does not guarantee +// that comments are retained in the pre-parsed syntax trees. By re-parsing the +// files ourselves with parser.ParseComments, we ensure doc comments are +// available in the AST. +func (e *commentExtractor) loadPackage(pkgPath string) *pkgComments { + cfg := &packages.Config{ + Mode: packages.NeedName | packages.NeedFiles, + } + + pkgs, err := packages.Load(cfg, pkgPath) + if err != nil { + return nil + } + + result := &pkgComments{ + structs: make(map[string]*structCommentData), + } + + fset := token.NewFileSet() + for _, pkg := range pkgs { + for _, filePath := range pkg.GoFiles { + file, err := parser.ParseFile(fset, filePath, nil, parser.ParseComments) + if err != nil { + continue + } + + extractStructComments(file, result) + } + } + + if len(result.structs) == 0 { + return nil + } + + return result +} + +// extractStructComments walks an AST file and populates result with doc-comment +// lines for every field of every named struct type declaration. +func extractStructComments(file *ast.File, result *pkgComments) { + for _, decl := range file.Decls { + genDecl, ok := decl.(*ast.GenDecl) + if !ok { + continue + } + + for _, spec := range genDecl.Specs { + typeSpec, ok := spec.(*ast.TypeSpec) + if !ok || typeSpec.Name == nil { + continue + } + + structType, ok := typeSpec.Type.(*ast.StructType) + if !ok || structType.Fields == nil { + continue + } + + structData := &structCommentData{ + fieldComments: make(map[string][]string), + } + + // Extract the struct-level doc comment (the // block above the + // type declaration, e.g. "// MyChangeset deploys ..."). + // The doc comment can be on either typeSpec.Doc or genDecl.Doc + // depending on whether the type is declared alone or grouped. + var doc *ast.CommentGroup + if typeSpec.Doc != nil { + doc = typeSpec.Doc + } else if len(genDecl.Specs) == 1 && genDecl.Doc != nil { + doc = genDecl.Doc + } + if doc != nil { + structData.structComments = splitCommentGroup(doc) + } + + for _, field := range structType.Fields.List { + // Embedded fields have no names — skip them. + if len(field.Names) == 0 { + continue + } + + var comments []string + + // Doc comment group: the // comment block directly above the field. + if field.Doc != nil { + comments = append(comments, splitCommentGroup(field.Doc)...) + } + + // Line comment: a trailing // comment on the same line as the field. + if field.Comment != nil { + comments = append(comments, splitCommentGroup(field.Comment)...) + } + + if len(comments) == 0 { + continue + } + + // A single ast.Field can declare multiple names (e.g. `a, b int`), + // so apply the same comments to each named field. + for _, name := range field.Names { + structData.fieldComments[name.Name] = comments + } + } + + result.structs[typeSpec.Name.Name] = structData + } + } +} + +// splitCommentGroup converts an ast.CommentGroup into a slice of individual +// comment lines, with the leading "//" markers and surrounding whitespace +// stripped. Empty lines are removed. Block comments (/* ... */) are split +// into individual lines with leading "*" prefixes stripped. +func splitCommentGroup(group *ast.CommentGroup) []string { + if group == nil { + return nil + } + + var lines []string + for _, comment := range group.List { + text := comment.Text + isBlock := false + + // Strip the "//" prefix (single-line comments). + if strings.HasPrefix(text, "//") { + text = strings.TrimPrefix(text, "//") + } else if strings.HasPrefix(text, "/*") && strings.HasSuffix(text, "*/") { + // Block comment: strip /* and */ delimiters. + text = strings.TrimSuffix(strings.TrimPrefix(text, "/*"), "*/") + isBlock = true + } + + // Trim a single leading space that Go convention adds after "//". + text = strings.TrimPrefix(text, " ") + + // Split multi-line comments into individual lines. + for _, line := range strings.Split(text, "\n") { + trimmed := strings.TrimSpace(line) + + // In block comments, strip a leading "*" that is commonly used + // as a line prefix (e.g. " * This is a line"). + if isBlock && strings.HasPrefix(trimmed, "*") { + trimmed = strings.TrimSpace(strings.TrimPrefix(trimmed, "*")) + } + + if trimmed != "" { + lines = append(lines, trimmed) + } + } + } + + return lines +} diff --git a/engine/cld/pipeline/template/comments_test.go b/engine/cld/pipeline/template/comments_test.go new file mode 100644 index 000000000..7c29b695f --- /dev/null +++ b/engine/cld/pipeline/template/comments_test.go @@ -0,0 +1,100 @@ +package template + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCommentExtractor_FieldComments_RealStruct(t *testing.T) { + t.Parallel() + + extractor := newCommentExtractor() + typ := reflect.TypeOf(commentedFixtureStruct{}) + + // ChainSelector — single-line doc comment. + chainComments := extractor.FieldComments(typ, "ChainSelector") + require.NotEmpty(t, chainComments) + require.Contains(t, chainComments[0], "ChainSelector is the EVM chain selector") + + // WorkflowName — multi-line doc comment (two // lines). + wfComments := extractor.FieldComments(typ, "WorkflowName") + require.Len(t, wfComments, 2) + require.Contains(t, wfComments[0], "WorkflowName is the name of the CRE workflow") + require.Contains(t, wfComments[1], "It must match the workflow name registered") + + // Decimals — multi-line doc comment. + decComments := extractor.FieldComments(typ, "Decimals") + require.NotEmpty(t, decComments) + require.Contains(t, decComments[0], "Decimals is the on-chain precision") + + // NoCommentField — no doc comment → nil. + require.Nil(t, extractor.FieldComments(typ, "NoCommentField")) + + // TrailingComment — has a trailing same-line comment. + trailingComments := extractor.FieldComments(typ, "TrailingComment") + require.NotEmpty(t, trailingComments) +} + +func TestCommentExtractor_FieldComments_Caching(t *testing.T) { + t.Parallel() + + extractor := newCommentExtractor() + typ := reflect.TypeOf(commentedFixtureStruct{}) + + // First call triggers packages.Load. + first := extractor.FieldComments(typ, "ChainSelector") + require.NotEmpty(t, first) + + // Second call should return the same result from cache. + second := extractor.FieldComments(typ, "ChainSelector") + require.Equal(t, first, second) + + // Verify the package is cached. + pkgPath := typ.PkgPath() + extractor.mu.RLock() + _, cached := extractor.cache[pkgPath] + extractor.mu.RUnlock() + require.True(t, cached) +} + +func TestCommentExtractor_FieldComments_NonExistentPackage(t *testing.T) { + t.Parallel() + + extractor := newCommentExtractor() + + // A type with a fake package path — should return nil, no panic. + // We simulate this by using a primitive type which has no PkgPath. + typ := reflect.TypeOf("") + require.Nil(t, extractor.FieldComments(typ, "SomeField")) +} + +func TestCommentExtractor_FieldComments_AnonymousStruct(t *testing.T) { + t.Parallel() + + extractor := newCommentExtractor() + + type anonymous struct { + Field string `yaml:"field"` + } + + typ := reflect.TypeOf(anonymous{}) + // Anonymous structs have no Name → should return nil. + require.Nil(t, extractor.FieldComments(typ, "Field")) +} + +func TestCommentExtractor_NilSafeInGenerateStructYAML(t *testing.T) { + t.Parallel() + + type S struct { + A string `yaml:"a"` + B int `yaml:"b"` + } + + // Passing nil as comments should produce identical output to the + // pre-comment-injection behavior. + got, err := GenerateStructYAMLWithDepthLimit(reflect.TypeOf(S{}), " ", 0, make(map[reflect.Type]bool), 5, nil) + require.NoError(t, err) + require.Equal(t, " a: # string\n b: # int\n", got) +} diff --git a/engine/cld/pipeline/template/comments_test_fixture.go b/engine/cld/pipeline/template/comments_test_fixture.go new file mode 100644 index 000000000..9e2fcf8de --- /dev/null +++ b/engine/cld/pipeline/template/comments_test_fixture.go @@ -0,0 +1,25 @@ +package template + +// commentedFixtureStruct is a non-test Go struct with doc comments on its +// fields, used by comments_test.go to verify that the commentExtractor can +// read source-level // doc comments via AST parsing. +// +// It MUST live in a regular .go file (not a _test.go file) because +// packages.Load does not load _test.go files by default. +type commentedFixtureStruct struct { + // ChainSelector is the EVM chain selector to deploy to. + ChainSelector uint64 `yaml:"chainSelector" json:"chainSelector"` + + // WorkflowName is the name of the CRE workflow that consumes this feed. + // It must match the workflow name registered in the DON config. + WorkflowName string `yaml:"workflowName" json:"workflowName"` + + // Decimals is the on-chain precision the consumer-facing + // AggregatorProxy.decimals() view will report. + Decimals uint8 `yaml:"decimals" json:"decimals"` + + NoCommentField string `yaml:"noComment" json:"noComment"` + + // TrailingComment shows a same-line trailing comment. //nolint:unused + TrailingComment string `yaml:"trailing" json:"trailing"` //nolint:unused +} diff --git a/engine/cld/pipeline/template/fixture_changeset_env.go b/engine/cld/pipeline/template/fixture_changeset_env.go new file mode 100644 index 000000000..0fd1cac30 --- /dev/null +++ b/engine/cld/pipeline/template/fixture_changeset_env.go @@ -0,0 +1,86 @@ +package template + +import ( + fdeployment "github.com/smartcontractkit/chainlink-deployments-framework/deployment" +) + +// envInputFixtureConfig is the config struct for envInputFixtureChangeset. +// It is used by the golden test to verify WithEnvInput path output, where the +// input type comes from the changeset's generic type parameter C rather than +// from a config resolver's function signature. +// +// It MUST live in a regular .go file (not a _test.go file) because +// packages.Load does not load _test.go files by default. +type envInputFixtureConfig struct { + // FeedURL is the HTTP endpoint the workflow polls for data. + FeedURL string `yaml:"feedURL" json:"feedURL"` + + // PollIntervalSec is the interval between polls in seconds. + // Must be a positive integer. + PollIntervalSec int `yaml:"pollIntervalSec" json:"pollIntervalSec"` + + // Enabled controls whether the feed is active. + Enabled bool `yaml:"enabled" json:"enabled"` + + /* BlockCommentField demonstrates a multi-line block comment + * with star-prefixed lines, which should be stripped in the + * generated YAML output. + */ + BlockCommentField string `yaml:"blockComment" json:"blockComment"` + + NoComment string `yaml:"noComment" json:"noComment"` +} + +// envInputFixtureChangeset is a stub changeset typed with envInputFixtureConfig, +// used by the golden test to verify the WithEnvInput path. This changeset's +// generic type C is envInputFixtureConfig, so cfg.InputType is populated and +// the InputType branch of generateChangesetSection is exercised. +// +// It MUST live in a regular .go file (not a _test.go file) because +// packages.Load does not load _test.go files by default. +type envInputFixtureChangeset struct{} + +func (envInputFixtureChangeset) Apply(_ fdeployment.Environment, _ envInputFixtureConfig) (fdeployment.ChangesetOutput, error) { + return fdeployment.ChangesetOutput{}, nil +} + +func (envInputFixtureChangeset) VerifyPreconditions(_ fdeployment.Environment, _ envInputFixtureConfig) error { + return nil +} + +var _ fdeployment.ChangeSetV2[envInputFixtureConfig] = (*envInputFixtureChangeset)(nil) + +// resolverInputStruct is the input type accepted by typedResolverFixtureResolver. +// It is intentionally different from envInputFixtureConfig (the changeset's +// generic type C) to verify that the ConfigResolver path shows the resolver's +// input type, not the changeset's config type. +// +// It MUST live in a regular .go file (not a _test.go file) because +// packages.Load does not load _test.go files by default. +type resolverInputStruct struct { + // ChainID is the EVM chain ID (not selector) to target. + ChainID uint64 `yaml:"chainID" json:"chainID"` + + // ContractAddress is the deployed contract address to interact with. + ContractAddress string `yaml:"contractAddress" json:"contractAddress"` +} + +// typedResolverFixtureChangeset is a stub changeset typed with +// envInputFixtureConfig, but wired with a config resolver that accepts +// resolverInputStruct as its input. This verifies that the generated YAML +// template shows the resolver's input type (resolverInputStruct), not the +// changeset's generic config type (envInputFixtureConfig). +// +// It MUST live in a regular .go file (not a _test.go file) because +// packages.Load does not load _test.go files by default. +type typedResolverFixtureChangeset struct{} + +func (typedResolverFixtureChangeset) Apply(_ fdeployment.Environment, _ envInputFixtureConfig) (fdeployment.ChangesetOutput, error) { + return fdeployment.ChangesetOutput{}, nil +} + +func (typedResolverFixtureChangeset) VerifyPreconditions(_ fdeployment.Environment, _ envInputFixtureConfig) error { + return nil +} + +var _ fdeployment.ChangeSetV2[envInputFixtureConfig] = (*typedResolverFixtureChangeset)(nil) diff --git a/engine/cld/pipeline/template/golden_test.go b/engine/cld/pipeline/template/golden_test.go new file mode 100644 index 000000000..ee2b1a5df --- /dev/null +++ b/engine/cld/pipeline/template/golden_test.go @@ -0,0 +1,111 @@ +package template + +import ( + "flag" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + fresolvers "github.com/smartcontractkit/chainlink-deployments-framework/changeset/resolvers" + cs "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/changeset" +) + +var updateGolden = flag.Bool("update", false, "update golden files") + +// assertGolden compares got against the golden file at testdata/. +// With -update flag it writes the golden file instead of comparing. +func assertGolden(t *testing.T, name string, got string) { + t.Helper() + + goldenPath := filepath.Join("testdata", name) + + if *updateGolden { + require.NoError(t, os.WriteFile(goldenPath, []byte(got), 0o600), "writing golden file %s", goldenPath) //nolint:gosec // G703: goldenPath is the in-repo testdata file, only written under -update by the developer + return + } + + want, err := os.ReadFile(goldenPath) + require.NoError(t, err, "reading golden file %s (run with -update to create it)", goldenPath) + require.Equal(t, string(want), got, "output does not match golden file %s\n\nrun: go test ./... -run %s -update", goldenPath, t.Name()) +} + +// typedResolverFixtureResolver accepts resolverInputStruct as input but returns +// envInputFixtureConfig. This verifies that the generated YAML template shows +// the resolver's input type (resolverInputStruct), not the changeset's config +// type (envInputFixtureConfig). +func typedResolverFixtureResolver(in resolverInputStruct) (envInputFixtureConfig, error) { + return envInputFixtureConfig{ + FeedURL: "https://example.com/feed", + PollIntervalSec: 30, + Enabled: true, + }, nil +} + +// TestGenerateMultiChangesetYAML_Golden is a golden-file test for the full +// end-to-end output of GenerateMultiChangesetYAML. It covers two variants: +// +// 1. env_input — a changeset wired with WithEnvInput, exercising the +// cfg.InputType branch where the input type comes from the changeset's +// generic type parameter C. +// 2. typed_resolver — a changeset wired with WithConfigResolver where the +// resolver's input type (resolverInputStruct) differs from the changeset's +// config type (envInputFixtureConfig), verifying the YAML shows the +// resolver's input type. +// +// Run `go test ./... -run TestGenerateMultiChangesetYAML_Golden -update` to +// regenerate the golden files after intentional changes to the output format. +func TestGenerateMultiChangesetYAML_Golden(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + golden string + changesetNames []string + regSetup func() *cs.ChangesetsRegistry + rmSetup func() *fresolvers.ConfigResolverManager + }{ + { + name: "env_input", + golden: "multi_changeset_env_input.golden.yaml", + changesetNames: []string{"0002_env"}, + regSetup: func() *cs.ChangesetsRegistry { + reg := cs.NewChangesetsRegistry() + reg.Add("0002_env", cs.Configure(&envInputFixtureChangeset{}).WithEnvInput()) + + return reg + }, + rmSetup: func() *fresolvers.ConfigResolverManager { + return fresolvers.NewConfigResolverManager() + }, + }, + { + name: "typed_resolver", + golden: "multi_changeset_typed_resolver.golden.yaml", + changesetNames: []string{"0003_typed"}, + regSetup: func() *cs.ChangesetsRegistry { + reg := cs.NewChangesetsRegistry() + reg.Add("0003_typed", cs.Configure(&typedResolverFixtureChangeset{}).WithConfigResolver(typedResolverFixtureResolver)) + + return reg + }, + rmSetup: func() *fresolvers.ConfigResolverManager { + rm := fresolvers.NewConfigResolverManager() + rm.Register(typedResolverFixtureResolver, fresolvers.ResolverInfo{Description: "typedResolverFixture"}) + + return rm + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := GenerateMultiChangesetYAML("mydomain", "testnet", tt.changesetNames, tt.regSetup(), tt.rmSetup(), 5) + require.NoError(t, err) + assertGolden(t, tt.golden, got) + }) + } +} diff --git a/engine/cld/pipeline/template/template.go b/engine/cld/pipeline/template/template.go index 442900d6b..1ca690186 100644 --- a/engine/cld/pipeline/template/template.go +++ b/engine/cld/pipeline/template/template.go @@ -29,6 +29,8 @@ domain: %s changesets: `, envKey, domainName) + comments := newCommentExtractor() + var sb strings.Builder for i, changesetName := range changesetNames { if changesetName == "" { @@ -44,7 +46,7 @@ changesets: return "", fmt.Errorf("get configurations for changeset %s: %w", changesetName, err) } - section, err := generateChangesetSection(changesetName, cfg, resolverManager, " ", depthLimit) + section, err := generateChangesetSection(changesetName, cfg, resolverManager, comments, " ", depthLimit) if err != nil { return "", fmt.Errorf("generate section for changeset %s: %w", changesetName, err) } @@ -60,6 +62,7 @@ func generateChangesetSection( changesetName string, cfg cs.Configurations, resolverManager *resolvers.ConfigResolverManager, + comments commentProvider, indent string, depthLimit int, ) (string, error) { @@ -80,13 +83,21 @@ func generateChangesetSection( fmt.Fprintf(§ion, "%s# Config Resolver: %s\n", indent, resolverName) fmt.Fprintf(§ion, "%s# Input type: %s\n", indent, inputType.String()) + + // Inject the changeset's own doc comment above the changeset name. + if comments != nil { + for _, commentLine := range comments.StructComments(cfg.ChangesetType) { + fmt.Fprintf(§ion, "%s# %s\n", indent, commentLine) + } + } + fmt.Fprintf(§ion, "%s- %s:\n", indent, changesetName) writeChainOverridesSection(§ion, indent) section.WriteString(indent + " payload:\n") - payloadYAML, err := GenerateStructYAMLWithDepthLimit(inputType, indent+" ", 0, make(map[reflect.Type]bool), depthLimit) + payloadYAML, err := GenerateStructYAMLWithDepthLimit(inputType, indent+" ", 0, make(map[reflect.Type]bool), depthLimit, comments) if err != nil { return "", fmt.Errorf("generate struct YAML for %s: %w", inputType.String(), err) } @@ -94,13 +105,21 @@ func generateChangesetSection( section.WriteString(payloadYAML) } else if cfg.InputType != nil { fmt.Fprintf(§ion, "%s# Input type: %s\n", indent, cfg.InputType.String()) + + // Inject the changeset's own doc comment above the changeset name. + if comments != nil { + for _, commentLine := range comments.StructComments(cfg.ChangesetType) { + fmt.Fprintf(§ion, "%s# %s\n", indent, commentLine) + } + } + fmt.Fprintf(§ion, "%s- %s:\n", indent, changesetName) writeChainOverridesSection(§ion, indent) section.WriteString(indent + " payload:\n") - payloadYAML, err := GenerateStructYAMLWithDepthLimit(cfg.InputType, indent+" ", 0, make(map[reflect.Type]bool), depthLimit) + payloadYAML, err := GenerateStructYAMLWithDepthLimit(cfg.InputType, indent+" ", 0, make(map[reflect.Type]bool), depthLimit, comments) if err != nil { return "", fmt.Errorf("generate struct YAML for %s: %w", cfg.InputType.String(), err) } @@ -119,12 +138,15 @@ func writeChainOverridesSection(section *strings.Builder, indent string) { } // GenerateStructYAMLWithDepthLimit recursively generates YAML structure with depth limiting. +// The comments parameter injects Go struct field doc comments as YAML comments +// above each field. Pass nil to disable comment injection. func GenerateStructYAMLWithDepthLimit( t reflect.Type, indent string, depth int, visited map[reflect.Type]bool, maxDepth int, + comments commentProvider, ) (string, error) { if depth > maxDepth { return "", nil @@ -147,6 +169,14 @@ func GenerateStructYAMLWithDepthLimit( fieldCount := 0 maxFields := 20 + // Inject the struct's own doc comment above its fields, giving + // users context on what the struct represents. + if comments != nil { + for _, commentLine := range comments.StructComments(t) { + fmt.Fprintf(&result, "%s# %s\n", indent, commentLine) + } + } + for i := 0; i < t.NumField() && fieldCount < maxFields; i++ { field := t.Field(i) @@ -161,7 +191,15 @@ func GenerateStructYAMLWithDepthLimit( fieldName := GetFieldName(field) fieldType := field.Type - fieldValue, err := GenerateFieldValueWithDepthLimit(fieldType, indent+" ", depth+1, visited, maxDepth) + // Inject Go doc comments as YAML comments above the field, giving + // users hints on what values to set. + if comments != nil { + for _, commentLine := range comments.FieldComments(t, field.Name) { + fmt.Fprintf(&result, "%s# %s\n", indent, commentLine) + } + } + + fieldValue, err := GenerateFieldValueWithDepthLimit(fieldType, indent+" ", depth+1, visited, maxDepth, comments) if err != nil { return "", fmt.Errorf("generate field value for %s: %w", field.Name, err) } @@ -184,7 +222,7 @@ func GenerateStructYAMLWithDepthLimit( elemType := t.Elem() result := fmt.Sprintf("%s# Array of %s\n%s- ", indent, elemType.String(), indent) - elemValue, err := GenerateFieldValueWithDepthLimit(elemType, indent+" ", depth+1, visited, maxDepth) + elemValue, err := GenerateFieldValueWithDepthLimit(elemType, indent+" ", depth+1, visited, maxDepth, comments) if err != nil { return "", err } @@ -199,7 +237,7 @@ func GenerateStructYAMLWithDepthLimit( return fmt.Sprintf("%s# Map[%s]%s\n%sexample_key: # %s\n", indent, keyType.String(), valueType.String(), indent, valueType.String()), nil } - valueStr, err := GenerateFieldValueWithDepthLimit(valueType, indent+" ", depth+1, visited, maxDepth) + valueStr, err := GenerateFieldValueWithDepthLimit(valueType, indent+" ", depth+1, visited, maxDepth, comments) if err != nil { return "", err } @@ -216,6 +254,7 @@ func GenerateStructYAMLWithDepthLimit( } // GenerateFieldValueWithDepthLimit generates an example value for a field based on its type. +// The comments parameter is forwarded to nested struct generation for comment injection. // Exported for testing. func GenerateFieldValueWithDepthLimit( t reflect.Type, @@ -223,6 +262,7 @@ func GenerateFieldValueWithDepthLimit( depth int, visited map[reflect.Type]bool, maxDepth int, + comments commentProvider, ) (string, error) { if depth > maxDepth { return " ...", nil @@ -246,7 +286,7 @@ func GenerateFieldValueWithDepthLimit( return " # " + t.String(), nil } elemType := t.Elem() - elemValue, err := GenerateFieldValueWithDepthLimit(elemType, indent+" ", depth+1, visited, maxDepth) + elemValue, err := GenerateFieldValueWithDepthLimit(elemType, indent+" ", depth+1, visited, maxDepth, comments) if err != nil { return "", err } @@ -258,7 +298,7 @@ func GenerateFieldValueWithDepthLimit( return fmt.Sprintf("\n%s- %s", indent, trimmedElem), nil case reflect.Struct: - structYAML, err := GenerateStructYAMLWithDepthLimit(t, indent, depth+1, visited, maxDepth) + structYAML, err := GenerateStructYAMLWithDepthLimit(t, indent, depth+1, visited, maxDepth, comments) if err != nil { return "", err } @@ -267,7 +307,7 @@ func GenerateFieldValueWithDepthLimit( case reflect.Map: keyType := t.Key() valueType := t.Elem() - valueStr, err := GenerateFieldValueWithDepthLimit(valueType, indent+" ", depth+1, visited, maxDepth) + valueStr, err := GenerateFieldValueWithDepthLimit(valueType, indent+" ", depth+1, visited, maxDepth, comments) if err != nil { return "", err } diff --git a/engine/cld/pipeline/template/template_test.go b/engine/cld/pipeline/template/template_test.go index 53ea7d5c9..80ad24b1a 100644 --- a/engine/cld/pipeline/template/template_test.go +++ b/engine/cld/pipeline/template/template_test.go @@ -138,7 +138,7 @@ func TestGenerateStructYAMLWithDepthLimit_Struct(t *testing.T) { B int `yaml:"b"` } - got, err := GenerateStructYAMLWithDepthLimit(reflect.TypeOf(S{}), " ", 0, make(map[reflect.Type]bool), 5) + got, err := GenerateStructYAMLWithDepthLimit(reflect.TypeOf(S{}), " ", 0, make(map[reflect.Type]bool), 5, nil) require.NoError(t, err) require.Equal(t, " a: # string\n b: # int\n", got) } @@ -150,7 +150,7 @@ func TestGenerateStructYAMLWithDepthLimit_DepthExceeded(t *testing.T) { A string `yaml:"a"` } - got, err := GenerateStructYAMLWithDepthLimit(reflect.TypeOf(S{}), " ", 10, make(map[reflect.Type]bool), 5) + got, err := GenerateStructYAMLWithDepthLimit(reflect.TypeOf(S{}), " ", 10, make(map[reflect.Type]bool), 5, nil) require.NoError(t, err) require.Empty(t, got) } @@ -162,7 +162,7 @@ func TestGenerateStructYAMLWithDepthLimit_CircularRef(t *testing.T) { Next *Node `yaml:"next"` } - got, err := GenerateStructYAMLWithDepthLimit(reflect.TypeOf(Node{}), " ", 0, make(map[reflect.Type]bool), 10) + got, err := GenerateStructYAMLWithDepthLimit(reflect.TypeOf(Node{}), " ", 0, make(map[reflect.Type]bool), 10, nil) require.NoError(t, err) require.Equal(t, " next:\n# ... (circular reference to template.Node)\n", got) } @@ -170,7 +170,7 @@ func TestGenerateStructYAMLWithDepthLimit_CircularRef(t *testing.T) { func TestGenerateFieldValueWithDepthLimit_String(t *testing.T) { t.Parallel() - got, err := GenerateFieldValueWithDepthLimit(reflect.TypeOf(""), " ", 0, make(map[reflect.Type]bool), 5) + got, err := GenerateFieldValueWithDepthLimit(reflect.TypeOf(""), " ", 0, make(map[reflect.Type]bool), 5, nil) require.NoError(t, err) require.Equal(t, " # string", got) } @@ -178,7 +178,7 @@ func TestGenerateFieldValueWithDepthLimit_String(t *testing.T) { func TestGenerateFieldValueWithDepthLimit_Int(t *testing.T) { t.Parallel() - got, err := GenerateFieldValueWithDepthLimit(reflect.TypeOf(0), " ", 0, make(map[reflect.Type]bool), 5) + got, err := GenerateFieldValueWithDepthLimit(reflect.TypeOf(0), " ", 0, make(map[reflect.Type]bool), 5, nil) require.NoError(t, err) require.Equal(t, " # int", got) } @@ -186,7 +186,7 @@ func TestGenerateFieldValueWithDepthLimit_Int(t *testing.T) { func TestGenerateFieldValueWithDepthLimit_Slice(t *testing.T) { t.Parallel() - got, err := GenerateFieldValueWithDepthLimit(reflect.TypeOf([]string{}), " ", 0, make(map[reflect.Type]bool), 5) + got, err := GenerateFieldValueWithDepthLimit(reflect.TypeOf([]string{}), " ", 0, make(map[reflect.Type]bool), 5, nil) require.NoError(t, err) require.Equal(t, "\n - # string", got) } diff --git a/engine/cld/pipeline/template/testdata/multi_changeset_env_input.golden.yaml b/engine/cld/pipeline/template/testdata/multi_changeset_env_input.golden.yaml new file mode 100644 index 000000000..7fb4c3ba6 --- /dev/null +++ b/engine/cld/pipeline/template/testdata/multi_changeset_env_input.golden.yaml @@ -0,0 +1,35 @@ +# Generated via template-input command +environment: testnet +domain: mydomain +changesets: + # Input type: template.envInputFixtureConfig + # envInputFixtureChangeset is a stub changeset typed with envInputFixtureConfig, + # used by the golden test to verify the WithEnvInput path. This changeset's + # generic type C is envInputFixtureConfig, so cfg.InputType is populated and + # the InputType branch of generateChangesetSection is exercised. + # It MUST live in a regular .go file (not a _test.go file) because + # packages.Load does not load _test.go files by default. + - 0002_env: + # Optional: Chain overrides (uncomment if needed) + # chainOverrides: + # - 1 # Chain selector 1 + # - 2 # Chain selector 2 + payload: + # envInputFixtureConfig is the config struct for envInputFixtureChangeset. + # It is used by the golden test to verify WithEnvInput path output, where the + # input type comes from the changeset's generic type parameter C rather than + # from a config resolver's function signature. + # It MUST live in a regular .go file (not a _test.go file) because + # packages.Load does not load _test.go files by default. + # FeedURL is the HTTP endpoint the workflow polls for data. + feedURL: # string + # PollIntervalSec is the interval between polls in seconds. + # Must be a positive integer. + pollIntervalSec: # int + # Enabled controls whether the feed is active. + enabled: # bool + # BlockCommentField demonstrates a multi-line block comment + # with star-prefixed lines, which should be stripped in the + # generated YAML output. + blockComment: # string + noComment: # string diff --git a/engine/cld/pipeline/template/testdata/multi_changeset_typed_resolver.golden.yaml b/engine/cld/pipeline/template/testdata/multi_changeset_typed_resolver.golden.yaml new file mode 100644 index 000000000..a3efc2100 --- /dev/null +++ b/engine/cld/pipeline/template/testdata/multi_changeset_typed_resolver.golden.yaml @@ -0,0 +1,29 @@ +# Generated via template-input command +environment: testnet +domain: mydomain +changesets: + # Config Resolver: github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/pipeline/template.typedResolverFixtureResolver + # Input type: template.resolverInputStruct + # typedResolverFixtureChangeset is a stub changeset typed with + # envInputFixtureConfig, but wired with a config resolver that accepts + # resolverInputStruct as its input. This verifies that the generated YAML + # template shows the resolver's input type (resolverInputStruct), not the + # changeset's generic config type (envInputFixtureConfig). + # It MUST live in a regular .go file (not a _test.go file) because + # packages.Load does not load _test.go files by default. + - 0003_typed: + # Optional: Chain overrides (uncomment if needed) + # chainOverrides: + # - 1 # Chain selector 1 + # - 2 # Chain selector 2 + payload: + # resolverInputStruct is the input type accepted by typedResolverFixtureResolver. + # It is intentionally different from envInputFixtureConfig (the changeset's + # generic type C) to verify that the ConfigResolver path shows the resolver's + # input type, not the changeset's config type. + # It MUST live in a regular .go file (not a _test.go file) because + # packages.Load does not load _test.go files by default. + # ChainID is the EVM chain ID (not selector) to target. + chainID: # uint64 + # ContractAddress is the deployed contract address to interact with. + contractAddress: # string diff --git a/go.mod b/go.mod index 22f34b6c8..255bb19d2 100644 --- a/go.mod +++ b/go.mod @@ -112,6 +112,7 @@ require ( go.uber.org/goleak v1.3.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect + golang.org/x/mod v0.36.0 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect @@ -338,7 +339,7 @@ require ( golang.org/x/term v0.44.0 // indirect golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.15.0 - golang.org/x/tools v0.45.0 // indirect + golang.org/x/tools v0.45.0 google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect