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 new file mode 100644 index 0000000..7ccdd87 --- /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-file: go.mod + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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/ 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 d102230..c3cfc10 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,116 @@ # 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. + +## 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 +``` + +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 +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 --output 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` (concept and design notes) +- `docs/how-gobce-works.md` (beginner-friendly implementation walkthrough) \ No newline at end of file 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..04cdb8d --- /dev/null +++ b/analyzer_test.go @@ -0,0 +1,137 @@ +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") + } +} + +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") + } +} + +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/cmd/gobce/main.go b/cmd/gobce/main.go new file mode 100644 index 0000000..5919fd8 --- /dev/null +++ b/cmd/gobce/main.go @@ -0,0 +1,75 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + + "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)") + 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) + } + + 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) + } + + 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 [--output ]") +} diff --git a/docs/gobce.md b/docs/gobce.md new file mode 100644 index 0000000..a493109 --- /dev/null +++ b/docs/gobce.md @@ -0,0 +1,101 @@ +# 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 \ + --output gobce-result.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 +``` 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` (集計) + +この順で追うと、全体像を掴みやすいです。 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..2b75e10 --- /dev/null +++ b/internal/analyzer/branches.go @@ -0,0 +1,146 @@ +package analyzer + +import ( + "errors" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" +) + +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 { + if errors.Is(err, os.ErrNotExist) { + continue + } + 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 { + startPos := fset.Position(start) + endPos := fset.Position(end) + + for _, b := range blocks { + if b.Count <= 0 { + continue + } + if spansOverlap(startPos.Line, startPos.Column, endPos.Line, endPos.Column, b.StartLine, b.StartCol, b.EndLine, b.EndCol) { + return true + } + } + return false +} + +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/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..8389142 --- /dev/null +++ b/internal/analyzer/coverprofile.go @@ -0,0 +1,117 @@ +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, startCol, err := parseSpanPosition(spanParts[0]) + if err != nil { + return coverageBlock{}, err + } + endLine, endCol, err := parseSpanPosition(spanParts[1]) + if err != nil { + return coverageBlock{}, err + } + + return coverageBlock{ + FilePath: filepath.Clean(filePath), + StartLine: startLine, + StartCol: startCol, + EndLine: endLine, + EndCol: endCol, + NumStmts: stmts, + Count: count, + }, nil +} + +func parseSpanPosition(pos string) (int, int, error) { + dot := strings.Index(pos, ".") + if dot == -1 { + return 0, 0, fmt.Errorf("invalid position format: %q", pos) + } + line, err := strconv.Atoi(pos[:dot]) + if err != nil { + return 0, 0, fmt.Errorf("invalid line in position: %w", err) + } + 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/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..24badb3 --- /dev/null +++ b/internal/analyzer/types.go @@ -0,0 +1,36 @@ +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 + StartCol int + EndLine int + EndCol 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"` +}