Skip to main content

find-dup-defs

Rust 2021 License: MIT crates.io PyPI exact difflib

Your coding agent is stateless, and your codebase doesn't fit in its context window. So when it writes a new function, it can't see that you already wrote that helper three modules over — it writes the copy. Over a year of AI-assisted commits, duplication stops being an accident and becomes the default.

find-dup-defs is the gate that catches it. It clusters duplicate and near-duplicate definitions — functions, methods, classes, constants, type aliases, TS interfaces, Rust traits — across Python, TypeScript and Rust; grades each cluster by how much a refactor would actually pay off; and calibrates its own noise filters to your tree. One parse per file, three frontends (Ruff, oxc, syn), and 2–12× faster than PMD CPD and jscpd while doing more semantic work than either.

Run it without installing anything:

uvx find-dup-defs ./src

Prebuilt wheels for Linux, macOS and Windows are on PyPI — no Rust toolchain needed:

pip install find-dup-defs        # or: uv tool install find-dup-defs

Or install from crates.io (builds from source):

cargo install find-dup-defs

or grab a prebuilt binary from the Releases page.

Why

GitClear's 2025 report measured 211M changed lines: copy-pasted lines grew from 8.3% to 12.3% of all changes between 2021 and 2024, while refactored lines fell from 25% to under 10%. For the first time on record, copy/paste exceeded reuse.

That isn't a coincidence, it's a mechanism. A human who half-remembers writing something greps for it. An agent can't — it holds a few thousand lines of your repo at once, your _helpers.py isn't among them, and emitting a fresh copy is locally the path of least resistance. Every copy is individually reasonable; the aggregate is a codebase that says the same thing five ways. A linter won't flag it, because each copy is valid code. You need something that looks across files at the definitions themselves.

How to?

Start with calibration. It never gates anything — it reads your tree and reports back:

$ find-dup-defs ./src --calibrate
=== thickness calibration (ERROR): 76 clusters analyzed ===
  T [0.2, 0.3)  ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 25
  T [0.3, 0.4)  ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 27
  T [0.4, 0.5)  ▇▇▇▇▇▇▇▇▇ 8

suggested thresholds (p50/p75/p90):
  balanced   --error-thickness 0.34  →  21 ERROR remain  (median dup: 14 loc, 2 args)

=== inferred directives (auto-detected noise patterns) ===
  → -D 'de-escalate:*@*/{test,tests,__tests__}/*=test parametrize/fixture candidates'
    rationale: 21 clusters live entirely in test paths
    affects: 21 total (10 ERROR, 11 WARNING, 0 INFO)

Three things come out: a histogram of how refactor-worthy your duplication is; threshold suggestions at the 50th/75th/90th percentile, each with a real code sample at the cut so you see what you'd be gating on; and inferred directives — ready-to-paste -D strings for the noise it found in your tree, each with its rationale and blast radius. Twenty-one clusters living entirely under tests/? It hands you the de-escalation rule for exactly that.

Then commit the suggestions you agree with and gate CI on the rest:

find-dup-defs ./src --error-thickness 0.5 -D @find-dup-defs.directives --errors-only

And, opt-in, surface the duplication that should become a helper rather than just being deleted:

find-dup-defs ./src --patternology

Nothing is filtered until a directive says so. Calibration suggests; the committed file decides.

What it finds, and why not just CPD

Three passes, all from the same single parse per file.

Pass Catches How
name-gated same-named copies defs sharing a (kind, name) clustered by exact Ratcliff–Obershelp similarity on the alpha-renamed canonical (via difflib-fast)
cross-name renamed copy-paste the alpha-renamed canonical bucketed; ≥2 distinct names across ≥2 files
Type-3 (ECScan) renamed and edited copies IDF-weighted cosine over name-agnostic lines, as an exact all-pairs cosine join — catches what byte-identity misses

The thing token-based clone detectors (jscpd, PMD CPD) structurally can't do is the middle two rows. They match token streams; rename the variables or edit a line and the match is gone. find-dup-defs clusters on an alpha-renamed AST canonical — every bound local rewritten to _v0, _v1, …, the def's own name blanked to _fn — so a function and its renamed-and-edited twin collapse to the same shape. The Type-3 pass goes further still: it builds IDF-weighted per-line vectors and runs them through difflib-fast's simjoin, an exact L2AP weighted-cosine join (every pair with cos ≥ θ, no LSH approximation, asserted bit-identical to brute force), then single-linkages the survivors.

So the answer to "why not CPD" isn't one feature, it's the stack: we cluster by meaning not tokens, we calibrate the noise ourselves, we rank by refactor-payoff instead of dumping a flat list — and we do all of that 2–12× faster than CPD while doing strictly more work per finding. (Performance has the numbers.)

Method receivers (self, cls, &self) are stripped, so a method matches the equivalent free function. And the shapes that look like duplication but aren't never form clusters in the first place:

  • Python / TS@overload / @abstractmethod / Protocol stubs (... / pass / docstring bodies), raise NotImplementedError, dispatch overrides that just return None / False / 0 / self, and @property setter/deleter accessors (suffixed so a getter never matches its setter).
  • Rust — one-line write! / writeln! Display/Debug impls, matches! predicates, todo! / unimplemented! / panic! / unreachable! stubs; and #[cfg(...)]-gated same-name siblings (#[cfg(unix)] fn x + #[cfg(windows)] fn x) collapse to one logical item.

Each surviving cluster lands in a tier: ERROR gates CI, WARNING is for review, INFO is hidden unless you ask (--show-info, or --json where it's always present). --only py,ts,rs scopes a run to specific frontends.

Thickness

What moves a cluster between tiers is its thickness — a normalized [0, 1] estimate of how much deleting the duplication would pay. It's the number you sort by, and it's exactly this:

T = 0.7 · sat(volume, 30) + 0.1 · sat(args, 5) + 0.2 · sim       sat(x, k) = 1 − exp(−x/k)
volume = (n_members − 1) · loc        # lines a refactor would actually delete

Volume dominates on purpose — a 60-line function copied four times outranks a 3-line one copied six, whatever the similarity scores say. Wide signatures and higher similarity nudge it up. Three flags move the cut: --error-thickness demotes thin ERRORs to WARNING, --warning-thickness demotes thin WARNINGs to INFO, and --escalate-thickness forces anything thick enough up to ERROR (applied last, so it overrides the demotions). Each defaults to 0.0 — off — until calibration tells you a number. Sort by T and the biggest refactor is on top.

Calibration & directives

The tool is meant to tune itself once, then be gated by an explicit, committed config — never by hidden heuristics.

--calibrate prints the thickness histogram, three percentile-anchored threshold suggestions (permissive / balanced / strict at p50 / p75 / p90, each with a concrete code sample at the cut), and inferred directives: ready-to-paste -D strings for the noise patterns it found in your tree. It only fires a suggestion when the evidence clears a floor:

Detected pattern Floor Suggested directive
clusters entirely in test dirs ≥3 de-escalate:*@*/{test,tests,__tests__,fixtures,integration,e2e}/*
clusters in .test.* / .spec.* files ≥3 de-escalate:*@*.{test,spec}.*
generated code (*_pb2*, *_grpc*, *.gen.*) ≥3 suppress:*@*_pb2*
schema migrations ≥3 suppress:*@*migrations/*
.d.ts declaration files ≥3 suppress:*@*.d.ts
i18n / locale / translation dirs ≥5 suppress:*@*/{locale,locales,i18n,translations}/*
doc / tutorial / example snippets ≥5 de-escalate:*@*/{examples,tutorial,samples}/*
Storybook stories ≥5 de-escalate:*@*.stories.*
vendored / fork snapshot roots ≥30 suppress:*@*<prefix>* (auto-derived, marker-gated)
(kind,name) group > 256 members settings:max-name-group=256
patternology candidates present ≥8 settings:pattern-min-thickness=<p75>

The vendored detector is marker-gated: it only fires on directories carrying a real vendoring signal (/vendor/, /third_party/, /util/vs/, /fixtures/, …). Same-name files across dirs without a marker stay visible — that's genuine cross-layer duplication, not vendoring.

The rule language is directiva, one rule per line:

ACTION : [<KIND>] NAME [@PATH] [=NOTE]

suppress drops a finding, de-escalate / escalate move it one tier (stepped and clamped), note annotates without touching severity, and set carries pipeline config (set:max-name-group=256, set:gpu=on, set:pattern-min-thickness=0.5). The note travels with the rule, so the why is still there when someone reads the file a year later:

-D 'de-escalate:<methods>Plugin.get_*_hook=intentional plugin no-op API'
-D 'suppress:<functions>spawn@*lib-rt/*=bootstrap copy, cannot import'
-D 'escalate:<methods>Lock.*@*/storage/*=must share impl before v1.0'

# keep them in a committed file and point CI at it (one per line; # comments; @- reads stdin)
-D @find-dup-defs.directives

Globs support {a,b,c} alternation, so one paste covers a whole convention family.

Patternology

The passes above answer "are these two definitions the same?". Patternology answers the next question: "this shape that recurs across seven functions — should it be one helper?" It's the same engine carried one step further — same alpha-renamed canonical forms, same Finding / severity / directive pipeline — not a separate tool bolted on. It's opt-in (--patternology) and advisory: WARNING for a tight family, INFO otherwise, never an ERROR gate. A refactor map, not a CI failure.

$ find-dup-defs ./crates --only rs --patternology     # the tool on its own code
--- helper candidates in functions (patternology — collapsible duplication) ---
DUPLICATE FUNCTION [WARNING]: analyze_impl_fn/analyze_item_fn  [ast sim 1.00, n=2, loc=3, args=1]
  # helper: fn _fn(_v0: &?) -> AnalyzedFn { analyze(&_v0.sig.ident.to_string(), &_v0.sig, &_v0.block) }
  #         (1 param); collapses 2 sites, ~3 loc saved

The mechanism

A family of instances is folded by Plotkin anti-unification (least general generalization) into a template with holes ? at the points where the instances diverge. Folding aligns same-tagged nodes by their common prefix and lists by longest-common-subsequence, so it's robust to arity divergence — [A, B, C] against [A, C] generalizes to [A, ?, C], not to a single hole. It's also async-insensitive: the fold strips the Async tag, so an async def and its sync twin anti-unify cleanly (the botocore ↔ aiobotocore mirror case).

Then the template has to survive, and most don't. A candidate is kept only if its holes are bindable expression parameters — things you could actually pass to a function. The filters, with their real defaults:

  • no statement-holes. A divergence in statement position can't be passed as an argument — you can't hand a function a missing if. Rejected.
  • no selector-holes. A varying method or attribute or keyword nameobj.?(), ?=val — would need getattr / **{name: v} reflection to parameterize. A helper that needs reflection isn't a helper, so it's rejected rather than surfaced.
  • a shared-anchor floor (≥2). The instances must share real identifiers or literals, not just tree shape. This kills pure-structure coincidences like ? = ?; ? = ? — two assignments that have nothing to do with each other.
  • a substantial fixed skeleton (≥6 shared nodes), a manageable arity (≤6 expression-holes → parameters), and a skeleton that dominates the variation (fixed / (fixed + holes) ≥ 0.5).

What's left is a motif that genuinely collapses into one clean, reflection-free helper. The proposed body is rendered as readable pseudo-source (def …: for Python, fn … for Rust, the matching shape for TS), and the finding carries its parameter count and an estimated LOC saved.

Two granularities

  • whole-function — families that share an entire shape, found by structural tf·idf cosine over node-type q-grams and a greedy maximal-clique cover (not connected components, which would single-linkage a whole dense neighborhood into one blob).
  • sub-block — a recurring statement-window idiom embedded inside otherwise-different functions, mined by support — how many functions contain it — not pairwise similarity, which is the case whole-function cosine structurally cannot reach. A fetch-one idiom shared across seven unrelated repository methods comes out as ? = await _v0.execute(?); return ?.scalar_one_or_none() (3 params).

Codometry

Every candidate carries a stable signature key: the fixed skeleton with holes as ? and atoms verbatim, rendered deterministically. The same idiom in different files — or different packages — produces the same key. So an external loop turns patternology into a measurement instrument:

for pkg in $(ls ~/.cargo/registry/src/*/); do
  find-dup-defs "$pkg" --patternology --json
done | jq -s 'map(.groups[] | select(.pattern)) | group_by(.pattern.signature)'

Group by signature across an ecosystem and you get codometry — which idioms recur where, at what support, weighted by the LOC each collapse would save. Nobody else can produce that number, because nobody else carries a cross-package-stable structural key on each finding.

The dialect seam is a Dialect trait — slot classification plus a pseudo-source renderer — with PyDialect, RustDialect and TsDialect behind it. A run partitions defs by language and folds each group with its own dialect; Python, TypeScript and Rust functions never anti-unify against each other.

Knobs: --pattern-theta (whole-fn cosine floor, default 0.85), --pattern-support (sub-block support floor, default 3), and -D settings:pattern-min-thickness=<F> to drop the thin two-site tail (--calibrate suggests the value).

Lenses

Every pass above canonicalizes one text — the definition's body — and varies only how much identity it strips. That is one axis. A lens varies the text instead: it projects the same definition onto a different question and throws the rest away.

The case that motivates it: two caches, one storing a JSONB blob and one storing typed columns, sharing no identifier anywhere. Same architecture, written twice.

$ find-dup-defs ./cache_a ./cache_b
No cross-file duplicates.

Nothing, and the reason is exact — the model's own name is a free name in the body canonical, so session.get(JsonCache, k) and db.get(ThumbEntry, i) never meet. Erase what the module itself introduced (imports, sibling definitions, class fields — renamed in attribute position too, which the local set never reaches) and what survives is the grammar of talking to things the module did not define:

$ find-dup-defs ./cache_a ./cache_b --kinds lenses
DUPLICATE LENSES [WARNING]: cache_get/thumb_lookup    [normalized-exact, T=0.60, n=2, loc=7]
    votes[5]: control×3 effects×1 outgoing×1 scope×1 signature×1
DUPLICATE LENSES [WARNING]: cache_put/thumb_store     [normalized-exact, T=0.65, n=2, loc=8]
    votes[5]: control×2 effects×2 outgoing×2 scope×1 signature×1
DUPLICATE LENSES [WARNING]: cache_evict/thumb_purge   [normalized-exact, T=0.73, n=2, loc=2]
    votes[4]: effects×3 outgoing×3 scope×1 signature×1

Ten lenses, each answering one question:

lens question keeps
outgoing what does it depend on? the set of callees the module did not introduce
effects what protocol does it drive? the same callees in call order
control how does it branch? the if/for/while/try/return/raise skeleton, with nesting
failures how does it fail? raised and caught exception types
resources what does it hold open? context expressions of with blocks
signature what contract does it offer? arity shape and annotation names — what it has, never what it lacks
decorators what role does it play? decorator names
schema what shape does it declare? column types and their options, as an unordered set
scope what does its body do? the body with every name its module introduced erased
use how is it handled? the statements elsewhere in the tree that mention it

Two of them needed their own treatment. schema compares declarations, where order is not meaning and the literals are identities: facts are sorted as a set, and __tablename__, index names and foreign-key targets are dropped while the ForeignKey / Index call survives — that a column references something is shape, which table it references is identity. Column types stay verbatim despite being imported: they are the grammar a schema is written in. use cannot be computed from a definition alone, so its facts are merged in after the tree is walked; assembly is by name, with no import resolution and no call graph — the assumption the name-gated pass has always made.

Agreement is the signal

All ten stitch into one record, each fact tagged with the lens it came from (control:if, outgoing:.commit, schema:col Text nullable). The Type-3 pass's IDF-weighted cosine over those lines then is the vote — nothing new had to be built. Agreeing through several lenses raises the score, agreeing through one barely moves it, and a fact the whole corpus shares (control:return) is weighted to nothing without anyone declaring it noise. A cross-name exact match means every lens agreed at once.

Each finding reports which lenses agreed and by how many facts, because similarity alone says how close two definitions are and never through what. Measured on one production tree, mean thickness climbs with the count — 0.71 at one vote, 0.79 at three, 0.89 at five, 0.92 at six — even though the score is computed from corpus IDF and knows nothing about votes. The two are independent estimates of the same thing, which is the best evidence the weighting works that could be had without tuning it to fit.

A lens is only safe if its facts are either rare (informative) or universal (IDF ≈ 0). Many facts of middling frequency are the failure mode: signature used to emit posonly 0 / kwonly 0 / async 0 for every ordinary function — seven facts about nothing — and dominated two thirds of all findings on that tree, collapsing thousands of unrelated definitions into one cluster. It now reports only what a signature has. Worth checking for any lens you add.

What it finds that the body passes cannot

a Timeout and a Delay enricher differing in one call, fifteen lines of identical plumbing sim 0.98
MediaConfigResource twice — the legacy and the authenticated media endpoint, differing in one regex sim 1.00
five Delete*Command classes on one template, one of them annotated list[Dashboard] by copy-paste sim 1.00
the same OAuth setup step in two self-hosted integrations, 49 lines each sim 1.00
six TypeGuard predicates across three files, docstrings included normalized-exact
AmplitudeClient in six places, the shared-library copy behind the forks that grew a feature sim 0.55

A finding carried by a single lens is weak by construction — the vote count is there to be read.

Opt in with --kinds lenses (Python only). The kind exists exactly when asked for, so the section list, the default report and the default JSON are byte-identical without it. Directives address it like any other kind — -D 'suppress:<lenses>*@*/legacy/*=deliberate parallel port'.

Performance

This is the part the tool is fastest at being smug about. hyperfine --warmup 1 --runs 3, macOS arm64, against jscpd@4 and PMD CPD 7.24, both in Python mode on the same trees:

repo (Python files) find-dup-defs PMD CPD jscpd
pip (633) 0.18 s 0.87 s (4.9×) 3.21 s (18.2×)
mypy (155) 0.18 s 0.81 s (4.6×) 1.47 s (8.4×)
sympy (1 589) 1.22 s 4.29 s (3.5×) 15.18 s (12.4×)
django (2 910) 1.01 s 2.08 s (2.1×) 9.67 s (9.6×)

It does more semantic work than either — alpha-renamed canonicals, an exact IDF cosine join, severity grading, calibration — and is still 3–12× faster, because it's Rust + rayon over single-parse frontends with no JVM or Node startup to amortize. Throughput on django (426K SLOC) is ~422K SLOC/s, against PMD's ~205K and jscpd's ~44K.

GPU acceleration (optional, macOS / Metal) — and why it rarely matters

difflib-fast can offload the name-gated Ratcliff–Obershelp clustering to the Apple-Silicon GPU via its Rationer handle. It's off by default and gated twice: build with --features gpu, enable with -D 'settings:gpu=on' (on / gpu+cpu / gpu / off). Only large all-ASCII same-name groups (≥ ~300 members) route to Metal; everything else stays on CPU, and the output is byte-for-byte identical in every mode.

In practice it rarely helps end-to-end. The GPU accelerates clustering of a single large group (1.1–1.4× in difflib-fast's own bench), but this tool's real workload is many mostly-small groups. On rustc/tests/ui (20 425 files, with fn main × 12 678): gpu=off 33.97 s, gpu=on 33.62 s. A tie. Keep CPU for everyday runs.

On real repos

Ten production TypeScript repos (vscode, the TS compiler, vue, angular, svelte, nest, astro, prisma, next.js, excalidraw; ≈6M SLOC), with --calibrate, the inferred directives, and the balanced thickness cut — raw ERROR count drops 94% on average:

repo LOC raw ERROR after %cut top remaining cluster
microsoft/vscode 3.1M 5428 174 97% registerCLIChatCommands 771 LOC
microsoft/TypeScript 265k 1840 9 100% NavigationBarItem interface
vercel/next.js 756k 489 26 95% defaultLoader 115 LOC
angular/angular 1.0M 627 54 91% conditionalCreate/conditionalBranchCreate
prisma/prisma 222k 322 68 79% fieldToColumnType 95 LOC × 3 adapters

Twenty-eight large Python repos (≈8M SLOC), auto-applied directives, 67% average cut:

repo raw ERROR after %cut top remaining cluster
home-assistant/core 4475 850 81% ConfigFlow.async_step_* (n=178)
apache/airflow 2203 337 84% CloudComposerGetEnvironmentOperator (n=18)
django/django 559 71 87% TupleGreaterThan.get_fallback_sql (n=4)
scipy/scipy 492 140 71% dct/dst/idct/idst (n=4)
pandas-dev/pandas 406 78 80% read_csv/read_table (n=2)

What's left at the top is the kind of thing a human reviewer would also flag. pip's Version __lt__…__gt__ ×6 collapse into one _compare helper, −130 lines. scipy's dct/dst/idct/idst ×4 want a factory, −330 lines. scikit-learn's BaseSGD{Classifier,Regressor}._fit is a sibling-estimator dupe waiting for a shared impl. The vendored snapshots, test fixtures, .d.ts and Storybook noise is gone before you read a line.

For agents

The JSON output is built so an agent never has to round-trip to the filesystem. Each finding ships the full source of one member (groups[].snippet), every location (members[] as file:line), the thickness for prioritization, the kind/severity/similarity, and any directive annotations (notes[]). Pattern findings additionally carry a structured pattern object — template, signature, params, granularity, support, loc_saved — so a consumer groups by signature without parsing prose. Lens findings carry facets[[lens, shared facts], …], strongest first — so a consumer can rank by how many perspectives agreed rather than by similarity alone, or filter to the ones a single lens carried. The field is omitted when a run produces no tagged facts, so the default document is unchanged.

# calibrate → JSON, then scan with the chosen tuning + inferred directives
find-dup-defs ./repo --calibrate --json > calib.json
find-dup-defs ./repo \
  --error-thickness <calib> \
  $(jq -r '.inferred_directives[].directive | "-D \"" + . + "\""' calib.json) \
  --errors-only --json > findings.json

Architecture

Six crates, layered so the engine never depends on a frontend and the contract crate stays pure:

              dup-defs-core            ← the contract: Def / KindSpec / Analysis / CanonDialect /
                  ▲                       the Frontend trait / LineMap.  No deps.
        ┌─────────┴─────────┐
   find-dup-defs-canon         find-dup-defs   ← find-dup-defs-canon: shared frontend helpers (alpha-rename, the
        ▲                  (engine+CLI)   KindSpec vocabulary, count_loc, AnalyzedFn).
   ┌────┼────┐               │           find-dup-defs: the 3 passes + patternology + severity +
 py-   rs-   ts-canon ───────┘           directives + calibration + reports.
 canon canon            (engine depends on the contract + each frontend, NOT on find-dup-defs-canon)

find-dup-defs is the engine and CLI; it clusters a Vec<Def> and never names a language. dup-defs-core is the engine↔frontend contract — Def, KindSpec, Analysis, the Frontend trait. find-dup-defs-canon holds the helpers the frontends share (the alpha-rename, the kind vocabulary, count_loc). py-canon, ts-canon and rs-canon are the frontends (Ruff, oxc, syn). Adding a language is one more <lang>-canon crate implementing Frontend — plus a Dialect impl if it wants patternology — and no engine changes.

The similarity engine underneath is difflib-fast, an exact Ratcliff–Obershelp + L2AP cosine-join port. And the tool eats its own cooking: this workspace gates to 0 ERROR under find-dup-defs crates -D @find-dup-defs.directives. (The file crates/find-dup-defs/src/simgraph.rs exists because an earlier run flagged the cosine/union-find helpers that type3 and patternology had each copied — so they were extracted into one module.)

CLI reference

USAGE:  find-dup-defs [OPTIONS] <PATHS>...

LANGUAGES
  --only <CODES>            Restrict to frontends (py,ts,rs). Default: all found in PATHS.
  --kinds <K,…>             functions,methods,classes,interfaces,constants,type-aliases
                            + `lenses` (opt-in, py only) — see Lenses

SEVERITY (thickness ladder)
  --error-thickness <F>     Demote ERROR → WARNING if T < F   (default 0.0 = off)
  --warning-thickness <F>   Demote WARNING → INFO  if T < F   (default 0.0 = off)
  --escalate-thickness <F>  Promote anything → ERROR if T ≥ F (default 0.0 = off, applied last)

SIMILARITY
  -t, --threshold <F>       Name-gated cluster floor   (default 0.5)
  -e, --error-threshold <F> Name-gated ERROR floor     (default 0.85)
  --type3-theta <F>         Type-3 cosine floor        (default 0.7)
  --max-name-group <N>      Skip name-gated clustering for (kind,name) groups > N

LENSES (opt-in · py only)
  --kinds lenses            Cluster by perspectives other than the body; each finding reports
                            which lenses agreed (`votes[n]: control×3 outgoing×2 …`)

PATTERNOLOGY (opt-in · advisory, never ERROR)
  --patternology            Surface collapsible-duplication helper candidates
  --pattern-theta <F>       Whole-fn structural cosine floor (default 0.85)
  --pattern-support <N>     Sub-block idiom support floor     (default 3)

FILTERS / MODES
  -D, --directive <S>       ACTION:[<KIND>]NAME[@PATH][=NOTE], repeatable. ACTION ∈
                            suppress / de-escalate / escalate / note / set:KEY=VALUE.
                            `@PATH` reads a directive file (# comments; @- = stdin).
  --min-size <N>            Only clusters with ≥ N members (default 2)
  --errors-only             Filter output to ERROR
  --show-info               Include INFO in the human report
  --calibrate               Histogram + threshold suggestions + inferred directives
  --json                    Machine-readable output
  --no-cross-name / --no-type3   Skip pass 2 / pass 3

Limitations

The honest ledger:

  • Python, TypeScript and Rust today; patternology covers all three. A new language is a <lang>-canon sibling crate.
  • Rust patternology is the youngest of the three: rs-canon splices statement bodies as node children rather than lists, so long-body alignment is prefix-only, and macro internals are opaque.
  • TypeScript patternology sees top-level function declarations and arrow / function-expression consts. Class methods don't participate — their slice doesn't re-parse as a standalone function, so they carry no patternology canonical. The duplicate passes still cover them.
  • Lenses are Python-only. Nine of the ten read the definition's own tree and would port to another frontend as-is; use needs the tree-wide mention index that py-canon builds.
  • The use lens assembles by name with no import resolution, which holds while top-level names are effectively unique (measured: 2435 distinct across 2444 classes in one production tree) and degrades on trees where one name covers hundreds of definitions — the case --max-name-group exists for.
  • Type-4 clones (same logic, different syntax) are out of scope.
  • Token-level sub-expression duplication is out of scope too; pair with jscpd or PMD CPD if you need it.
  • The thickness constants were tuned on the benchmark corpora above. Your codebase may want different ones — that's what --calibrate is for.

Copy-paste has nowhere left to hide.

Made with ⚡ by @prostomarkeloff

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

find_dup_defs-0.8.0.tar.gz (225.1 kB view details)

Uploaded Source

Built Distributions

If you're not sure about the file name format, learn more about wheel file names.

find_dup_defs-0.8.0-py3-none-win_amd64.whl (3.0 MB view details)

Uploaded Python 3Windows x86-64

find_dup_defs-0.8.0-py3-none-musllinux_1_2_x86_64.whl (11.5 MB view details)

Uploaded Python 3musllinux: musl 1.2+ x86-64

find_dup_defs-0.8.0-py3-none-musllinux_1_2_aarch64.whl (11.5 MB view details)

Uploaded Python 3musllinux: musl 1.2+ ARM64

find_dup_defs-0.8.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (12.3 MB view details)

Uploaded Python 3manylinux: glibc 2.17+ x86-64

find_dup_defs-0.8.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (11.4 MB view details)

Uploaded Python 3manylinux: glibc 2.17+ ARM64

find_dup_defs-0.8.0-py3-none-macosx_11_0_arm64.whl (3.2 MB view details)

Uploaded Python 3macOS 11.0+ ARM64

find_dup_defs-0.8.0-py3-none-macosx_10_12_x86_64.whl (3.2 MB view details)

Uploaded Python 3macOS 10.12+ x86-64

File details

Details for the file find_dup_defs-0.8.0.tar.gz.

File metadata

  • Download URL: find_dup_defs-0.8.0.tar.gz
  • Upload date:
  • Size: 225.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.8.1

File hashes

Hashes for find_dup_defs-0.8.0.tar.gz
Algorithm Hash digest
SHA256 6ef5c55d318591fa82e20cdc1d74c2cd26012984caadc046436c962619bfb465
MD5 d4c6dde12ef2a31c113884f61c2153f2
BLAKE2b-256 f6f22aa3ec3d864b73c43897851646391311ed293afea31df0ae0626172643e2

See more details on using hashes here.

File details

Details for the file find_dup_defs-0.8.0-py3-none-win_amd64.whl.

File metadata

File hashes

Hashes for find_dup_defs-0.8.0-py3-none-win_amd64.whl
Algorithm Hash digest
SHA256 5687fa43230d8964f5cc81342abed60355566481eef54d51b9e96a4a0d0cc1c7
MD5 8af87d31904b4e1950a2a2a7ede56d65
BLAKE2b-256 67dd95ed3c9711e0d83b83b4d45d36b33f31e78f5a2356723ba97783bbf05aaf

See more details on using hashes here.

File details

Details for the file find_dup_defs-0.8.0-py3-none-musllinux_1_2_x86_64.whl.

File metadata

File hashes

Hashes for find_dup_defs-0.8.0-py3-none-musllinux_1_2_x86_64.whl
Algorithm Hash digest
SHA256 e10c40bd197a637bb913f514dd317a7993b97a52a66a6970b246c773258c572c
MD5 324a4fbb327c12bc5856cd3cc4266de3
BLAKE2b-256 a7d796a4cbd73f49db5e8b6ec7973c0953030552bfcc695507a5543caa66e169

See more details on using hashes here.

File details

Details for the file find_dup_defs-0.8.0-py3-none-musllinux_1_2_aarch64.whl.

File metadata

File hashes

Hashes for find_dup_defs-0.8.0-py3-none-musllinux_1_2_aarch64.whl
Algorithm Hash digest
SHA256 8c5b33084eb0f9640352942ed73ba261bf1ed1b4562c3199c44f81e81615f380
MD5 d00d8531e59294e747709f661786a9bf
BLAKE2b-256 33fd71c94810532938595c1064cac57f01d5aa5ee41356b967221393aa507945

See more details on using hashes here.

File details

Details for the file find_dup_defs-0.8.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for find_dup_defs-0.8.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 ff00d19d0ead28e78740c513467719f5ba3c008a79ed2414ad29d402464c8923
MD5 e0cb0bf57ef44803ace5a0bb8032e39c
BLAKE2b-256 0b25f130d218722eab0cac1d50782bf4c661151205d2c9b1d8e66b7d82d27a64

See more details on using hashes here.

File details

Details for the file find_dup_defs-0.8.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.

File metadata

File hashes

Hashes for find_dup_defs-0.8.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Algorithm Hash digest
SHA256 9862793059c639dec43eb3b3f9edd055468345144b7322c9a95202803b703738
MD5 45e53784358695b678c6b18ddd2d76af
BLAKE2b-256 f18414d90d698b647bdd644d7fd6649761d5f011cf1a1918f3de2abd15600adb

See more details on using hashes here.

File details

Details for the file find_dup_defs-0.8.0-py3-none-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for find_dup_defs-0.8.0-py3-none-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 2262cc699a45e9bd0e9547b1dca51bcb5b6e633ca3690b882faf3aa66f488596
MD5 2112b825fefbe4bae3cf7cf7a4d3e417
BLAKE2b-256 9dca1adff1984a26652fedc340e1ece36d715567b32e15e0a417aa07a1d2e56d

See more details on using hashes here.

File details

Details for the file find_dup_defs-0.8.0-py3-none-macosx_10_12_x86_64.whl.

File metadata

File hashes

Hashes for find_dup_defs-0.8.0-py3-none-macosx_10_12_x86_64.whl
Algorithm Hash digest
SHA256 ffa33ee358427bb60928e41c535603f3b0d00fb9e8baddd587fddd5f822ff0df
MD5 eb8c8267bf2015586a9514635dcdef3c
BLAKE2b-256 3636945ef49f9ea170c3b1555dd3015b8f520184f314ad8c8a770b59ac0e4a2f

See more details on using hashes here.

Release history Release notifications | RSS feed

0.13.2

8 files

0.13.1

8 files

0.13.0

8 files

0.12.0

8 files

0.11.0

8 files

0.10.0

8 files

0.9.0

8 files

This release

0.8.0 This release

8 files

0.7.6

8 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page