diff --git a/cmd/go-covercheck/root.go b/cmd/go-covercheck/root.go index b0413d8..a77cad5 100644 --- a/cmd/go-covercheck/root.go +++ b/cmd/go-covercheck/root.go @@ -53,6 +53,14 @@ const ( TotalBlockThresholdFlagShort = "B" TotalBlockThresholdFlagUsage = "total block threshold to enforce [0=disabled]" + FunctionThresholdFlag = "function-threshold" + FunctionThresholdFlagShort = "n" + FunctionThresholdFlagUsage = "global function threshold to enforce [0=disabled]" + + TotalFunctionThresholdFlag = "total-function-threshold" + TotalFunctionThresholdFlagShort = "F" + TotalFunctionThresholdFlagUsage = "total function threshold to enforce [0=disabled]" + SortByFlag = "sort-by" SortOrderFlag = "sort-order" @@ -132,12 +140,14 @@ var ( ) SortByFlagUsage = fmt.Sprintf( - "sort-by [%s|%s|%s|%s|%s]", + "sort-by [%s|%s|%s|%s|%s|%s|%s]", config.SortByFile, config.SortByBlocks, config.SortByStatements, + config.SortByFunctions, config.SortByStatementPercent, config.SortByBlockPercent, + config.SortByFunctionPercent, ) SortOrderFlagUsage = fmt.Sprintf("sort order [%s|%s]", @@ -431,12 +441,19 @@ func applyConfigOverrides(cfg *config.Config, cmd *cobra.Command, noConfigFile b noConfigFile { cfg.BlockThreshold = v } + if v, _ := cmd.Flags().GetFloat64(FunctionThresholdFlag); cmd.Flags().Changed(FunctionThresholdFlag) || + noConfigFile { + cfg.FunctionThreshold = v + } if v, _ := cmd.Flags().GetFloat64(TotalStatementThresholdFlag); cmd.Flags().Changed(TotalStatementThresholdFlag) { cfg.Total[config.StatementsSection] = v } if v, _ := cmd.Flags().GetFloat64(TotalBlockThresholdFlag); cmd.Flags().Changed(TotalBlockThresholdFlag) { cfg.Total[config.BlocksSection] = v } + if v, _ := cmd.Flags().GetFloat64(TotalFunctionThresholdFlag); cmd.Flags().Changed(TotalFunctionThresholdFlag) { + cfg.Total[config.FunctionsSection] = v + } if v, _ := cmd.Flags().GetString(SortByFlag); cmd.Flags().Changed(SortByFlag) || noConfigFile { cfg.SortBy = v @@ -483,6 +500,10 @@ func applyConfigOverrides(cfg *config.Config, cmd *cobra.Command, noConfigFile b cfg.Total[config.BlocksSection] == config.BlockThresholdDefault { cfg.Total[config.BlocksSection] = v } + if v, _ := cmd.Flags().GetFloat64(FunctionThresholdFlag); !cmd.Flags().Changed(TotalFunctionThresholdFlag) && + cfg.Total[config.FunctionsSection] == config.FunctionThresholdDefault { + cfg.Total[config.FunctionsSection] = v + } } func getVersion() string { @@ -552,6 +573,13 @@ func initFlags(cmd *cobra.Command) { BlockThresholdFlagUsage, ) + cmd.Flags().Float64P( + FunctionThresholdFlag, + FunctionThresholdFlagShort, + config.FunctionThresholdDefault, + FunctionThresholdFlagUsage, + ) + cmd.Flags().Float64P( TotalStatementThresholdFlag, TotalStatementThresholdFlagShort, @@ -566,6 +594,13 @@ func initFlags(cmd *cobra.Command) { TotalBlockThresholdFlagUsage, ) + cmd.Flags().Float64P( + TotalFunctionThresholdFlag, + TotalFunctionThresholdFlagShort, + 0, + TotalFunctionThresholdFlagUsage, + ) + cmd.Flags().String( SortByFlag, config.SortByDefault, diff --git a/pkg/compute/compute.go b/pkg/compute/compute.go index 6a0fb23..0f00c89 100644 --- a/pkg/compute/compute.go +++ b/pkg/compute/compute.go @@ -24,6 +24,7 @@ func collect(profiles []*cover.Profile, cfg *config.Config) (Results, bool) { // ByTotal: Totals{ Statements: TotalStatements{}, Blocks: TotalBlocks{}, + Functions: TotalFunctions{}, }, ByPackage: make([]ByPackage, 0), } @@ -39,8 +40,16 @@ func collect(profiles []*cover.Profile, cfg *config.Config) (Results, bool) { // blocks++ } + // Calculate function coverage + functions, functionHits, err := GetFunctionCoverageForFile(p.FileName, p.Blocks) + if err != nil { + // If we can't get function coverage, default to 0 + functions, functionHits = 0, 0 + } + stmtPct := math.Percent(stmtHits, stmts) blockPct := math.Percent(blockHits, blocks) + functionPct := math.Percent(functionHits, functions) stmtThreshold := cfg.StatementThreshold if t, ok := cfg.PerFile.Statements[p.FileName]; ok { @@ -54,6 +63,12 @@ func collect(profiles []*cover.Profile, cfg *config.Config) (Results, bool) { // } failed = failed || blockPct < blockThreshold + functionThreshold := cfg.FunctionThreshold + if t, ok := cfg.PerFile.Functions[p.FileName]; ok { + functionThreshold = t + } + failed = failed || functionPct < functionThreshold + if failed { hasFailure = true } @@ -63,15 +78,20 @@ func collect(profiles []*cover.Profile, cfg *config.Config) (Results, bool) { // By: By{ Statements: fmt.Sprintf("%d/%d", stmtHits, stmts), Blocks: fmt.Sprintf("%d/%d", blockHits, blocks), + Functions: fmt.Sprintf("%d/%d", functionHits, functions), StatementPercentage: stmtPct, StatementThreshold: stmtThreshold, BlockPercentage: blockPct, BlockThreshold: blockThreshold, + FunctionPercentage: functionPct, + FunctionThreshold: functionThreshold, Failed: failed, stmtHits: stmtHits, blockHits: blockHits, stmts: stmts, blocks: blocks, + functions: functions, + functionHits: functionHits, }, }) @@ -79,6 +99,8 @@ func collect(profiles []*cover.Profile, cfg *config.Config) (Results, bool) { // results.ByTotal.Statements.totalCoveredStatements += stmtHits results.ByTotal.Blocks.totalBlocks += blocks results.ByTotal.Blocks.totalCoveredBlocks += blockHits + results.ByTotal.Functions.totalFunctions += functions + results.ByTotal.Functions.totalCoveredFunctions += functionHits } sortFileResults(results.ByFile, cfg) @@ -88,7 +110,8 @@ func collect(profiles []*cover.Profile, cfg *config.Config) (Results, bool) { // return results, hasFailure || hasPackageFailure || results.ByTotal.Statements.Failed || - results.ByTotal.Blocks.Failed + results.ByTotal.Blocks.Failed || + results.ByTotal.Functions.Failed } func collectPackageResults(results *Results, cfg *config.Config) bool { @@ -104,6 +127,8 @@ func collectPackageResults(results *Results, cfg *config.Config) bool { p.blockHits += v.blockHits p.blocks += v.blocks p.stmts += v.stmts + p.functions += v.functions + p.functionHits += v.functionHits working[path.Dir(v.File)] = p } @@ -111,8 +136,10 @@ func collectPackageResults(results *Results, cfg *config.Config) bool { for _, v := range working { v.Statements = fmt.Sprintf("%d/%d", v.stmtHits, v.stmts) v.Blocks = fmt.Sprintf("%d/%d", v.blockHits, v.blocks) + v.Functions = fmt.Sprintf("%d/%d", v.functionHits, v.functions) v.StatementPercentage = math.Percent(v.stmtHits, v.stmts) v.BlockPercentage = math.Percent(v.blockHits, v.blocks) + v.FunctionPercentage = math.Percent(v.functionHits, v.functions) v.StatementThreshold = cfg.StatementThreshold if t, ok := cfg.PerPackage.Statements[v.Package]; ok { @@ -126,6 +153,12 @@ func collectPackageResults(results *Results, cfg *config.Config) bool { } v.Failed = v.Failed || v.BlockPercentage < v.BlockThreshold + v.FunctionThreshold = cfg.FunctionThreshold + if t, ok := cfg.PerPackage.Functions[v.Package]; ok { + v.FunctionThreshold = t + } + v.Failed = v.Failed || v.FunctionPercentage < v.FunctionThreshold + if v.Failed { hasFailed = true } @@ -150,6 +183,14 @@ func setTotals(results *Results, cfg *config.Config) { results.ByTotal.Blocks.Percentage = math.Percent(results.ByTotal.Blocks.totalCoveredBlocks, results.ByTotal.Blocks.totalBlocks) results.ByTotal.Blocks.Failed = results.ByTotal.Blocks.Percentage < results.ByTotal.Blocks.Threshold + + results.ByTotal.Functions.Threshold = cfg.Total[config.FunctionsSection] + results.ByTotal.Functions.Coverage = + fmt.Sprintf("%d/%d", results.ByTotal.Functions.totalCoveredFunctions, + results.ByTotal.Functions.totalFunctions) + results.ByTotal.Functions.Percentage = math.Percent(results.ByTotal.Functions.totalCoveredFunctions, + results.ByTotal.Functions.totalFunctions) + results.ByTotal.Functions.Failed = results.ByTotal.Functions.Percentage < results.ByTotal.Functions.Threshold } func sortBy[T HasBy](results []T, cfg *config.Config) { @@ -170,6 +211,11 @@ func sortBy[T HasBy](results []T, cfg *config.Config) { return byI.BlockPercentage > byJ.BlockPercentage } return byI.BlockPercentage < byJ.BlockPercentage + case config.SortByFunctionPercent: + if sortByDesc { + return byI.FunctionPercentage > byJ.FunctionPercentage + } + return byI.FunctionPercentage < byJ.FunctionPercentage case config.SortByStatements: if sortByDesc { return byI.stmtHits > byJ.stmtHits @@ -180,6 +226,11 @@ func sortBy[T HasBy](results []T, cfg *config.Config) { return byI.blockHits > byJ.blockHits } return byI.blockHits < byJ.blockHits + case config.SortByFunctions: + if sortByDesc { + return byI.functionHits > byJ.functionHits + } + return byI.functionHits < byJ.functionHits default: return false } @@ -188,7 +239,7 @@ func sortBy[T HasBy](results []T, cfg *config.Config) { func sortFileResults(results []ByFile, cfg *config.Config) { switch cfg.SortBy { - case config.SortByStatementPercent, config.SortByBlockPercent, config.SortByStatements, config.SortByBlocks: + case config.SortByStatementPercent, config.SortByBlockPercent, config.SortByFunctionPercent, config.SortByStatements, config.SortByBlocks, config.SortByFunctions: sortBy(results, cfg) return default: @@ -205,7 +256,7 @@ func sortFileResults(results []ByFile, cfg *config.Config) { func sortPackageResults(results []ByPackage, cfg *config.Config) { switch cfg.SortBy { - case config.SortByStatementPercent, config.SortByBlockPercent, config.SortByStatements, config.SortByBlocks: + case config.SortByStatementPercent, config.SortByBlockPercent, config.SortByFunctionPercent, config.SortByStatements, config.SortByBlocks, config.SortByFunctions: sortBy(results, cfg) return default: diff --git a/pkg/compute/functions.go b/pkg/compute/functions.go new file mode 100644 index 0000000..739d88a --- /dev/null +++ b/pkg/compute/functions.go @@ -0,0 +1,150 @@ +package compute + +import ( + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "strings" + + "golang.org/x/tools/cover" +) + +// FunctionInfo holds information about a function's location and coverage. +type FunctionInfo struct { + Name string + StartLine int + EndLine int + Covered bool +} + +// CountFunctionsInFile counts the number of functions declared in a Go source file. +func CountFunctionsInFile(filename string) ([]FunctionInfo, error) { + fset := token.NewFileSet() + + // Parse the Go source file + node, err := parser.ParseFile(fset, filename, nil, parser.ParseComments) + if err != nil { + return nil, err + } + + var functions []FunctionInfo + + // Walk the AST to find function declarations + ast.Inspect(node, func(n ast.Node) bool { + switch x := n.(type) { + case *ast.FuncDecl: + if x.Name != nil { + pos := fset.Position(x.Pos()) + end := fset.Position(x.End()) + + funcName := x.Name.Name + // Include receiver type for methods + if x.Recv != nil && len(x.Recv.List) > 0 { + if recv := x.Recv.List[0]; recv.Type != nil { + funcName = getTypeName(recv.Type) + "." + funcName + } + } + + functions = append(functions, FunctionInfo{ + Name: funcName, + StartLine: pos.Line, + EndLine: end.Line, + Covered: false, // Will be determined later by coverage analysis + }) + } + } + return true + }) + + return functions, nil +} + +// getTypeName extracts the type name from an AST expression. +func getTypeName(expr ast.Expr) string { + switch x := expr.(type) { + case *ast.Ident: + return x.Name + case *ast.StarExpr: + return "*" + getTypeName(x.X) + case *ast.SelectorExpr: + return getTypeName(x.X) + "." + x.Sel.Name + default: + return "unknown" + } +} + +// MatchFunctionsWithCoverage determines which functions are covered based on coverage blocks. +func MatchFunctionsWithCoverage(functions []FunctionInfo, blocks []cover.ProfileBlock) []FunctionInfo { + result := make([]FunctionInfo, len(functions)) + copy(result, functions) + + // For each function, check if any coverage block within its range has count > 0 + for i := range result { + for _, block := range blocks { + // Check if this block overlaps with the function's line range + if block.StartLine >= result[i].StartLine && block.StartLine <= result[i].EndLine { + if block.Count > 0 { + result[i].Covered = true + break + } + } + } + } + + return result +} + +// GetFunctionCoverageForFile returns function coverage statistics for a file. +func GetFunctionCoverageForFile(filename string, coverageBlocks []cover.ProfileBlock) (totalFunctions, coveredFunctions int, err error) { + // Skip non-Go files + if !strings.HasSuffix(filename, ".go") { + return 0, 0, nil + } + + // Get the actual source file path from the coverage filename + // Coverage filenames might be module-relative paths + sourcePath := filename + if !filepath.IsAbs(filename) { + // Try to find the file relative to current directory + // This is a simple approach - in a real implementation you might want + // to handle module paths more sophisticatedly + if _, err := parser.ParseFile(token.NewFileSet(), filename, nil, parser.PackageClauseOnly); err != nil { + // If we can't parse the file, assume 0 functions + return 0, 0, nil + } + sourcePath = filename + } + + functions, err := CountFunctionsInFile(sourcePath) + if err != nil { + // If we can't parse the source file, we can't count functions + // This might happen for generated files or files outside the module + return 0, 0, nil + } + + // Convert coverage blocks to our internal format + blocks := make([]cover.ProfileBlock, len(coverageBlocks)) + for i, block := range coverageBlocks { + blocks[i] = cover.ProfileBlock{ + StartLine: block.StartLine, + StartCol: block.StartCol, + EndLine: block.EndLine, + EndCol: block.EndCol, + NumStmt: block.NumStmt, + Count: block.Count, + } + } + + // Match functions with coverage + functionsWithCoverage := MatchFunctionsWithCoverage(functions, blocks) + + totalFunctions = len(functionsWithCoverage) + for _, fn := range functionsWithCoverage { + if fn.Covered { + coveredFunctions++ + } + } + + return totalFunctions, coveredFunctions, nil +} \ No newline at end of file diff --git a/pkg/compute/functions_test.go b/pkg/compute/functions_test.go new file mode 100644 index 0000000..d2aea01 --- /dev/null +++ b/pkg/compute/functions_test.go @@ -0,0 +1,47 @@ +package compute_test + +import ( + "testing" + + "github.com/mach6/go-covercheck/pkg/compute" + "github.com/stretchr/testify/require" + "golang.org/x/tools/cover" +) + +func TestGetFunctionCoverageForFile(t *testing.T) { + // Test with a Go file that doesn't exist + total, covered, err := compute.GetFunctionCoverageForFile("nonexistent.go", nil) + require.NoError(t, err) + require.Equal(t, 0, total) + require.Equal(t, 0, covered) + + // Test with a non-Go file + total, covered, err = compute.GetFunctionCoverageForFile("readme.txt", nil) + require.NoError(t, err) + require.Equal(t, 0, total) + require.Equal(t, 0, covered) +} + +func TestCountFunctionsInFile(t *testing.T) { + // Test with a non-existent file + functions, err := compute.CountFunctionsInFile("nonexistent.go") + require.Error(t, err) + require.Nil(t, functions) +} + +func TestMatchFunctionsWithCoverage(t *testing.T) { + functions := []compute.FunctionInfo{ + {Name: "func1", StartLine: 1, EndLine: 5, Covered: false}, + {Name: "func2", StartLine: 10, EndLine: 15, Covered: false}, + } + + blocks := []cover.ProfileBlock{ + {StartLine: 2, StartCol: 1, EndLine: 3, EndCol: 10, Count: 1}, + {StartLine: 12, StartCol: 1, EndLine: 13, EndCol: 10, Count: 0}, + } + + result := compute.MatchFunctionsWithCoverage(functions, blocks) + require.Len(t, result, 2) + require.True(t, result[0].Covered) // func1 should be covered (block with Count: 1) + require.False(t, result[1].Covered) // func2 should not be covered (block with Count: 0) +} \ No newline at end of file diff --git a/pkg/compute/model.go b/pkg/compute/model.go index f26bfb4..7a36f92 100644 --- a/pkg/compute/model.go +++ b/pkg/compute/model.go @@ -7,14 +7,18 @@ type HasBy interface { // By holds cover.Profile information. type By struct { - Statements string `json:"statementCoverage" yaml:"statementCoverage"` - Blocks string `json:"blockCoverage" yaml:"blockCoverage"` - StatementPercentage float64 `json:"statementPercentage" yaml:"statementPercentage"` - BlockPercentage float64 `json:"blockPercentage" yaml:"blockPercentage"` - StatementThreshold float64 `json:"statementThreshold" yaml:"statementThreshold"` - BlockThreshold float64 `json:"blockThreshold" yaml:"blockThreshold"` - Failed bool `json:"failed" yaml:"failed"` - stmts, blocks, stmtHits, blockHits int + Statements string `json:"statementCoverage" yaml:"statementCoverage"` + Blocks string `json:"blockCoverage" yaml:"blockCoverage"` + Functions string `json:"functionCoverage" yaml:"functionCoverage"` + StatementPercentage float64 `json:"statementPercentage" yaml:"statementPercentage"` + BlockPercentage float64 `json:"blockPercentage" yaml:"blockPercentage"` + FunctionPercentage float64 `json:"functionPercentage" yaml:"functionPercentage"` + StatementThreshold float64 `json:"statementThreshold" yaml:"statementThreshold"` + BlockThreshold float64 `json:"blockThreshold" yaml:"blockThreshold"` + FunctionThreshold float64 `json:"functionThreshold" yaml:"functionThreshold"` + Failed bool `json:"failed" yaml:"failed"` + stmts, blocks, stmtHits, blockHits int + functions, functionHits int } // ByFile holds information for a cover.Profile result of a file. @@ -43,6 +47,7 @@ func (f ByPackage) GetBy() By { type Totals struct { Statements TotalStatements `json:"statements" yaml:"statements"` Blocks TotalBlocks `json:"blocks" yaml:"blocks"` + Functions TotalFunctions `json:"functions" yaml:"functions"` } // TotalBlocks holds cover.Profile total block results. @@ -65,6 +70,16 @@ type TotalStatements struct { totalStatements int } +// TotalFunctions holds cover.Profile total function results. +type TotalFunctions struct { + totalFunctions int + totalCoveredFunctions int + Coverage string `json:"coverage" yaml:"coverage"` + Threshold float64 `json:"threshold" yaml:"threshold"` + Percentage float64 `json:"percentage" yaml:"percentage"` + Failed bool `json:"failed" yaml:"failed"` +} + // Results holds information for all stats collected form the cover.Profile data. type Results struct { ByFile []ByFile `json:"byFile" yaml:"byFile"` diff --git a/pkg/compute/model_test.go b/pkg/compute/model_test.go index 9260ba1..1bb30c6 100644 --- a/pkg/compute/model_test.go +++ b/pkg/compute/model_test.go @@ -17,10 +17,13 @@ const ( { "statementCoverage": "150/150", "blockCoverage": "1/1", + "functionCoverage": "0/0", "statementPercentage": 100, "blockPercentage": 100, - "statementThreshold": 0, - "blockThreshold": 0, + "functionPercentage": 100, + "statementThreshold": 70, + "blockThreshold": 50, + "functionThreshold": 60, "failed": false, "file": "foo" } @@ -29,10 +32,13 @@ const ( { "statementCoverage": "150/150", "blockCoverage": "1/1", + "functionCoverage": "0/0", "statementPercentage": 100, "blockPercentage": 100, - "statementThreshold": 0, - "blockThreshold": 0, + "functionPercentage": 100, + "statementThreshold": 70, + "blockThreshold": 50, + "functionThreshold": 60, "failed": false, "package": "." } @@ -40,13 +46,19 @@ const ( "byTotal": { "statements": { "coverage": "150/150", - "threshold": 0, + "threshold": 70, "percentage": 100, "failed": false }, "blocks": { "coverage": "1/1", - "threshold": 0, + "threshold": 50, + "percentage": 100, + "failed": false + }, + "functions": { + "coverage": "0/0", + "threshold": 60, "percentage": 100, "failed": false } @@ -56,30 +68,41 @@ const ( expectYAML = `byFile: - statementCoverage: 150/150 blockCoverage: 1/1 + functionCoverage: 0/0 statementPercentage: 100 blockPercentage: 100 - statementThreshold: 0 - blockThreshold: 0 + functionPercentage: 100 + statementThreshold: 70 + blockThreshold: 50 + functionThreshold: 60 failed: false file: foo byPackage: - statementCoverage: 150/150 blockCoverage: 1/1 + functionCoverage: 0/0 statementPercentage: 100 blockPercentage: 100 - statementThreshold: 0 - blockThreshold: 0 + functionPercentage: 100 + statementThreshold: 70 + blockThreshold: 50 + functionThreshold: 60 failed: false package: . byTotal: statements: coverage: 150/150 - threshold: 0 + threshold: 70 percentage: 100 failed: false blocks: coverage: 1/1 - threshold: 0 + threshold: 50 + percentage: 100 + failed: false + functions: + coverage: 0/0 + threshold: 60 percentage: 100 failed: false ` @@ -123,7 +146,9 @@ func TestModelMarshalYaml(t *testing.T) { }, } - r, _ := compute.CollectResults(profiles, new(config.Config)) + cfg := new(config.Config) + cfg.ApplyDefaults() + r, _ := compute.CollectResults(profiles, cfg) out, err := yaml.Marshal(r) require.NoError(t, err) require.NotEmpty(t, out) @@ -148,7 +173,9 @@ func TestModelMarshalJson(t *testing.T) { }, } - r, _ := compute.CollectResults(profiles, new(config.Config)) + cfg := new(config.Config) + cfg.ApplyDefaults() + r, _ := compute.CollectResults(profiles, cfg) out, err := json.MarshalIndent(r, "", " ") require.NoError(t, err) require.NotEmpty(t, out) diff --git a/pkg/config/config.go b/pkg/config/config.go index b5e5c39..f5736db 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -23,8 +23,10 @@ const ( SortByFile = "file" SortByStatements = "statements" SortByBlocks = "blocks" + SortByFunctions = "functions" SortByStatementPercent = "statement-percent" SortByBlockPercent = "block-percent" + SortByFunctionPercent = "function-percent" SortByDefault = SortByFile SortOrderAsc = "asc" @@ -51,23 +53,30 @@ const ( BlockThresholdOff = thresholdOff BlockThresholdMax = thresholdMax + FunctionThresholdDefault = 60 + FunctionThresholdOff = thresholdOff + FunctionThresholdMax = thresholdMax + StatementsSection = "statements" BlocksSection = "blocks" + FunctionsSection = "functions" ) // PerOverride holds override per thresholds. type PerOverride map[string]float64 -// PerThresholdOverride holds PerOverride's for Statements and Blocks. +// PerThresholdOverride holds PerOverride's for Statements, Blocks, and Functions. type PerThresholdOverride struct { Statements PerOverride `yaml:"statements"` Blocks PerOverride `yaml:"blocks"` + Functions PerOverride `yaml:"functions"` } // Config for application. type Config struct { StatementThreshold float64 `yaml:"statementThreshold,omitempty"` BlockThreshold float64 `yaml:"blockThreshold,omitempty"` + FunctionThreshold float64 `yaml:"functionThreshold,omitempty"` SortBy string `yaml:"sortBy,omitempty"` SortOrder string `yaml:"sortOrder,omitempty"` Skip []string `yaml:"skip,omitempty"` @@ -106,6 +115,7 @@ func Load(path string) (*Config, error) { func (c *Config) ApplyDefaults() { c.StatementThreshold = StatementThresholdDefault c.BlockThreshold = BlockThresholdDefault + c.FunctionThreshold = FunctionThresholdDefault c.SortBy = SortByDefault c.SortOrder = SortOrderDefault c.Skip = []string{} @@ -113,7 +123,7 @@ func (c *Config) ApplyDefaults() { c.initPerFileWhenNil() c.initPerPackageWhenNil() - c.setTotalThresholds(StatementThresholdDefault, BlockThresholdDefault) + c.setTotalThresholds(StatementThresholdDefault, BlockThresholdDefault, FunctionThresholdDefault) } // Validate the config or return an error if it is not valid. @@ -124,13 +134,16 @@ func (c *Config) Validate() error { //nolint:cyclop if c.BlockThreshold < BlockThresholdOff || c.BlockThreshold > BlockThresholdMax { return errors.New("block threshold must be between 0 and 100") } + if c.FunctionThreshold < FunctionThresholdOff || c.FunctionThreshold > FunctionThresholdMax { + return errors.New("function threshold must be between 0 and 100") + } switch c.SortBy { - case SortByFile, SortByStatements, SortByBlocks, SortByStatementPercent, SortByBlockPercent: + case SortByFile, SortByStatements, SortByBlocks, SortByFunctions, SortByStatementPercent, SortByBlockPercent, SortByFunctionPercent: break default: - return fmt.Errorf("sort-by must be one of %s|%s|%s|%s|%s", - SortByFile, SortByStatements, SortByBlocks, SortByStatementPercent, SortByBlockPercent) + return fmt.Errorf("sort-by must be one of %s|%s|%s|%s|%s|%s|%s", + SortByFile, SortByStatements, SortByBlocks, SortByFunctions, SortByStatementPercent, SortByBlockPercent, SortByFunctionPercent) } switch c.SortOrder { @@ -158,7 +171,7 @@ func (c *Config) Validate() error { //nolint:cyclop c.initPerFileWhenNil() c.initPerPackageWhenNil() - c.setTotalThresholds(c.StatementThreshold, c.BlockThreshold) + c.setTotalThresholds(c.StatementThreshold, c.BlockThreshold, c.FunctionThreshold) return nil } @@ -170,6 +183,9 @@ func (c *Config) initPerFileWhenNil() { if c.PerFile.Statements == nil { c.PerFile.Statements = PerOverride{} } + if c.PerFile.Functions == nil { + c.PerFile.Functions = PerOverride{} + } } func (c *Config) initPerPackageWhenNil() { @@ -179,9 +195,12 @@ func (c *Config) initPerPackageWhenNil() { if c.PerPackage.Statements == nil { c.PerPackage.Statements = PerOverride{} } + if c.PerPackage.Functions == nil { + c.PerPackage.Functions = PerOverride{} + } } -func (c *Config) setTotalThresholds(totalStatement, totalBlock float64) { +func (c *Config) setTotalThresholds(totalStatement, totalBlock, totalFunction float64) { if c.Total == nil { c.Total = PerOverride{} } @@ -191,4 +210,7 @@ func (c *Config) setTotalThresholds(totalStatement, totalBlock float64) { if _, exists := c.Total[BlocksSection]; !exists { c.Total[BlocksSection] = totalBlock } + if _, exists := c.Total[FunctionsSection]; !exists { + c.Total[FunctionsSection] = totalFunction + } } diff --git a/pkg/output/history.go b/pkg/output/history.go index 34d4455..a912b65 100644 --- a/pkg/output/history.go +++ b/pkg/output/history.go @@ -37,7 +37,8 @@ func compareByPackage(results compute.Results, refEntry *history.Entry) bool { if curr.Package == prev.Package { //nolint:nestif s, ss := formatDelta(curr.StatementPercentage - prev.StatementPercentage) b, sb := formatDelta(curr.BlockPercentage - prev.BlockPercentage) - if ss || sb { + f, sf := formatDelta(curr.FunctionPercentage - prev.FunctionPercentage) + if ss || sb || sf { if !bPrintedPkg { fmt.Printf(" → By Package\n") bPrintedPkg = true @@ -51,6 +52,10 @@ func compareByPackage(results compute.Results, refEntry *history.Entry) bool { compareShowB() fmt.Printf("%s [%s]\n", curr.Package, b) } + if sf { + compareShowF() + fmt.Printf("%s [%s]\n", curr.Package, f) + } } } } @@ -63,8 +68,9 @@ func compareByTotal(results compute.Results, refEntry *history.Entry) bool { bPrintedTotal := false deltaS, okS := formatDelta(results.ByTotal.Statements.Percentage - refEntry.Results.ByTotal.Statements.Percentage) deltaB, okB := formatDelta(results.ByTotal.Blocks.Percentage - refEntry.Results.ByTotal.Blocks.Percentage) + deltaF, okF := formatDelta(results.ByTotal.Functions.Percentage - refEntry.Results.ByTotal.Functions.Percentage) - if okS || okB { + if okS || okB || okF { fmt.Printf(" → By Total\n") bPrintedTotal = true if okS { @@ -75,6 +81,10 @@ func compareByTotal(results compute.Results, refEntry *history.Entry) bool { compareShowB() fmt.Printf("total [%s]\n", deltaB) } + if okF { + compareShowF() + fmt.Printf("total [%s]\n", deltaF) + } } return bPrintedTotal } @@ -87,7 +97,8 @@ func compareByFile(results compute.Results, refEntry *history.Entry) bool { if curr.File == prev.File { //nolint:nestif s, ss := formatDelta(curr.StatementPercentage - prev.StatementPercentage) b, sb := formatDelta(curr.BlockPercentage - prev.BlockPercentage) - if ss || sb { + f, sf := formatDelta(curr.FunctionPercentage - prev.FunctionPercentage) + if ss || sb || sf { if !bPrintedFile { fmt.Printf(" → By File\n") bPrintedFile = true @@ -101,6 +112,10 @@ func compareByFile(results compute.Results, refEntry *history.Entry) bool { compareShowB() fmt.Printf("%s [%s]\n", curr.File, b) } + if sf { + compareShowF() + fmt.Printf("%s [%s]\n", curr.File, f) + } } } } @@ -173,6 +188,8 @@ func ShowHistory(h *history.History, limit int, cfg *config.Config) { entry.Results.ByTotal.Statements.Threshold) blockColor := severityColor(entry.Results.ByTotal.Blocks.Percentage, entry.Results.ByTotal.Blocks.Threshold) + funcColor := severityColor(entry.Results.ByTotal.Functions.Percentage, + entry.Results.ByTotal.Functions.Threshold) wrapTextWidth := 20 t.AppendRow(table.Row{ @@ -182,7 +199,8 @@ func ShowHistory(h *history.History, limit int, cfg *config.Config) { fmt.Sprintf("%-15s", wrapText(strings.Join(entry.Tags, ", "), wrapTextWidth)), wrapText(fmt.Sprintf("%-15s", entry.Label), wrapTextWidth), stmtColor(fmt.Sprintf("%-7s", entry.Results.ByTotal.Statements.Coverage)) + " [S]\n" + - blockColor(fmt.Sprintf("%-7s", entry.Results.ByTotal.Blocks.Coverage)) + " [B]", + blockColor(fmt.Sprintf("%-7s", entry.Results.ByTotal.Blocks.Coverage)) + " [B]\n" + + funcColor(fmt.Sprintf("%-7s", entry.Results.ByTotal.Functions.Coverage)) + " [F]", }) } @@ -234,3 +252,7 @@ func compareShowS() { func compareShowB() { fmt.Printf(" [%s] ", color.New(color.FgHiMagenta).Sprint("B")) } + +func compareShowF() { + fmt.Printf(" [%s] ", color.New(color.FgGreen).Sprint("F")) +} diff --git a/pkg/output/history_test.go b/pkg/output/history_test.go index 689ba83..26f7fe9 100644 --- a/pkg/output/history_test.go +++ b/pkg/output/history_test.go @@ -33,12 +33,15 @@ func TestCompareHistory(t *testing.T) { → By File [S] github.com/mach6/go-covercheck/pkg/math/math.go [−25.0%] [B] github.com/mach6/go-covercheck/pkg/math/math.go [−25.0%] + [F] github.com/mach6/go-covercheck/pkg/math/math.go [+100.0%] → By Package [S] github.com/mach6/go-covercheck/pkg/math [−25.0%] [B] github.com/mach6/go-covercheck/pkg/math [−25.0%] + [F] github.com/mach6/go-covercheck/pkg/math [+100.0%] → By Total [S] total [+22.2%] [B] total [+26.8%] + [F] total [+100.0%] `, stdout) } @@ -55,6 +58,7 @@ func TestShowHistory(t *testing.T) { ├────────────┼─────────┼─────────────────┼─────────────────┼─────────────────┼─────────────┤ │ 2025-07-18 │ e402629 │ main │ │ │ 180/648 [S] │ │ │ │ │ │ │ 95/409 [B] │ +│ │ │ │ │ │ [F] │ └────────────┴─────────┴─────────────────┴─────────────────┴─────────────────┴─────────────┘ ≡ Showing last 1 history entry `, stdout) diff --git a/pkg/output/summary.go b/pkg/output/summary.go index 532c78b..4a4b296 100644 --- a/pkg/output/summary.go +++ b/pkg/output/summary.go @@ -29,7 +29,7 @@ func renderSummary(hasFailure bool, results compute.Results, cfg *config.Config) } func renderTotal(results compute.Results) { - if !results.ByTotal.Statements.Failed && !results.ByTotal.Blocks.Failed { + if !results.ByTotal.Statements.Failed && !results.ByTotal.Blocks.Failed && !results.ByTotal.Functions.Failed { return } @@ -57,6 +57,18 @@ func renderTotal(results compute.Results) { color.New(color.FgHiMagenta).Sprintf("%.1f%%", totalExpect), ) } + + totalExpect = results.ByTotal.Functions.Threshold + percentTotalFunctions := results.ByTotal.Functions.Percentage + if percentTotalFunctions < totalExpect { + gap := totalExpect - percentTotalFunctions + _, _ = fmt.Printf(msgF, + color.New(color.FgHiYellow).Sprint("F"), + "total", + severityColor(percentTotalFunctions, totalExpect)(fmt.Sprintf("%.1f%%", gap)), + color.New(color.FgHiYellow).Sprintf("%.1f%%", totalExpect), + ) + } } func renderByPackage(results compute.Results) { @@ -113,4 +125,14 @@ func renderBy[T compute.HasBy](by T, item string) { color.New(color.FgHiMagenta).Sprintf("%.1f%%", r.BlockThreshold), ) } + + if r.FunctionPercentage < r.FunctionThreshold { + gap := r.FunctionThreshold - r.FunctionPercentage + _, _ = fmt.Printf(msgF, + color.New(color.FgHiYellow).Sprint("F"), + item, + severityColor(r.FunctionPercentage, r.FunctionThreshold)(fmt.Sprintf("%.1f%%", gap)), + color.New(color.FgHiYellow).Sprintf("%.1f%%", r.FunctionThreshold), + ) + } } diff --git a/pkg/output/table.go b/pkg/output/table.go index 60b9fdf..5be8406 100644 --- a/pkg/output/table.go +++ b/pkg/output/table.go @@ -52,7 +52,7 @@ func renderTable(results compute.Results, cfg *config.Config) { }, ) - t.AppendHeader(table.Row{"", "Statements", "Blocks", "Statement %", "Block %"}) + t.AppendHeader(table.Row{"", "Statements", "Blocks", "Functions", "Statement %", "Block %", "Function %"}) t.AppendSeparator() t.AppendRow(table.Row{text.Bold.Sprint("BY FILE")}) @@ -66,27 +66,35 @@ func renderTable(results compute.Results, cfg *config.Config) { AlignFooter: text.AlignRight, WidthMin: fixedWidth}, {Name: "Blocks", Align: text.AlignRight, AlignFooter: text.AlignRight, WidthMin: fixedWidth}, + {Name: "Functions", Align: text.AlignRight, + AlignFooter: text.AlignRight, WidthMin: fixedWidth}, {Name: "Statement %", Align: text.AlignRight, AlignFooter: text.AlignRight, WidthMax: fixedWidth, WidthMin: fixedWidth}, {Name: "Block %", Align: text.AlignRight, AlignFooter: text.AlignRight, WidthMax: fixedWidth, WidthMin: fixedWidth}, + {Name: "Function %", Align: text.AlignRight, + AlignFooter: text.AlignRight, WidthMax: fixedWidth, WidthMin: fixedWidth}, }) for _, r := range results.ByFile { stmtColor := severityColor(r.StatementPercentage, r.StatementThreshold) blockColor := severityColor(r.BlockPercentage, r.BlockThreshold) + functionColor := severityColor(r.FunctionPercentage, r.FunctionThreshold) t.AppendRow(table.Row{ r.File, r.Statements, r.Blocks, + r.Functions, stmtColor(fmt.Sprintf("%.1f", r.StatementPercentage)), blockColor(fmt.Sprintf("%.1f", r.BlockPercentage)), + functionColor(fmt.Sprintf("%.1f", r.FunctionPercentage)), }) } stmtColor := severityColor(results.ByTotal.Statements.Percentage, results.ByTotal.Statements.Threshold) blockColor := severityColor(results.ByTotal.Blocks.Percentage, results.ByTotal.Blocks.Threshold) + functionColor := severityColor(results.ByTotal.Functions.Percentage, results.ByTotal.Functions.Threshold) t.AppendSeparator() t.AppendRow(table.Row{text.Bold.Sprint("BY PACKAGE")}) @@ -95,13 +103,16 @@ func renderTable(results compute.Results, cfg *config.Config) { for _, r := range results.ByPackage { stmtColor := severityColor(r.StatementPercentage, r.StatementThreshold) blockColor := severityColor(r.BlockPercentage, r.BlockThreshold) + functionColor := severityColor(r.FunctionPercentage, r.FunctionThreshold) t.AppendRow(table.Row{ r.Package, r.Statements, r.Blocks, + r.Functions, stmtColor(fmt.Sprintf("%.1f", r.StatementPercentage)), blockColor(fmt.Sprintf("%.1f", r.BlockPercentage)), + functionColor(fmt.Sprintf("%.1f", r.FunctionPercentage)), }) } @@ -113,8 +124,10 @@ func renderTable(results compute.Results, cfg *config.Config) { "", text.Bold.Sprint(results.ByTotal.Statements.Coverage), text.Bold.Sprint(results.ByTotal.Blocks.Coverage), + text.Bold.Sprint(results.ByTotal.Functions.Coverage), stmtColor(text.Bold.Sprintf("%.1f", results.ByTotal.Statements.Percentage)), blockColor(text.Bold.Sprintf("%.1f", results.ByTotal.Blocks.Percentage)), + functionColor(text.Bold.Sprintf("%.1f", results.ByTotal.Functions.Percentage)), }) switch cfg.Format { diff --git a/samples/.go-covercheck.yml b/samples/.go-covercheck.yml index 8c3d8c7..f33c987 100644 --- a/samples/.go-covercheck.yml +++ b/samples/.go-covercheck.yml @@ -15,8 +15,14 @@ statementThreshold: 65.0 # disabled with 0 blockThreshold: 60.0 +# global threshold % for function coverage. +# global default for per file %, per package %, and total % +# default 60. +# disabled with 0 +functionThreshold: 55.0 + # sortBy condition for table output formats. -# file|blocks|statements|statement-percent|block-percent +# file|blocks|statements|functions|statement-percent|block-percent|function-percent # default file sortBy: statement-percent @@ -48,7 +54,7 @@ format: table terminalWidth: 0 # per-file threshold overrides -# default {"statements": {}, "blocks": {}} +# default {"statements": {}, "blocks": {}, "functions": {}} # disabled with 0 perFile: statements: @@ -57,9 +63,12 @@ perFile: blocks: # main.go: 0 # cmd/root.go: 20 + functions: +# main.go: 0 +# cmd/root.go: 80 # per-package threshold overrides -# default {"statements": {}, "blocks": {}} +# default {"statements": {}, "blocks": {}, "functions": {}} # disabled with 0 perPackage: statements: @@ -67,13 +76,16 @@ perPackage: blocks: # pkg/config: 10 # pkg/formatter: 0 + functions: +# pkg/config: 50 # total threshold overrides -# default {"statements": statementThreshold, "blocks": blockThreshold} +# default {"statements": statementThreshold, "blocks": blockThreshold, "functions": functionThreshold} # disabled with 0 total: statements: 65.0 blocks: 60 + functions: 55.0 # skip package(s) and/or file(s) regex # default []