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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Diffguard turns "is this code good?" into a set of numbers an agent can iterate
- Cognitive complexity ≤ 10 per function
- Function bodies ≤ 50 lines, files ≤ 500 lines
- No new dependency cycles or Stable Dependencies Principle violations
- No unused (dead) symbols introduced by the diff
- Tier‑1 mutation kill rate ≥ 90% (tests actually catch logic changes)

Run diffguard, read the violations, change the code, run again — loop until it exits 0. The metrics become the spec. The agent has something objective to optimize for rather than guessing at taste, and you get a reproducible definition of "good enough" instead of having to re‑judge every diff by eye. Also useful for traditional human-written CI, but the real lift is on AI-generated PRs where line-by-line review doesn't scale.
Expand Down Expand Up @@ -141,6 +142,21 @@ Builds a directed graph of internal package imports from changed packages and re

Cross-references git history with complexity scores. Functions that are both complex AND frequently modified are the highest-risk targets for bugs. Reports the top 10 by `commits * complexity`.

### Dead Code

Flags non-exported functions, variables, and constants that are declared in the diff but have no references in their analyzable scope. Useful for catching code that was added "just in case" but never wired up, or that became orphaned when its sole caller was removed in the same diff.

Scope is deliberately conservative to avoid false positives:

| Language | Scope scanned for references | Symbols considered |
|------------|------------------------------|-------------------------------------------------------|
| Go | All `*.go` in the package directory (including `_test.go`) | unexported free functions, package-level `var`/`const` |
| TypeScript | The single source file | non-`export`ed functions, classes, top-level `const`/`let`/`var` |

Skipped on purpose: exported / public symbols (may be consumed externally), methods (may satisfy interfaces), types (uses through embedding/casting are noisy), and well-known runtime/test entry points (`init`, `main` in `package main`, `TestXxx`, `BenchmarkXxx`, `ExampleXxx`, `FuzzXxx`).

Severity: **WARN**. Dead-code detection is heuristic — symbols can be referenced via reflection, framework registration, or codegen the detector can't see — so findings nudge the reviewer to verify rather than block the build outright. Use `--skip-deadcode` to disable the check entirely.

### Mutation Testing

Applies mutations to changed code and runs tests to verify they catch the change:
Expand Down Expand Up @@ -206,6 +222,7 @@ Flags:
--function-size-threshold int Maximum lines per function (default 50)
--file-size-threshold int Maximum lines per file (default 500)
--skip-mutation Skip mutation testing
--skip-deadcode Skip dead code (unused symbol) detection
--skip-generated Skip files marked as generated (for example `Code generated ... DO NOT EDIT`) (default true)
--mutation-sample-rate float Percentage of mutants to test, 0-100 (default 100)
--test-timeout duration Per-mutant go test timeout (default 30s)
Expand Down Expand Up @@ -337,6 +354,11 @@ Violations:
12 functions analyzed | Top churn*complexity score: 440 [WARN]
Warnings:
pkg/handler/routes.go:45:HandleRequest commits=20 complexity=22 score=440 [WARN]

=== Dead Code ===
1 unused symbol detected in changed code [WARN]
Warnings:
pkg/auth/token.go:208:legacyDecode unused func "legacyDecode" [WARN]
```

## License
Expand Down
11 changes: 11 additions & 0 deletions cmd/diffguard/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/0xPolygon/diffguard/internal/churn"
"github.com/0xPolygon/diffguard/internal/complexity"
"github.com/0xPolygon/diffguard/internal/deadcode"
"github.com/0xPolygon/diffguard/internal/deps"
"github.com/0xPolygon/diffguard/internal/diff"
"github.com/0xPolygon/diffguard/internal/lang"
Expand All @@ -28,6 +29,7 @@ func main() {
flag.IntVar(&cfg.FunctionSizeThreshold, "function-size-threshold", 50, "Maximum lines per function")
flag.IntVar(&cfg.FileSizeThreshold, "file-size-threshold", 500, "Maximum lines per file")
flag.BoolVar(&cfg.SkipMutation, "skip-mutation", false, "Skip mutation testing")
flag.BoolVar(&cfg.SkipDeadCode, "skip-deadcode", false, "Skip dead code (unused symbol) detection")
flag.BoolVar(&cfg.SkipGenerated, "skip-generated", true, "Skip files marked as generated (for example `Code generated ... DO NOT EDIT`)")
flag.Float64Var(&cfg.MutationSampleRate, "mutation-sample-rate", 100, "Percentage of mutants to test, 0-100")
flag.DurationVar(&cfg.TestTimeout, "test-timeout", 30*time.Second, "Per-mutant test binary timeout (e.g. 60s, 2m)")
Expand Down Expand Up @@ -70,6 +72,7 @@ type Config struct {
FunctionSizeThreshold int
FileSizeThreshold int
SkipMutation bool
SkipDeadCode bool
SkipGenerated bool
MutationSampleRate float64
TestTimeout time.Duration
Expand Down Expand Up @@ -292,6 +295,14 @@ func runAnalyses(repoPath string, d *diff.Result, cfg Config, l lang.Language) (
}
sections = append(sections, churnSection)

if !cfg.SkipDeadCode {
deadcodeSection, err := deadcode.Analyze(repoPath, d, l.DeadCodeDetector())
if err != nil {
return nil, fmt.Errorf("dead code analysis: %w", err)
}
sections = append(sections, deadcodeSection)
}

if !cfg.SkipMutation {
mutationSection, err := mutation.Analyze(repoPath, d, l, mutation.Options{
SampleRate: cfg.MutationSampleRate,
Expand Down
1 change: 1 addition & 0 deletions cmd/diffguard/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ func (s stubLanguage) MutantGenerator() lang.MutantGenerator { return
func (s stubLanguage) MutantApplier() lang.MutantApplier { return nil }
func (s stubLanguage) AnnotationScanner() lang.AnnotationScanner { return nil }
func (s stubLanguage) TestRunner() lang.TestRunner { return nil }
func (s stubLanguage) DeadCodeDetector() lang.DeadCodeDetector { return nil }

// initTempGoRepo creates a minimal git repo with a single committed Go
// file on main, plus an additional file on HEAD so the diff has content.
Expand Down
108 changes: 108 additions & 0 deletions internal/deadcode/deadcode.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// Package deadcode runs a language's DeadCodeDetector across a diff's
// changed files and produces the "Dead Code" report section.
//
// The per-language detection logic (AST/CST walk, reference counting) lives
// in the language back-end (for Go: internal/lang/goanalyzer/deadcode.go;
// for TypeScript: internal/lang/tsanalyzer/deadcode.go). This package is a
// thin orchestrator: it iterates over changed files, calls the detector on
// each, and formats the resulting unused-symbol list.
//
// Dead-code findings are reported as WARN (not FAIL) because the detector
// is conservative but not omniscient — symbols can be referenced via
// reflection, framework registration, or codegen the detector can't see.
// Treating them as warnings nudges the human to verify rather than blocking
// the build outright.
package deadcode

import (
"fmt"
"sort"

"github.com/0xPolygon/diffguard/internal/diff"
"github.com/0xPolygon/diffguard/internal/lang"
"github.com/0xPolygon/diffguard/internal/report"
)

// Analyze runs detector across every file in d and returns the "Dead Code"
// report section. detector == nil produces a PASS section with a summary
// noting that the language has no detector wired up — useful as a hedge
// when adding the feature to languages incrementally.
func Analyze(repoPath string, d *diff.Result, detector lang.DeadCodeDetector) (report.Section, error) {
if detector == nil {
return report.Section{
Name: "Dead Code",
Summary: "No dead code detector available for this language",
Severity: report.SeverityPass,
}, nil
}

var results []lang.UnusedSymbol
for _, fc := range d.Files {
found, err := detector.FindDeadCode(repoPath, fc)
if err != nil {
return report.Section{}, fmt.Errorf("dead code analysis %s: %w", fc.Path, err)
}
results = append(results, found...)
}
return buildSection(results), nil
}

// buildSection turns a list of unused symbols into the "Dead Code" section.
// An empty list yields a PASS; any unused symbols flip the section to WARN.
// Findings are sorted by file path then line so the output is deterministic.
func buildSection(results []lang.UnusedSymbol) report.Section {
if len(results) == 0 {
return report.Section{
Name: "Dead Code",
Summary: "No unused symbols detected in changed code",
Severity: report.SeverityPass,
}
}

findings := make([]report.Finding, 0, len(results))
for _, r := range results {
findings = append(findings, report.Finding{
File: r.File,
Line: r.Line,
Function: r.Name,
Message: fmt.Sprintf("unused %s %q", r.Kind, r.Name),
Value: 1,
Severity: report.SeverityWarn,
})
}
sort.SliceStable(findings, func(i, j int) bool {
if findings[i].File != findings[j].File {
return findings[i].File < findings[j].File
}
return findings[i].Line < findings[j].Line
})

summary := fmt.Sprintf("%d unused %s detected in changed code",
len(results), pluralize("symbol", len(results)))

return report.Section{
Name: "Dead Code",
Summary: summary,
Severity: report.SeverityWarn,
Findings: findings,
Stats: map[string]any{
"unused_symbols": len(results),
"by_kind": countByKind(results),
},
}
}

func countByKind(results []lang.UnusedSymbol) map[string]int {
out := map[string]int{}
for _, r := range results {
out[r.Kind]++
}
return out
}

func pluralize(word string, n int) string {
if n == 1 {
return word
}
return word + "s"
}
144 changes: 144 additions & 0 deletions internal/deadcode/deadcode_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package deadcode

import (
"errors"
"testing"

"github.com/0xPolygon/diffguard/internal/diff"
"github.com/0xPolygon/diffguard/internal/lang"
"github.com/0xPolygon/diffguard/internal/report"
)

// stubDetector is a minimal in-memory DeadCodeDetector that replays a fixed
// list of unused symbols (per file path) without touching the filesystem.
// Lets the orchestrator tests run without spinning up a real Go toolchain.
type stubDetector struct {
byPath map[string][]lang.UnusedSymbol
err error
}

func (s *stubDetector) FindDeadCode(_ string, fc diff.FileChange) ([]lang.UnusedSymbol, error) {
if s.err != nil {
return nil, s.err
}
return s.byPath[fc.Path], nil
}

func TestAnalyze_NilDetectorPasses(t *testing.T) {
d := &diff.Result{Files: []diff.FileChange{{Path: "a.go"}}}
s, err := Analyze("/repo", d, nil)
if err != nil {
t.Fatalf("Analyze: %v", err)
}
if s.Severity != report.SeverityPass {
t.Errorf("severity = %v, want PASS", s.Severity)
}
if s.Name != "Dead Code" {
t.Errorf("name = %q, want Dead Code", s.Name)
}
}

func TestAnalyze_NoDeadCodePasses(t *testing.T) {
d := &diff.Result{Files: []diff.FileChange{{Path: "a.go"}}}
det := &stubDetector{byPath: map[string][]lang.UnusedSymbol{}}
s, err := Analyze("/repo", d, det)
if err != nil {
t.Fatalf("Analyze: %v", err)
}
if s.Severity != report.SeverityPass {
t.Errorf("severity = %v, want PASS", s.Severity)
}
}

func TestAnalyze_DeadCodeWarns(t *testing.T) {
d := &diff.Result{Files: []diff.FileChange{{Path: "a.go"}, {Path: "b.go"}}}
det := &stubDetector{
byPath: map[string][]lang.UnusedSymbol{
"a.go": {{File: "a.go", Line: 5, Name: "foo", Kind: "func"}},
"b.go": {{File: "b.go", Line: 10, Name: "bar", Kind: "var"}},
},
}
s, err := Analyze("/repo", d, det)
if err != nil {
t.Fatalf("Analyze: %v", err)
}
if s.Severity != report.SeverityWarn {
t.Errorf("severity = %v, want WARN", s.Severity)
}
if len(s.Findings) != 2 {
t.Fatalf("expected 2 findings, got %d", len(s.Findings))
}
// Findings should be sorted by file then line.
if s.Findings[0].File != "a.go" || s.Findings[1].File != "b.go" {
t.Errorf("findings not sorted by file: %+v", s.Findings)
}
for _, f := range s.Findings {
if f.Severity != report.SeverityWarn {
t.Errorf("finding severity = %v, want WARN", f.Severity)
}
}
}

func TestAnalyze_DetectorErrorPropagates(t *testing.T) {
d := &diff.Result{Files: []diff.FileChange{{Path: "a.go"}}}
det := &stubDetector{err: errors.New("boom")}
_, err := Analyze("/repo", d, det)
if err == nil {
t.Fatal("expected error, got nil")
}
}

func TestBuildSection_StatsByKind(t *testing.T) {
results := []lang.UnusedSymbol{
{File: "a.go", Line: 1, Name: "f1", Kind: "func"},
{File: "a.go", Line: 2, Name: "f2", Kind: "func"},
{File: "b.go", Line: 3, Name: "v1", Kind: "var"},
}
s := buildSection(results)
stats, ok := s.Stats.(map[string]any)
if !ok {
t.Fatalf("stats wrong type: %T", s.Stats)
}
if stats["unused_symbols"] != 3 {
t.Errorf("unused_symbols = %v, want 3", stats["unused_symbols"])
}
byKind, ok := stats["by_kind"].(map[string]int)
if !ok {
t.Fatalf("by_kind wrong type: %T", stats["by_kind"])
}
if byKind["func"] != 2 || byKind["var"] != 1 {
t.Errorf("by_kind = %+v, want func:2 var:1", byKind)
}
}

func TestBuildSection_FindingMessage(t *testing.T) {
s := buildSection([]lang.UnusedSymbol{
{File: "a.go", Line: 1, Name: "foo", Kind: "func"},
})
if len(s.Findings) != 1 {
t.Fatalf("got %d findings, want 1", len(s.Findings))
}
want := `unused func "foo"`
if s.Findings[0].Message != want {
t.Errorf("message = %q, want %q", s.Findings[0].Message, want)
}
if s.Findings[0].Function != "foo" {
t.Errorf("function = %q, want foo", s.Findings[0].Function)
}
}

func TestPluralize(t *testing.T) {
tests := []struct {
n int
want string
}{
{0, "symbols"},
{1, "symbol"},
{2, "symbols"},
}
for _, tt := range tests {
if got := pluralize("symbol", tt.n); got != tt.want {
t.Errorf("pluralize(%d) = %q, want %q", tt.n, got, tt.want)
}
}
}
Loading
Loading