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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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 ./...
31 changes: 31 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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 }}
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.tools/
.envrc
coverage.out
/gobce
dist/
33 changes: 33 additions & 0 deletions .goreleaser.yml
Original file line number Diff line number Diff line change
@@ -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
116 changes: 115 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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)
29 changes: 29 additions & 0 deletions analyze.go
Original file line number Diff line number Diff line change
@@ -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
}
137 changes: 137 additions & 0 deletions analyzer_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading
Loading