// cmd_docblock.go implements docblock/docstring coverage checking for Go code. // // Usage: // // core qa docblock # Check current directory // core qa docblock ./pkg/... # Check specific packages // core qa docblock --threshold=80 # Require 80% coverage package qa import ( "encoding/json" "fmt" "go/ast" "go/parser" "go/token" "os" "path/filepath" "sort" "strings" "github.com/host-uk/core/pkg/cli" "github.com/host-uk/core/pkg/i18n" ) // Docblock command flags var ( docblockThreshold float64 docblockVerbose bool docblockJSON bool ) // addDocblockCommand adds the 'docblock' command to qa. func addDocblockCommand(parent *cli.Command) { docblockCmd := &cli.Command{ Use: "docblock [packages...]", Short: i18n.T("cmd.qa.docblock.short"), Long: i18n.T("cmd.qa.docblock.long"), RunE: func(cmd *cli.Command, args []string) error { paths := args if len(paths) == 0 { paths = []string{"./..."} } return RunDocblockCheck(paths, docblockThreshold, docblockVerbose, docblockJSON) }, } docblockCmd.Flags().Float64Var(&docblockThreshold, "threshold", 80, i18n.T("cmd.qa.docblock.flag.threshold")) docblockCmd.Flags().BoolVarP(&docblockVerbose, "verbose", "v", false, i18n.T("common.flag.verbose")) docblockCmd.Flags().BoolVar(&docblockJSON, "json", false, i18n.T("common.flag.json")) parent.AddCommand(docblockCmd) } // DocblockResult holds the result of a docblock coverage check. type DocblockResult struct { Coverage float64 `json:"coverage"` Threshold float64 `json:"threshold"` Total int `json:"total"` Documented int `json:"documented"` Missing []MissingDocblock `json:"missing,omitempty"` Passed bool `json:"passed"` } // MissingDocblock represents an exported symbol without documentation. type MissingDocblock struct { File string `json:"file"` Line int `json:"line"` Name string `json:"name"` Kind string `json:"kind"` // func, type, const, var Reason string `json:"reason,omitempty"` } // RunDocblockCheck checks docblock coverage for the given packages. func RunDocblockCheck(paths []string, threshold float64, verbose, jsonOutput bool) error { result, err := CheckDocblockCoverage(paths) if err != nil { return err } result.Threshold = threshold result.Passed = result.Coverage >= threshold if jsonOutput { data, err := json.MarshalIndent(result, "", " ") if err != nil { return err } fmt.Println(string(data)) if !result.Passed { return cli.Err("docblock coverage %.1f%% below threshold %.1f%%", result.Coverage, threshold) } return nil } // Sort missing by file then line sort.Slice(result.Missing, func(i, j int) bool { if result.Missing[i].File != result.Missing[j].File { return result.Missing[i].File < result.Missing[j].File } return result.Missing[i].Line < result.Missing[j].Line }) // Print result if verbose && len(result.Missing) > 0 { cli.Print("%s\n\n", i18n.T("cmd.qa.docblock.missing_docs")) for _, m := range result.Missing { cli.Print(" %s:%d: %s %s\n", dimStyle.Render(m.File), m.Line, dimStyle.Render(m.Kind), m.Name, ) } cli.Blank() } // Summary coverageStr := fmt.Sprintf("%.1f%%", result.Coverage) thresholdStr := fmt.Sprintf("%.1f%%", threshold) if result.Passed { cli.Print("%s %s %s/%s (%s >= %s)\n", successStyle.Render(i18n.T("common.label.success")), i18n.T("cmd.qa.docblock.coverage"), fmt.Sprintf("%d", result.Documented), fmt.Sprintf("%d", result.Total), successStyle.Render(coverageStr), thresholdStr, ) return nil } cli.Print("%s %s %s/%s (%s < %s)\n", errorStyle.Render(i18n.T("common.label.error")), i18n.T("cmd.qa.docblock.coverage"), fmt.Sprintf("%d", result.Documented), fmt.Sprintf("%d", result.Total), errorStyle.Render(coverageStr), thresholdStr, ) // Always show compact file:line list when failing (token-efficient for AI agents) if len(result.Missing) > 0 { cli.Blank() for _, m := range result.Missing { cli.Print("%s:%d\n", m.File, m.Line) } } return cli.Err("docblock coverage %.1f%% below threshold %.1f%%", result.Coverage, threshold) } // CheckDocblockCoverage analyzes Go packages for docblock coverage. func CheckDocblockCoverage(patterns []string) (*DocblockResult, error) { result := &DocblockResult{} // Expand patterns to actual directories dirs, err := expandPatterns(patterns) if err != nil { return nil, err } fset := token.NewFileSet() for _, dir := range dirs { pkgs, err := parser.ParseDir(fset, dir, func(fi os.FileInfo) bool { return !strings.HasSuffix(fi.Name(), "_test.go") }, parser.ParseComments) if err != nil { // Log parse errors but continue to check other directories cli.Warnf("failed to parse %s: %v", dir, err) continue } for _, pkg := range pkgs { for filename, file := range pkg.Files { checkFile(fset, filename, file, result) } } } if result.Total > 0 { result.Coverage = float64(result.Documented) / float64(result.Total) * 100 } return result, nil } // expandPatterns expands Go package patterns like ./... to actual directories. func expandPatterns(patterns []string) ([]string, error) { var dirs []string seen := make(map[string]bool) for _, pattern := range patterns { if strings.HasSuffix(pattern, "/...") { // Recursive pattern base := strings.TrimSuffix(pattern, "/...") if base == "." { base = "." } err := filepath.Walk(base, func(path string, info os.FileInfo, err error) error { if err != nil { return nil // Skip errors } if !info.IsDir() { return nil } // Skip vendor, testdata, and hidden directories (but not "." itself) name := info.Name() if name == "vendor" || name == "testdata" || (strings.HasPrefix(name, ".") && name != ".") { return filepath.SkipDir } // Check if directory has Go files if hasGoFiles(path) && !seen[path] { dirs = append(dirs, path) seen[path] = true } return nil }) if err != nil { return nil, err } } else { // Single directory path := pattern if !seen[path] && hasGoFiles(path) { dirs = append(dirs, path) seen[path] = true } } } return dirs, nil } // hasGoFiles checks if a directory contains Go files. func hasGoFiles(dir string) bool { entries, err := os.ReadDir(dir) if err != nil { return false } for _, entry := range entries { if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".go") && !strings.HasSuffix(entry.Name(), "_test.go") { return true } } return false } // checkFile analyzes a single file for docblock coverage. func checkFile(fset *token.FileSet, filename string, file *ast.File, result *DocblockResult) { // Make filename relative if possible if cwd, err := os.Getwd(); err == nil { if rel, err := filepath.Rel(cwd, filename); err == nil { filename = rel } } for _, decl := range file.Decls { switch d := decl.(type) { case *ast.FuncDecl: // Skip unexported functions if !ast.IsExported(d.Name.Name) { continue } // Skip methods on unexported types if d.Recv != nil && len(d.Recv.List) > 0 { if recvType := getReceiverTypeName(d.Recv.List[0].Type); recvType != "" && !ast.IsExported(recvType) { continue } } result.Total++ if d.Doc != nil && len(d.Doc.List) > 0 { result.Documented++ } else { pos := fset.Position(d.Pos()) result.Missing = append(result.Missing, MissingDocblock{ File: filename, Line: pos.Line, Name: d.Name.Name, Kind: "func", }) } case *ast.GenDecl: for _, spec := range d.Specs { switch s := spec.(type) { case *ast.TypeSpec: if !ast.IsExported(s.Name.Name) { continue } result.Total++ // Type can have doc on GenDecl or TypeSpec if (d.Doc != nil && len(d.Doc.List) > 0) || (s.Doc != nil && len(s.Doc.List) > 0) { result.Documented++ } else { pos := fset.Position(s.Pos()) result.Missing = append(result.Missing, MissingDocblock{ File: filename, Line: pos.Line, Name: s.Name.Name, Kind: "type", }) } case *ast.ValueSpec: // Check exported consts and vars for _, name := range s.Names { if !ast.IsExported(name.Name) { continue } result.Total++ // Value can have doc on GenDecl or ValueSpec if (d.Doc != nil && len(d.Doc.List) > 0) || (s.Doc != nil && len(s.Doc.List) > 0) { result.Documented++ } else { pos := fset.Position(name.Pos()) result.Missing = append(result.Missing, MissingDocblock{ File: filename, Line: pos.Line, Name: name.Name, Kind: kindFromToken(d.Tok), }) } } } } } } } // getReceiverTypeName extracts the type name from a method receiver. func getReceiverTypeName(expr ast.Expr) string { switch t := expr.(type) { case *ast.Ident: return t.Name case *ast.StarExpr: return getReceiverTypeName(t.X) } return "" } // kindFromToken returns a string representation of the token kind. func kindFromToken(tok token.Token) string { switch tok { case token.CONST: return "const" case token.VAR: return "var" default: return "value" } }