doppel
A CLI tool that detects structurally similar functions across a Go codebase. It helps identify duplicate logic and refactoring opportunities by fingerprinting each function from its AST and cross-checking matches against call-graph context — rather than by text matching.
Everything runs locally and offline: no models, no network, no cache. The same source always produces the same report.
For a detailed breakdown of the pipeline internals, see How Doppel Works.
Quick Start
Prerequisites: Go 1.25+
go run . analyze .
This scans the current directory, fingerprints every Go function, and prints the most similar pairs with the evidence behind each match. Add --output report.md to also write a Markdown report.
Real-world examples
examples/ holds doppel's actual output on seven pinned
public Go projects, ordered old-and-complex to new-and-narrow — from
moby (8003 functions, a decade of accretion) down to
conc (81 functions, one idea, written recently). Each report
is regenerated by task examples, never hand-edited, and every pair it names
can be opened in the corresponding pinned tree.
The same directory carries the per-stage performance numbers across that ladder
and a committed golden labels file: a
human review of cobra's ranking that task golden scores the current pipeline
against.
Installation
Download a prebuilt binary from the latest release
— linux, darwin and windows, on amd64 and arm64. Extract it and put doppel on your PATH:
tar -xzf doppel_*_linux_amd64.tar.gz
sudo install doppel /usr/local/bin/
Every release also ships checksums.txt:
sha256sum -c checksums.txt --ignore-missing
Or install with Go:
go install github.com/LukasSelin/doppel@latest
Or build from source:
git clone https://github.com/LukasSelin/doppel
cd doppel
go build -o doppel .
doppel version prints the build identity. That string is recorded in every snapshot, and a
session baseline is discarded when it changes — so upgrading mid-session correctly invalidates
the baseline instead of comparing measurements across a scoring change.
Usage
doppel analyze <path> [flags]
Examples
# Analyze current directory with defaults
doppel analyze .
# Lower the threshold to catch more subtle similarities
doppel analyze ./src --threshold 0.50
# Keep only pairs that also share architectural context, and save a report
doppel analyze . --struct-min 0.4 --output report.md
# Print the vocabulary scoring is based on, and check it is consistent
doppel ontology --defs
# Before writing a function, ask whether the repo already has one like it
doppel query --near billing . < draft.go
Querying before you write
doppel query reads a Go snippet — the function you are about to write — and reports the corpus
functions most related to it by structure, concept tags and calls:
query: cmd.validateHookSetup — tags: validation
role: orchestrator resolved calls: 3
Corpus: 304 functions. 5 related functions:
#1 cmd.hookParams cmd/config.go:130
evidence: 69.7 nats (shape 59.1, concept 1.4, call 9.2) code-shape: 0.49 locality: 1.00
tags: validation role: orchestrator
Matches are ranked by evidence boosted with locality — the fraction of the snippet's resolved
call neighborhood the match inhabits — so architecturally near code outranks equally-similar code
from elsewhere. --near names the package the function will live in: a bare snippet is wrapped in
it, and its bare-name calls resolve to that package's functions, which is what locality is built
from. Include the snippet's imports — calls into imported packages only count as evidence when the
import that binds them is present.
Two scores per pair
Every reported pair carries two independent numbers:
- Code similarity (
Score, gated by --threshold) — how alike the two bodies are, from the AST fingerprint. The report breaks it into its components: ast (3-gram shingle overlap), flow (control-flow shape), sig (parameter and result types), and size (relative body size, shown for context but not scored).
- Structural overlap (gated by
--struct-min) — how much architectural context the two share: callees, callers, intent patterns, role, package, and what their own callers and callees do. Intent patterns, roles and receiver types are matched through a concept hierarchy rather than compared as strings, so two functions doing related work — one hitting a database, the other a cache — score partial credit instead of zero. Every graded match comes with an evidence line saying which ancestor relates the two and how strongly.
A high code score with low structural overlap means two lookalike bodies in unrelated parts of the system. High on both is the real merge candidate.
Flags
| Flag |
Default |
Description |
-t, --threshold |
0.60 |
Minimum code similarity score to report (0.0–1.0) |
-n, --top |
20 |
Maximum number of pairs to show (0 for no limit) |
--struct-min |
0.0 |
Minimum structural overlap score (0.0–1.0) to keep a pair |
--min-nodes |
12 |
Skip functions whose body has fewer than this many AST nodes. Guards against one-line accessors, which match each other perfectly and would otherwise flood the report |
-o, --output |
(disabled) |
Write report as Markdown to this file. The stdout report is still printed |
--format |
text |
Stdout format: text or json. The JSON form is a deterministic snapshot of the whole run — every function, its concept tags and role, and every reported pair |
--config |
.doppel.json if present |
Path to a JSON config file |
Configuration
Any flag above except --config can be set in a .doppel.json at the repo root. Keys are kebab-case, mirroring the flag names, and an explicit CLI flag always wins over the file:
{
"threshold": 0.65,
"top": 10,
"struct-min": 0.4,
"output": "doppel-report.md"
}
A missing config file is not an error; malformed JSON is.
One key has no flag behind it: hook-notify (agent | user | off) decides who the plugin's
Stop hook reports to. See plugin/README.md — reaching the agent costs an extra
turn, so it is worth understanding before leaving it on the default.
Use as a Claude Code plugin
The same analysis can run automatically around a coding session, answering questions it is otherwise
easy to skip: does this codebase already have a concept for what I am about to write, does the
file I am about to edit have twins, and what did I just do to its duplication surface.
The plugin shells out to the doppel binary and does not bundle one, so install it first —
a release download or:
go install github.com/LukasSelin/doppel@latest
claude plugin marketplace add LukasSelin/doppel
claude plugin install doppel@doppel
Four hooks, placed by when a fact can still change what gets written:
- SessionStart — the corpus inventory: which concept tags the repo carries, which it has none
of, the role distribution.
- UserPromptSubmit — the duplication facts for the packages your message mentions, and nothing
else. Silent when it recognises none.
- PreToolUse on
Edit/Write — immediately before a file changes, the merge-worthy twins of
the functions in it. Advisory only; it never blocks an edit.
- Stop — what the session has done to the duplication surface, leading with the pairs it can
trace to a function you actually edited. Prints nothing on turns that changed nothing.
Each is driven by a doppel hook <name> subcommand reading a Claude Code hook payload on stdin and
writing a hook response on stdout. None ever exits non-zero: a measurement must not be able to break
a session.
See plugin/README.md for what the output means and how to read it honestly, and
Hooks and the Causal Window for why each hook fires where it does.