From 412a460f825ba755292cf462864a886fb11bd63b Mon Sep 17 00:00:00 2001 From: keyskey <39644776+keyskey@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:23:30 +0900 Subject: [PATCH 01/10] Update README.md to provide a detailed description of gobce, including its functionality and coverage estimation capabilities. --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d102230..6b6c5dd 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,9 @@ # gobce -Estimate branch coverage in Golang project from coverprofiles using lightweight AST/CFG analysis. + +gobce (Go Branch Coverage Estimator) is a standalone CLI that estimates C1 branch coverage for Go projects. +It combines `go test -coverprofile` output with lightweight AST/CFG analysis to produce: +- statement coverage +- estimated C1 branch coverage +- uncovered branch findings + +> Note: gobce reports **estimated C1**, not exact branch coverage, because Go coverprofiles are statement/block-oriented. \ No newline at end of file From 742004ed38d40337af6424666ac192b3f8954dda Mon Sep 17 00:00:00 2001 From: keyskey <39644776+keyskey@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:38:34 +0900 Subject: [PATCH 02/10] Ignore local development artifacts. Exclude local toolchain files, local environment config, coverage output, and build artifacts from version control. Made-with: Cursor --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..227a481 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.tools/ +.envrc +coverage.out +/gobce +dist/ From f956a7d0dd5ab6889a4df99101f7dfd4b5cbf4ba Mon Sep 17 00:00:00 2001 From: keyskey <39644776+keyskey@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:38:38 +0900 Subject: [PATCH 03/10] Add gobce analyzer core and CLI entrypoint. Introduce the public Analyze API, internal analyzer pipeline, tests, and CLI analyze command to run branch coverage estimation from coverprofile data. Made-with: Cursor --- analyze.go | 29 +++++++ analyzer_test.go | 60 +++++++++++++ cmd/gobce/main.go | 54 ++++++++++++ go.mod | 3 + internal/analyzer/analyze.go | 47 ++++++++++ internal/analyzer/branches.go | 133 +++++++++++++++++++++++++++++ internal/analyzer/coverage.go | 20 +++++ internal/analyzer/coverprofile.go | 111 ++++++++++++++++++++++++ internal/analyzer/doc.go | 3 + internal/analyzer/path_resolver.go | 80 +++++++++++++++++ internal/analyzer/types.go | 34 ++++++++ types.go | 19 +++++ 12 files changed, 593 insertions(+) create mode 100644 analyze.go create mode 100644 analyzer_test.go create mode 100644 cmd/gobce/main.go create mode 100644 go.mod create mode 100644 internal/analyzer/analyze.go create mode 100644 internal/analyzer/branches.go create mode 100644 internal/analyzer/coverage.go create mode 100644 internal/analyzer/coverprofile.go create mode 100644 internal/analyzer/doc.go create mode 100644 internal/analyzer/path_resolver.go create mode 100644 internal/analyzer/types.go create mode 100644 types.go diff --git a/analyze.go b/analyze.go new file mode 100644 index 0000000..588f5d4 --- /dev/null +++ b/analyze.go @@ -0,0 +1,29 @@ +package gobce + +import "github.com/keyskey/gobce/internal/analyzer" + +func Analyze(input AnalyzeInput) (Result, error) { + analysisResult, err := analyzer.Analyze(analyzer.Input{ + CoverProfilePath: input.CoverProfilePath, + }) + if err != nil { + return Result{}, err + } + + uncovered := make([]UncoveredBranch, 0, len(analysisResult.UncoveredBranches)) + for _, b := range analysisResult.UncoveredBranches { + uncovered = append(uncovered, UncoveredBranch{ + File: b.File, + Line: b.Line, + Kind: b.Kind, + Recommendation: b.Recommendation, + }) + } + + return Result{ + Language: analysisResult.Language, + StatementCoverage: analysisResult.StatementCoverage, + EstimatedBranchCoverage: analysisResult.EstimatedBranchCoverage, + UncoveredBranches: uncovered, + }, nil +} diff --git a/analyzer_test.go b/analyzer_test.go new file mode 100644 index 0000000..3e456fc --- /dev/null +++ b/analyzer_test.go @@ -0,0 +1,60 @@ +package gobce + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestAnalyze(t *testing.T) { + tmp := t.TempDir() + srcPath := filepath.Join(tmp, "sample.go") + src := `package sample + +func score(v int) int { + if v > 10 { + return 1 + } else { + return 2 + } +} +` + if err := os.WriteFile(srcPath, []byte(src), 0o644); err != nil { + t.Fatalf("write source: %v", err) + } + + coverPath := filepath.Join(tmp, "coverage.out") + coverage := strings.Join([]string{ + "mode: set", + srcPath + ":3.23,4.13 1 1", + srcPath + ":4.13,5.3 1 1", + srcPath + ":6.8,8.3 1 0", + }, "\n") + if err := os.WriteFile(coverPath, []byte(coverage), 0o644); err != nil { + t.Fatalf("write coverage: %v", err) + } + + result, err := Analyze(AnalyzeInput{CoverProfilePath: coverPath}) + if err != nil { + t.Fatalf("analyze: %v", err) + } + + if result.Language != "go" { + t.Fatalf("language: got %q", result.Language) + } + if len(result.UncoveredBranches) == 0 { + t.Fatalf("expected uncovered branches") + } + + var hasIfFalse bool + for _, b := range result.UncoveredBranches { + if b.Kind == "if_false_path" { + hasIfFalse = true + break + } + } + if !hasIfFalse { + t.Fatalf("expected if_false_path in uncovered branches") + } +} diff --git a/cmd/gobce/main.go b/cmd/gobce/main.go new file mode 100644 index 0000000..24e0ae5 --- /dev/null +++ b/cmd/gobce/main.go @@ -0,0 +1,54 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + + "github.com/keyskey/gobce" +) + +func main() { + if len(os.Args) < 2 || os.Args[1] != "analyze" { + printUsage() + os.Exit(2) + } + + analyzeCmd := flag.NewFlagSet("analyze", flag.ExitOnError) + coverProfile := analyzeCmd.String("coverprofile", "", "path to go coverprofile") + outputFormat := analyzeCmd.String("format", "json", "output format (json)") + if err := analyzeCmd.Parse(os.Args[2:]); err != nil { + fmt.Fprintf(os.Stderr, "parse flags: %v\n", err) + os.Exit(2) + } + + if *coverProfile == "" { + fmt.Fprintln(os.Stderr, "--coverprofile is required") + os.Exit(2) + } + if *outputFormat != "json" { + fmt.Fprintf(os.Stderr, "unsupported format: %s\n", *outputFormat) + os.Exit(2) + } + + result, err := gobce.Analyze(gobce.AnalyzeInput{ + CoverProfilePath: *coverProfile, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "analyze failed: %v\n", err) + os.Exit(1) + } + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(result); err != nil { + fmt.Fprintf(os.Stderr, "encode result: %v\n", err) + os.Exit(1) + } +} + +func printUsage() { + fmt.Fprintln(os.Stderr, "usage:") + fmt.Fprintln(os.Stderr, " gobce analyze --coverprofile --format json") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5d457ef --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/keyskey/gobce + +go 1.26 diff --git a/internal/analyzer/analyze.go b/internal/analyzer/analyze.go new file mode 100644 index 0000000..4291373 --- /dev/null +++ b/internal/analyzer/analyze.go @@ -0,0 +1,47 @@ +package analyzer + +import "errors" + +func Analyze(input Input) (Result, error) { + if input.CoverProfilePath == "" { + return Result{}, errors.New("coverprofile path is required") + } + + blocks, err := parseCoverProfile(input.CoverProfilePath) + if err != nil { + return Result{}, err + } + + statementCoverage := computeStatementCoverage(blocks) + candidates, err := collectBranchCandidates(blocks) + if err != nil { + return Result{}, err + } + + covered := 0 + uncovered := make([]UncoveredBranch, 0) + for _, c := range candidates { + if c.Covered { + covered++ + continue + } + + uncovered = append(uncovered, UncoveredBranch{ + File: c.FilePath, + Line: c.Line, + Kind: c.Kind, + }) + } + + estimated := 100.0 + if len(candidates) > 0 { + estimated = percent(float64(covered), float64(len(candidates))) + } + + return Result{ + Language: "go", + StatementCoverage: statementCoverage, + EstimatedBranchCoverage: estimated, + UncoveredBranches: uncovered, + }, nil +} diff --git a/internal/analyzer/branches.go b/internal/analyzer/branches.go new file mode 100644 index 0000000..61df501 --- /dev/null +++ b/internal/analyzer/branches.go @@ -0,0 +1,133 @@ +package analyzer + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" +) + +func collectBranchCandidates(blocks []coverageBlock) ([]branchCandidate, error) { + byFile := map[string][]coverageBlock{} + for _, b := range blocks { + byFile[b.FilePath] = append(byFile[b.FilePath], b) + } + + result := make([]branchCandidate, 0) + for filePath, fileBlocks := range byFile { + candidates, err := collectFileBranchCandidates(filePath, fileBlocks) + if err != nil { + return nil, err + } + result = append(result, candidates...) + } + return result, nil +} + +func collectFileBranchCandidates(filePath string, blocks []coverageBlock) ([]branchCandidate, error) { + fset := token.NewFileSet() + parsed, err := parser.ParseFile(fset, filePath, nil, 0) + if err != nil { + return nil, fmt.Errorf("parse source %q: %w", filePath, err) + } + + candidates := make([]branchCandidate, 0) + ast.Inspect(parsed, func(node ast.Node) bool { + switch n := node.(type) { + case *ast.IfStmt: + candidates = append(candidates, branchCandidate{ + FilePath: filePath, + Line: fset.Position(n.Body.Lbrace).Line, + Kind: "if_true_path", + Covered: spanCovered(blocks, fset, n.Body.Pos(), n.Body.End()), + }) + + if n.Else != nil { + candidates = append(candidates, branchCandidate{ + FilePath: filePath, + Line: fset.Position(n.Else.Pos()).Line, + Kind: "if_false_path", + Covered: spanCovered(blocks, fset, n.Else.Pos(), n.Else.End()), + }) + } + + case *ast.SwitchStmt: + candidates = append(candidates, collectCaseClauseCandidates(filePath, fset, blocks, n.Body.List, "switch_case_path", "switch_default_path")...) + case *ast.TypeSwitchStmt: + candidates = append(candidates, collectCaseClauseCandidates(filePath, fset, blocks, n.Body.List, "type_switch_case_path", "type_switch_default_path")...) + case *ast.ForStmt: + bodyCovered := spanCovered(blocks, fset, n.Body.Pos(), n.Body.End()) + stmtCovered := spanCovered(blocks, fset, n.Pos(), n.Body.Lbrace) + candidates = append(candidates, branchCandidate{ + FilePath: filePath, + Line: fset.Position(n.Body.Lbrace).Line, + Kind: "for_body_entered", + Covered: bodyCovered, + }) + candidates = append(candidates, branchCandidate{ + FilePath: filePath, + Line: fset.Position(n.For).Line, + Kind: "for_body_not_entered", + Covered: stmtCovered && !bodyCovered, + }) + case *ast.RangeStmt: + bodyCovered := spanCovered(blocks, fset, n.Body.Pos(), n.Body.End()) + stmtCovered := spanCovered(blocks, fset, n.Pos(), n.Body.Lbrace) + candidates = append(candidates, branchCandidate{ + FilePath: filePath, + Line: fset.Position(n.Body.Lbrace).Line, + Kind: "range_body_entered", + Covered: bodyCovered, + }) + candidates = append(candidates, branchCandidate{ + FilePath: filePath, + Line: fset.Position(n.For).Line, + Kind: "range_body_not_entered", + Covered: stmtCovered && !bodyCovered, + }) + } + return true + }) + + return candidates, nil +} + +func collectCaseClauseCandidates(filePath string, fset *token.FileSet, blocks []coverageBlock, clauses []ast.Stmt, caseKind string, defaultKind string) []branchCandidate { + candidates := make([]branchCandidate, 0, len(clauses)) + for _, stmt := range clauses { + clause, ok := stmt.(*ast.CaseClause) + if !ok { + continue + } + kind := caseKind + if len(clause.List) == 0 { + kind = defaultKind + } + candidates = append(candidates, branchCandidate{ + FilePath: filePath, + Line: fset.Position(clause.Case).Line, + Kind: kind, + Covered: spanCovered(blocks, fset, clause.Pos(), clause.End()), + }) + } + return candidates +} + +func spanCovered(blocks []coverageBlock, fset *token.FileSet, start token.Pos, end token.Pos) bool { + startLine := fset.Position(start).Line + endLine := fset.Position(end).Line + + for _, b := range blocks { + if b.Count <= 0 { + continue + } + if linesOverlap(startLine, endLine, b.StartLine, b.EndLine) { + return true + } + } + return false +} + +func linesOverlap(aStart, aEnd, bStart, bEnd int) bool { + return aStart <= bEnd && bStart <= aEnd +} diff --git a/internal/analyzer/coverage.go b/internal/analyzer/coverage.go new file mode 100644 index 0000000..b5c6790 --- /dev/null +++ b/internal/analyzer/coverage.go @@ -0,0 +1,20 @@ +package analyzer + +func computeStatementCoverage(blocks []coverageBlock) float64 { + total := 0 + covered := 0 + for _, b := range blocks { + total += b.NumStmts + if b.Count > 0 { + covered += b.NumStmts + } + } + if total == 0 { + return 0 + } + return percent(float64(covered), float64(total)) +} + +func percent(numerator, denominator float64) float64 { + return (numerator / denominator) * 100 +} diff --git a/internal/analyzer/coverprofile.go b/internal/analyzer/coverprofile.go new file mode 100644 index 0000000..2c27e46 --- /dev/null +++ b/internal/analyzer/coverprofile.go @@ -0,0 +1,111 @@ +package analyzer + +import ( + "bufio" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +func parseCoverProfile(path string) ([]coverageBlock, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open coverprofile: %w", err) + } + defer f.Close() + + scanner := bufio.NewScanner(f) + blocks := make([]coverageBlock, 0) + lineNo := 0 + for scanner.Scan() { + lineNo++ + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + if lineNo == 1 && strings.HasPrefix(line, "mode:") { + continue + } + + b, err := parseCoverProfileLine(line) + if err != nil { + return nil, fmt.Errorf("parse coverprofile line %d: %w", lineNo, err) + } + blocks = append(blocks, b) + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("scan coverprofile: %w", err) + } + + if len(blocks) == 0 { + return nil, errors.New("coverprofile contains no coverage blocks") + } + + normalizedBlocks, err := normalizeBlockPaths(blocks, path) + if err != nil { + return nil, err + } + return normalizedBlocks, nil +} + +func parseCoverProfileLine(line string) (coverageBlock, error) { + parts := strings.Fields(line) + if len(parts) != 3 { + return coverageBlock{}, fmt.Errorf("unexpected coverprofile entry: %q", line) + } + + fileAndSpan := parts[0] + stmts, err := strconv.Atoi(parts[1]) + if err != nil { + return coverageBlock{}, fmt.Errorf("invalid num statements: %w", err) + } + count, err := strconv.Atoi(parts[2]) + if err != nil { + return coverageBlock{}, fmt.Errorf("invalid execution count: %w", err) + } + + colon := strings.LastIndex(fileAndSpan, ":") + if colon == -1 { + return coverageBlock{}, fmt.Errorf("missing ':' in span: %q", fileAndSpan) + } + filePath := fileAndSpan[:colon] + span := fileAndSpan[colon+1:] + + spanParts := strings.Split(span, ",") + if len(spanParts) != 2 { + return coverageBlock{}, fmt.Errorf("invalid span: %q", span) + } + + startLine, err := parseSpanLine(spanParts[0]) + if err != nil { + return coverageBlock{}, err + } + endLine, err := parseSpanLine(spanParts[1]) + if err != nil { + return coverageBlock{}, err + } + + return coverageBlock{ + FilePath: filepath.Clean(filePath), + StartLine: startLine, + EndLine: endLine, + NumStmts: stmts, + Count: count, + }, nil +} + +func parseSpanLine(pos string) (int, error) { + dot := strings.Index(pos, ".") + if dot == -1 { + return 0, fmt.Errorf("invalid position format: %q", pos) + } + line, err := strconv.Atoi(pos[:dot]) + if err != nil { + return 0, fmt.Errorf("invalid line in position: %w", err) + } + return line, nil +} diff --git a/internal/analyzer/doc.go b/internal/analyzer/doc.go new file mode 100644 index 0000000..2f88aa5 --- /dev/null +++ b/internal/analyzer/doc.go @@ -0,0 +1,3 @@ +package analyzer + +// Package analyzer implements gobce's core coverage analysis pipeline. diff --git a/internal/analyzer/path_resolver.go b/internal/analyzer/path_resolver.go new file mode 100644 index 0000000..ff393d4 --- /dev/null +++ b/internal/analyzer/path_resolver.go @@ -0,0 +1,80 @@ +package analyzer + +import ( + "os" + "path/filepath" + "strings" +) + +func normalizeBlockPaths(blocks []coverageBlock, coverProfilePath string) ([]coverageBlock, error) { + coverDir := filepath.Dir(coverProfilePath) + moduleRoot, modulePath := discoverModuleContext(coverDir) + + normalized := make([]coverageBlock, 0, len(blocks)) + for _, b := range blocks { + resolved := resolveSourcePath(b.FilePath, coverDir, moduleRoot, modulePath) + b.FilePath = filepath.Clean(resolved) + normalized = append(normalized, b) + } + return normalized, nil +} + +func discoverModuleContext(startDir string) (moduleRoot string, modulePath string) { + dir := filepath.Clean(startDir) + for { + goModPath := filepath.Join(dir, "go.mod") + raw, err := os.ReadFile(goModPath) + if err == nil { + return dir, parseModulePath(string(raw)) + } + + parent := filepath.Dir(dir) + if parent == dir { + return "", "" + } + dir = parent + } +} + +func parseModulePath(goModContent string) string { + for _, rawLine := range strings.Split(goModContent, "\n") { + line := strings.TrimSpace(rawLine) + if line == "" || strings.HasPrefix(line, "//") { + continue + } + if !strings.HasPrefix(line, "module ") { + continue + } + modulePath := strings.TrimSpace(strings.TrimPrefix(line, "module ")) + modulePath = strings.Trim(modulePath, "\"") + return modulePath + } + return "" +} + +func resolveSourcePath(rawPath string, coverDir string, moduleRoot string, modulePath string) string { + if filepath.IsAbs(rawPath) { + return rawPath + } + + relativeCandidate := filepath.Join(coverDir, rawPath) + if _, err := os.Stat(relativeCandidate); err == nil { + return relativeCandidate + } + + if moduleRoot != "" && modulePath != "" { + modulePrefix := modulePath + "/" + if rawPath == modulePath { + return moduleRoot + } + if strings.HasPrefix(rawPath, modulePrefix) { + suffix := strings.TrimPrefix(rawPath, modulePrefix) + moduleCandidate := filepath.Join(moduleRoot, filepath.FromSlash(suffix)) + if _, err := os.Stat(moduleCandidate); err == nil { + return moduleCandidate + } + } + } + + return rawPath +} diff --git a/internal/analyzer/types.go b/internal/analyzer/types.go new file mode 100644 index 0000000..782f71b --- /dev/null +++ b/internal/analyzer/types.go @@ -0,0 +1,34 @@ +package analyzer + +type Input struct { + CoverProfilePath string +} + +type Result struct { + Language string + StatementCoverage float64 + EstimatedBranchCoverage float64 + UncoveredBranches []UncoveredBranch +} + +type UncoveredBranch struct { + File string + Line int + Kind string + Recommendation string +} + +type coverageBlock struct { + FilePath string + StartLine int + EndLine int + NumStmts int + Count int +} + +type branchCandidate struct { + FilePath string + Line int + Kind string + Covered bool +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..3e561ea --- /dev/null +++ b/types.go @@ -0,0 +1,19 @@ +package gobce + +type AnalyzeInput struct { + CoverProfilePath string +} + +type Result struct { + Language string `json:"language"` + StatementCoverage float64 `json:"statementCoverage"` + EstimatedBranchCoverage float64 `json:"estimatedBranchCoverage"` + UncoveredBranches []UncoveredBranch `json:"uncoveredBranches"` +} + +type UncoveredBranch struct { + File string `json:"file"` + Line int `json:"line"` + Kind string `json:"kind"` + Recommendation string `json:"recommendation,omitempty"` +} From b1de576721bf17e8dfda0063cb381b750997e187 Mon Sep 17 00:00:00 2001 From: keyskey <39644776+keyskey@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:38:41 +0900 Subject: [PATCH 04/10] Add standalone gobce design documentation. Document gobce's scope, CLI usage, estimation model, and algorithm overview as a dedicated design note. Made-with: Cursor --- docs/gobce.md | 100 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 docs/gobce.md diff --git a/docs/gobce.md b/docs/gobce.md new file mode 100644 index 0000000..009c01f --- /dev/null +++ b/docs/gobce.md @@ -0,0 +1,100 @@ +# Go C1 カバレッジ計測ツール gobce + +Status: 初期コンセプトノート +Date: 2026-04-25 + +## 位置づけ + +`gobce` は Go 向けの C1 branch coverage estimator であり、独立した OSS library / CLI として提供する。 + +`gobce` は Golang Branch Coverage Estimator の略である。Go 標準の coverage 情報をもとに、C1 branch coverage を軽量に推定することを目的にする。 + +重要な方針: + +```text +gobce は独立した別リポジトリとして開発する。 +任意の CI / quality gate から利用できる汎用ツールとして設計する。 +``` + +## 目的 + +Go 標準の coverage は `go test -coverprofile` による statement/block coverage を提供する。しかし、Production Readiness の観点では「分岐がどれだけテストされているか」というより強いシグナルが欲しい。 + +`gobce` は Go の coverprofile と AST / 軽量 CFG 解析を組み合わせ、C1 branch coverage を推定する。 + +```text +go test -coverprofile + Go AST/CFG analysis + -> estimated branch coverage + -> uncovered branch findings +``` + +## Standalone CLI + +```bash +go test ./... -coverprofile coverage.out + +go run ./cmd/gobce analyze \ + --coverprofile coverage.out \ + --format json +``` + +出力例: + +```json +{ + "language": "go", + "statementCoverage": 82.1, + "estimatedBranchCoverage": 68.4, + "uncoveredBranches": [ + { + "file": "internal/order/validator.go", + "line": 42, + "kind": "if_false_path" + } + ] +} +``` + +## 初期解析スコープ + +MVP で扱う branch candidate: + +```text +if / else +switch / case / default +type switch +select +for / range の loop body entered vs not entered +&& / || による short-circuit boolean expression +``` + +将来的に扱う branch candidate: + +```text +panic / recover paths +defer-related paths +error-return conventions +table-test branch attribution +generated-code filtering improvements +``` + +## アルゴリズム概要 + +```text +1. Go coverprofile を parse する。 +2. go/parser と go/ast で Go package / file を parse する。 +3. 対応構文から branch candidate span を作る。 +4. coverage block と branch candidate span を対応付ける。 +5. 各 branch side が実行されたかを推定する。 +6. estimated C1 percentage を計算する。 +7. file, line, kind, recommendation 付きの uncovered branch finding を出す。 +``` + +## 重要な制約 + +最初のバージョンでは、結果を exact C1 ではなく estimated C1 と呼ぶ。Go の coverprofile は branch coverage 専用フォーマットではないため、シグナルの性質を正直に表現する。 + +```text +statement coverage = measured +estimated C1 = inferred from coverprofile + source analysis +``` From 42fea841cb0423dc0fba344c91e88a44212479fc Mon Sep 17 00:00:00 2001 From: keyskey <39644776+keyskey@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:38:44 +0900 Subject: [PATCH 05/10] Document installation flows and automate release publishing. Expand README for install and CI usage, and add GoReleaser plus a tag-triggered GitHub Actions workflow for GitHub Release artifacts. Made-with: Cursor --- .github/workflows/release.yml | 31 +++++++++++ .goreleaser.yml | 33 +++++++++++ README.md | 101 +++++++++++++++++++++++++++++++++- 3 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release.yml create mode 100644 .goreleaser.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..144c390 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,31 @@ +name: release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: "1.26.x" + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..b9f562e --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,33 @@ +version: 2 + +project_name: gobce + +before: + hooks: + - go mod tidy + - go test ./... + +builds: + - id: gobce + main: ./cmd/gobce + binary: gobce + goos: + - darwin + - linux + goarch: + - amd64 + - arm64 + +archives: + - id: archives + builds: + - gobce + formats: + - tar.gz + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + +checksum: + name_template: checksums.txt + +changelog: + use: git diff --git a/README.md b/README.md index 6b6c5dd..9f6ca9f 100644 --- a/README.md +++ b/README.md @@ -6,4 +6,103 @@ It combines `go test -coverprofile` output with lightweight AST/CFG analysis to - estimated C1 branch coverage - uncovered branch findings -> Note: gobce reports **estimated C1**, not exact branch coverage, because Go coverprofiles are statement/block-oriented. \ No newline at end of file +> Note: gobce reports **estimated C1**, not exact branch coverage, because Go coverprofiles are statement/block-oriented. + +## Setup + +Requirements: +- Go 1.26+ +- A Go project where `go test ./... -coverprofile coverage.out` works + +## Install + +Install via `go install`: + +```bash +go install github.com/keyskey/gobce/cmd/gobce@latest +``` + +For CI and team-wide reproducibility, pin a version tag: + +```bash +go install github.com/keyskey/gobce/cmd/gobce@v0.1.0 +``` + +## Binary Distribution + +Prebuilt binaries can also be distributed through GitHub Releases. +Recommended archive set: +- darwin-arm64 +- darwin-amd64 +- linux-arm64 +- linux-amd64 + +## CLI Usage + +```bash +go test ./... -coverprofile coverage.out +go run ./cmd/gobce analyze --coverprofile coverage.out --format json +``` + +Build an executable: + +```bash +go build -o gobce ./cmd/gobce +./gobce analyze --coverprofile coverage.out --format json +``` + +## GitHub Actions (CI) + +Minimal workflow example: + +```yaml +name: test-and-gobce + +on: + pull_request: + push: + branches: [main] + +jobs: + coverage: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.26.x" + + - name: Generate coverage profile + run: go test ./... -coverprofile coverage.out + + - name: Run gobce + run: go run github.com/keyskey/gobce/cmd/gobce@latest analyze --coverprofile coverage.out --format json +``` + +If you want to fail CI by threshold, combine JSON output with `jq`: + +```bash +go run github.com/keyskey/gobce/cmd/gobce@latest analyze --coverprofile coverage.out --format json > gobce.json +jq -e '.estimatedBranchCoverage >= 70' gobce.json +``` + +For stable CI behavior, prefer pinned versions (`@vX.Y.Z`) over `@latest`. + +## Release Flow + +This repository is configured to publish GitHub Releases via GoReleaser when a version tag is pushed. + +```bash +git tag v0.1.0 +git push origin v0.1.0 +``` + +After push: +- GitHub Actions workflow `release` runs automatically +- GitHub Release is created for `v0.1.0` +- OS/arch archives and `checksums.txt` are uploaded + +## Design + +Design notes are available in `docs/gobce.md`. \ No newline at end of file From e4276a90c151808c9cee6145d9c6376bf86c47e6 Mon Sep 17 00:00:00 2001 From: keyskey <39644776+keyskey@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:49:55 +0900 Subject: [PATCH 06/10] Add beginner-friendly gobce walkthrough documentation. Introduce a plain-language explanation of gobce's analysis flow with a concrete if-branch mapping example, and link it from README design docs. Made-with: Cursor --- README.md | 4 +- docs/how-gobce-works.md | 151 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 docs/how-gobce-works.md diff --git a/README.md b/README.md index 9f6ca9f..591a3e9 100644 --- a/README.md +++ b/README.md @@ -105,4 +105,6 @@ After push: ## Design -Design notes are available in `docs/gobce.md`. \ No newline at end of file +Design notes are available in: +- `docs/gobce.md` (concept and design notes) +- `docs/how-gobce-works.md` (beginner-friendly implementation walkthrough) \ No newline at end of file diff --git a/docs/how-gobce-works.md b/docs/how-gobce-works.md new file mode 100644 index 0000000..83410f6 --- /dev/null +++ b/docs/how-gobce-works.md @@ -0,0 +1,151 @@ +# gobce の仕組みをやさしく説明 + +このドキュメントは、`gobce` が内部的に何をやっているのかをできるだけ平易に説明するためのものです。 + +## まず一言でいうと + +`gobce` は次の 2 つを組み合わせて、分岐カバレッジ (C1) を「推定」します。 + +- `go test -coverprofile` の結果 +- ソースコードの構文解析 (AST) + +ここで大事なのは、`gobce` の C1 は **推定値 (estimated)** だということです。 + +## そもそも coverprofile とは? + +`go test -coverprofile coverage.out` を実行すると、 +「どのコード範囲が何回実行されたか」が記録されたテキストファイルができます。 + +ただし、これは主に statement/block ベースの情報です。 +「if の true 側と false 側が両方通ったか」を直接そのまま持っているわけではありません。 + +## なぜ AST 解析が必要? + +coverprofile だけだと「分岐そのもの」が見えにくいので、`gobce` はコードを読みます。 + +具体的には、Go の標準機能でコードを AST として解析して、 +以下のような分岐候補を探します。 + +- `if` の true 側 / false 側 +- `switch` / `type switch` の各 case +- `for` / `range` の「ループ本体に入ったか・入らなかったか」 + +つまり、 + +1. コード上で「分岐の場所」を見つける +2. coverprofile で「その範囲が実行されたか」を当てる + +という 2 段構えです。 + +## 実際の処理フロー + +`gobce analyze --coverprofile coverage.out --format json` で、概ね次を行います。 + +1. `coverage.out` を読む +2. 各行をパースして、ファイル・開始行・終了行・実行回数を取り出す +3. 必要なら import path 形式のファイル名をローカル実ファイルパスに解決する +4. 各 Go ファイルを AST 解析して分岐候補を集める +5. 分岐候補の行範囲と coverage の行範囲が重なっているかを見て、covered/uncovered を判定する +6. 結果を集計して JSON 出力する + - `statementCoverage` + - `estimatedBranchCoverage` + - `uncoveredBranches` + +## 図解: 1つの if をどう判定するか + +ここでは、最小例で `gobce` の見え方を対応させます。 + +対象コード: + +```go +func score(v int) int { + if v > 10 { + return 1 + } else { + return 2 + } +} +``` + +テストが `v=20` しか通していない場合のイメージ: + +```text +if 条件: v > 10 +├─ true 側: 実行された +└─ false 側: 実行されていない +``` + +coverprofile 側では、ざっくり次の情報が入ります。 + +```text +sample.go:4.13,5.3 ... count=1 (true 側の範囲) +sample.go:6.8,8.3 ... count=0 (false 側の範囲) +``` + +`gobce` は AST で + +- `if_true_path` の行範囲 +- `if_false_path` の行範囲 + +を作り、coverprofile の範囲と重ねて判定します。 + +結果イメージ: + +```json +{ + "uncoveredBranches": [ + { + "file": "sample.go", + "line": 6, + "kind": "if_false_path" + } + ] +} +``` + +つまり「`if` がある」だけではなく、 +**`if` の両側のコードがテスト実行時に実際に実行されたか**を `gobce` は見ようとしています。 + +## 出力項目の見方 + +- `statementCoverage` + `go test` のカバレッジ情報から算出した statement ベースの割合。 + +- `estimatedBranchCoverage` + `gobce` が分岐候補に対して covered/uncovered を推定して計算した割合。 + +- `uncoveredBranches` + テスト実行時にまだ実行されていないと推定された分岐の一覧。 + どのファイル・どの行・どの kind かが入ります。 + +## 「推定」になる理由 + +Go の coverprofile は branch coverage 専用のフォーマットではないため、 +分岐の完全な実行情報を直接は持っていません。 + +そのため `gobce` は、 +「コード構造 + 実行された行範囲」から妥当な推定をしている、という位置づけです。 + +## よくある疑問 + +### Q. statementCoverage が高いのに estimatedBranchCoverage が低いのはなぜ? + +A. 直線的な処理のコードはテスト実行時に実行されていても、条件分岐の片側だけしかテスト実行時に実行されていない可能性があります。 +`gobce` はその差分を見える化するためのツールです。 + +### Q. まず何を見ればいい? + +A. `uncoveredBranches` から見れば OK です。 +分岐の未到達ポイントが具体的に出るので、テスト追加の優先順位を付けやすくなります。 + +## 開発者向けの読み進め順 + +実装を読むなら、次の順がおすすめです。 + +1. `analyze.go` (公開 API の入口) +2. `internal/analyzer/analyze.go` (処理の本流) +3. `internal/analyzer/coverprofile.go` (coverage の入力) +4. `internal/analyzer/branches.go` (分岐候補の抽出) +5. `internal/analyzer/coverage.go` (集計) + +この順で追うと、全体像を掴みやすいです。 From c09dd912379979dac1079c6f91724f48aa390da8 Mon Sep 17 00:00:00 2001 From: keyskey <39644776+keyskey@users.noreply.github.com> Date: Sat, 25 Apr 2026 23:02:43 +0900 Subject: [PATCH 07/10] Enhance gobce analyze command to support output file option. Update README and documentation to reflect the new `--output` flag for saving JSON results to a specified file while maintaining stdout output. Adjust usage examples accordingly. --- README.md | 8 +++++++- cmd/gobce/main.go | 29 +++++++++++++++++++++++++---- docs/gobce.md | 3 ++- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 591a3e9..c3cfc10 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,12 @@ go test ./... -coverprofile coverage.out go run ./cmd/gobce analyze --coverprofile coverage.out --format json ``` +Write JSON result to file (while keeping stdout output): + +```bash +go run ./cmd/gobce analyze --coverprofile coverage.out --format json --output gobce-result.json +``` + Build an executable: ```bash @@ -83,7 +89,7 @@ jobs: If you want to fail CI by threshold, combine JSON output with `jq`: ```bash -go run github.com/keyskey/gobce/cmd/gobce@latest analyze --coverprofile coverage.out --format json > gobce.json +go run github.com/keyskey/gobce/cmd/gobce@latest analyze --coverprofile coverage.out --format json --output gobce.json jq -e '.estimatedBranchCoverage >= 70' gobce.json ``` diff --git a/cmd/gobce/main.go b/cmd/gobce/main.go index 24e0ae5..5919fd8 100644 --- a/cmd/gobce/main.go +++ b/cmd/gobce/main.go @@ -5,6 +5,7 @@ import ( "flag" "fmt" "os" + "path/filepath" "github.com/keyskey/gobce" ) @@ -18,6 +19,7 @@ func main() { analyzeCmd := flag.NewFlagSet("analyze", flag.ExitOnError) coverProfile := analyzeCmd.String("coverprofile", "", "path to go coverprofile") outputFormat := analyzeCmd.String("format", "json", "output format (json)") + outputPath := analyzeCmd.String("output", "", "optional path to write analysis result JSON") if err := analyzeCmd.Parse(os.Args[2:]); err != nil { fmt.Fprintf(os.Stderr, "parse flags: %v\n", err) os.Exit(2) @@ -40,15 +42,34 @@ func main() { os.Exit(1) } - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - if err := enc.Encode(result); err != nil { + encoded, err := json.MarshalIndent(result, "", " ") + if err != nil { fmt.Fprintf(os.Stderr, "encode result: %v\n", err) os.Exit(1) } + + // Keep stdout output for terminal use and piping. + if _, err := fmt.Fprintln(os.Stdout, string(encoded)); err != nil { + fmt.Fprintf(os.Stderr, "write stdout: %v\n", err) + os.Exit(1) + } + + if *outputPath != "" { + parent := filepath.Dir(*outputPath) + if parent != "." { + if err := os.MkdirAll(parent, 0o755); err != nil { + fmt.Fprintf(os.Stderr, "create output directory: %v\n", err) + os.Exit(1) + } + } + if err := os.WriteFile(*outputPath, append(encoded, '\n'), 0o644); err != nil { + fmt.Fprintf(os.Stderr, "write output file: %v\n", err) + os.Exit(1) + } + } } func printUsage() { fmt.Fprintln(os.Stderr, "usage:") - fmt.Fprintln(os.Stderr, " gobce analyze --coverprofile --format json") + fmt.Fprintln(os.Stderr, " gobce analyze --coverprofile --format json [--output ]") } diff --git a/docs/gobce.md b/docs/gobce.md index 009c01f..a493109 100644 --- a/docs/gobce.md +++ b/docs/gobce.md @@ -35,7 +35,8 @@ go test ./... -coverprofile coverage.out go run ./cmd/gobce analyze \ --coverprofile coverage.out \ - --format json + --format json \ + --output gobce-result.json ``` 出力例: From 95e76d0260bf3c08e26e7e80dacb2f405209c88e Mon Sep 17 00:00:00 2001 From: keyskey <39644776+keyskey@users.noreply.github.com> Date: Sat, 25 Apr 2026 23:07:45 +0900 Subject: [PATCH 08/10] Fix branch coverage span checks with column-aware overlap. Parse and store coverprofile column positions, use line+column range overlap in spanCovered, and add a regression test for one-line if/else coverage misclassification. Made-with: Cursor --- analyzer_test.go | 38 +++++++++++++++++++++++++++++++ internal/analyzer/branches.go | 18 +++++++++++---- internal/analyzer/coverprofile.go | 18 ++++++++++----- internal/analyzer/types.go | 2 ++ 4 files changed, 65 insertions(+), 11 deletions(-) diff --git a/analyzer_test.go b/analyzer_test.go index 3e456fc..3d5f605 100644 --- a/analyzer_test.go +++ b/analyzer_test.go @@ -58,3 +58,41 @@ func score(v int) int { t.Fatalf("expected if_false_path in uncovered branches") } } + +func TestAnalyzeOneLineIfElseColumnAwareCoverage(t *testing.T) { + tmp := t.TempDir() + srcPath := filepath.Join(tmp, "oneline.go") + src := `package sample + +func score(v int) int { if v > 10 { return 1 } else { return 2 } } +` + if err := os.WriteFile(srcPath, []byte(src), 0o644); err != nil { + t.Fatalf("write source: %v", err) + } + + coverPath := filepath.Join(tmp, "coverage.out") + coverage := strings.Join([]string{ + "mode: set", + srcPath + ":3.31,3.41 1 1", + srcPath + ":3.50,3.65 1 0", + }, "\n") + if err := os.WriteFile(coverPath, []byte(coverage), 0o644); err != nil { + t.Fatalf("write coverage: %v", err) + } + + result, err := Analyze(AnalyzeInput{CoverProfilePath: coverPath}) + if err != nil { + t.Fatalf("analyze: %v", err) + } + + var hasIfFalse bool + for _, b := range result.UncoveredBranches { + if b.Kind == "if_false_path" { + hasIfFalse = true + break + } + } + if !hasIfFalse { + t.Fatalf("expected if_false_path in uncovered branches") + } +} diff --git a/internal/analyzer/branches.go b/internal/analyzer/branches.go index 61df501..78bc6fd 100644 --- a/internal/analyzer/branches.go +++ b/internal/analyzer/branches.go @@ -114,20 +114,28 @@ func collectCaseClauseCandidates(filePath string, fset *token.FileSet, blocks [] } func spanCovered(blocks []coverageBlock, fset *token.FileSet, start token.Pos, end token.Pos) bool { - startLine := fset.Position(start).Line - endLine := fset.Position(end).Line + startPos := fset.Position(start) + endPos := fset.Position(end) for _, b := range blocks { if b.Count <= 0 { continue } - if linesOverlap(startLine, endLine, b.StartLine, b.EndLine) { + if spansOverlap(startPos.Line, startPos.Column, endPos.Line, endPos.Column, b.StartLine, b.StartCol, b.EndLine, b.EndCol) { return true } } return false } -func linesOverlap(aStart, aEnd, bStart, bEnd int) bool { - return aStart <= bEnd && bStart <= aEnd +func spansOverlap(aStartLine, aStartCol, aEndLine, aEndCol, bStartLine, bStartCol, bEndLine, bEndCol int) bool { + return positionLess(aStartLine, aStartCol, bEndLine, bEndCol) && + positionLess(bStartLine, bStartCol, aEndLine, aEndCol) +} + +func positionLess(lineA, colA, lineB, colB int) bool { + if lineA != lineB { + return lineA < lineB + } + return colA < colB } diff --git a/internal/analyzer/coverprofile.go b/internal/analyzer/coverprofile.go index 2c27e46..8389142 100644 --- a/internal/analyzer/coverprofile.go +++ b/internal/analyzer/coverprofile.go @@ -80,11 +80,11 @@ func parseCoverProfileLine(line string) (coverageBlock, error) { return coverageBlock{}, fmt.Errorf("invalid span: %q", span) } - startLine, err := parseSpanLine(spanParts[0]) + startLine, startCol, err := parseSpanPosition(spanParts[0]) if err != nil { return coverageBlock{}, err } - endLine, err := parseSpanLine(spanParts[1]) + endLine, endCol, err := parseSpanPosition(spanParts[1]) if err != nil { return coverageBlock{}, err } @@ -92,20 +92,26 @@ func parseCoverProfileLine(line string) (coverageBlock, error) { return coverageBlock{ FilePath: filepath.Clean(filePath), StartLine: startLine, + StartCol: startCol, EndLine: endLine, + EndCol: endCol, NumStmts: stmts, Count: count, }, nil } -func parseSpanLine(pos string) (int, error) { +func parseSpanPosition(pos string) (int, int, error) { dot := strings.Index(pos, ".") if dot == -1 { - return 0, fmt.Errorf("invalid position format: %q", pos) + return 0, 0, fmt.Errorf("invalid position format: %q", pos) } line, err := strconv.Atoi(pos[:dot]) if err != nil { - return 0, fmt.Errorf("invalid line in position: %w", err) + return 0, 0, fmt.Errorf("invalid line in position: %w", err) } - return line, nil + col, err := strconv.Atoi(pos[dot+1:]) + if err != nil { + return 0, 0, fmt.Errorf("invalid column in position: %w", err) + } + return line, col, nil } diff --git a/internal/analyzer/types.go b/internal/analyzer/types.go index 782f71b..24badb3 100644 --- a/internal/analyzer/types.go +++ b/internal/analyzer/types.go @@ -21,7 +21,9 @@ type UncoveredBranch struct { type coverageBlock struct { FilePath string StartLine int + StartCol int EndLine int + EndCol int NumStmts int Count int } From 032117440c714d1fb35fbd048c717b000013e051 Mon Sep 17 00:00:00 2001 From: keyskey <39644776+keyskey@users.noreply.github.com> Date: Sat, 25 Apr 2026 23:12:18 +0900 Subject: [PATCH 09/10] Add PR-only CI test workflow and align Go version resolution. Run go test on pull requests and use go.mod via setup-go go-version-file in both CI and release workflows. Made-with: Cursor --- .github/workflows/ci.yml | 20 ++++++++++++++++++++ .github/workflows/release.yml | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e2156e6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +name: ci + +on: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Run tests + run: go test ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 144c390..7ccdd87 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: "1.26.x" + go-version-file: go.mod - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6 From 9a4177546a481ab84f37fc567449a6009dbfc503 Mon Sep 17 00:00:00 2001 From: keyskey <39644776+keyskey@users.noreply.github.com> Date: Sat, 25 Apr 2026 23:15:18 +0900 Subject: [PATCH 10/10] Skip unresolved source files during branch candidate collection. Avoid aborting analysis when parser cannot open a file from coverprofile by skipping missing files and add a regression test covering mixed resolvable/unresolvable entries. Made-with: Cursor --- analyzer_test.go | 39 +++++++++++++++++++++++++++++++++++ internal/analyzer/branches.go | 5 +++++ 2 files changed, 44 insertions(+) diff --git a/analyzer_test.go b/analyzer_test.go index 3d5f605..04cdb8d 100644 --- a/analyzer_test.go +++ b/analyzer_test.go @@ -96,3 +96,42 @@ func score(v int) int { if v > 10 { return 1 } else { return 2 } } t.Fatalf("expected if_false_path in uncovered branches") } } + +func TestAnalyzeSkipsUnresolvableSourcePaths(t *testing.T) { + tmp := t.TempDir() + srcPath := filepath.Join(tmp, "sample.go") + src := `package sample + +func score(v int) int { + if v > 10 { + return 1 + } else { + return 2 + } +} +` + if err := os.WriteFile(srcPath, []byte(src), 0o644); err != nil { + t.Fatalf("write source: %v", err) + } + + coverPath := filepath.Join(tmp, "coverage.out") + coverage := strings.Join([]string{ + "mode: set", + srcPath + ":3.23,4.13 1 1", + srcPath + ":4.13,5.3 1 1", + srcPath + ":6.8,8.3 1 0", + "github.com/example/dependency/pkg/generated.go:1.1,1.10 1 1", + }, "\n") + if err := os.WriteFile(coverPath, []byte(coverage), 0o644); err != nil { + t.Fatalf("write coverage: %v", err) + } + + result, err := Analyze(AnalyzeInput{CoverProfilePath: coverPath}) + if err != nil { + t.Fatalf("analyze should not fail on unresolved source path: %v", err) + } + + if len(result.UncoveredBranches) == 0 { + t.Fatalf("expected uncovered branches from resolvable source file") + } +} diff --git a/internal/analyzer/branches.go b/internal/analyzer/branches.go index 78bc6fd..2b75e10 100644 --- a/internal/analyzer/branches.go +++ b/internal/analyzer/branches.go @@ -1,10 +1,12 @@ package analyzer import ( + "errors" "fmt" "go/ast" "go/parser" "go/token" + "os" ) func collectBranchCandidates(blocks []coverageBlock) ([]branchCandidate, error) { @@ -17,6 +19,9 @@ func collectBranchCandidates(blocks []coverageBlock) ([]branchCandidate, error) for filePath, fileBlocks := range byFile { candidates, err := collectFileBranchCandidates(filePath, fileBlocks) if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } return nil, err } result = append(result, candidates...)