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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/clever-steaks-end.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"chainlink-deployments-framework": minor
---

feat(template-input): inject Go struct doc comments into generated YAML templates
6 changes: 6 additions & 0 deletions engine/cld/changeset/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
305 changes: 305 additions & 0 deletions engine/cld/pipeline/template/comments.go
Original file line number Diff line number Diff line change
@@ -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) {

Check warning on line 192 in engine/cld/pipeline/template/comments.go

View check run for this annotation

CL-sonarqube-production / SonarQube Code Analysis

Refactor this method to reduce its Cognitive Complexity from 44 to the 30 allowed.

[S3776] Cognitive Complexity of functions should not be too high See more on https://sonarqube.main.prod.cldev.sh/project/issues?id=smartcontractkit_chainlink_deployments_framework&pullRequest=1128&issues=b360b334-645b-4cf1-9613-bddf8c9a606f&open=b360b334-645b-4cf1-9613-bddf8c9a606f
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
}
Loading
Loading