Skip to content
Draft
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
10 changes: 5 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ clonehunter scan [PATHS...] [--format json|html|sarif] [--out FILE] # default
--engine semantic|sonarqube --embedder codebert|stub|onnx|mlx --index brute|faiss --device auto|cpu|cuda
--threshold-func/-win/-exp FLOAT --min-window-hits INT --lexical-min-ratio/-weight FLOAT
--window-lines/-stride-lines/-min-nonempty INT --expand-calls [--expand-depth/-max-chars INT]
--cache-path PATH --cluster [--cluster-min-size INT]
--cache-path PATH
--repotype <lang>... --include-globs GLOB... --exclude-globs GLOB... # repeatable; layered (see below)

clonehunter diff --base REF [--format ...] [--out FILE] [--engine/-embedder/-index/-device ...]
Expand All @@ -29,7 +29,7 @@ clonehunter diff --base REF [--format ...] [--out FILE] [--engine/-embedder/-ind
2. **extract units** — python files → `extract_functions` (tree-sitter CST walk) go into *both* `python_functions` and `window_units`; every other file → one whole-file unit into `window_units` only.
3. **generate snippets** — FUNC (one per function) + WIN (sliding windows over every unit) + EXP (call-expansion, only if `expansion.enabled`), concatenated into one list. Each snippet's `text` is **tree-sitter comment-stripped** (analysis text); `display_text` keeps comments.
4. **embed** — `StubEmbedder` if `embedder.name==stub` else `CodeBertEmbedder` (candle XLMRobertaModel), `OnnxEmbedder` (`--features onnx`), or `MlxEmbedder` (`--features mlx`); results memoized in SQLite [src/embedding/cache.rs](src/embedding/cache.rs). Only cache-misses are embedded, in `batch_size` batches.
5. **similarity** — build the brute index, `retrieve_candidates` → `rollup_findings` → optional clustering.
5. **similarity** — build the brute index, `retrieve_candidates` → `rollup_findings`.
6. **assemble** — `ScanResult { findings, stats, config_snapshot, timing, degradations }` → the matching reporter.

## Repo layout
Expand All @@ -51,8 +51,8 @@ clonehunter diff --base REF [--format ...] [--out FILE] [--engine/-embedder/-ind
- `ranking.rs` (`kind_rank`, `best_match` — deterministic tie-break via `to_bits()`).
- `rollup.rs` (`rollup_findings`: filter-overlap → filter-lexical → dedupe → normalize a/b orientation → group by function pair → emit only if ≥1 reason; `_duplicated_lines`).
- `occurrences.rs` (`SelfCloneOccurrences` — union-find over overlapping spans; `covered_lines` uses adjacency; `occurrence_for` is `&self`).
- `clustering.rs` (union-find over `function.identity`; only runs when `cluster_findings` is on).
- [src/reporting/](src/reporting/) — `schema.rs` (`SCHEMA_VERSION = env!("CARGO_PKG_VERSION")`). `compare.rs` (`select_compare` → `best_match` for rendering). `json.rs` (`write_json`: `{schema_version, findings, stats, config, timing}`, each finding with a `similar`-crate unified diff). `sarif.rs` (`write_sarif`: SARIF 2.1.0, `note`-level results). `html.rs` (`write_html`: self-contained inline CSS/JS, `DiffOp` side-by-side diff, client-side sort; self-clone aware via `SelfCloneOccurrences`).
- `clustering.rs` — `build_groups` (always-on union-find over `function.identity`; derives stable, re-numbered `CloneGroup`s — `locations` identity-sorted, `finding_indices` sorted by pair identity — for reporters/stats).
- [src/reporting/](src/reporting/) — `schema.rs` (`SCHEMA_VERSION = env!("CARGO_PKG_VERSION")`). `compare.rs` (`select_compare` → `best_match` for rendering). `json.rs` (`write_json`: `{schema_version, groups, stats, config, timing, degradations}`; findings nest under `groups[].findings[]` via `build_groups`, each with a `similar`-crate unified diff). `sarif.rs` (`write_sarif`: SARIF 2.1.0, `note`-level results; still one result per finding, unaffected by grouping). `html.rs` (`write_html`: self-contained inline CSS/JS, `DiffOp` side-by-side diff, client-side sort; self-clone aware via `SelfCloneOccurrences`; always renders clone-family cards — a single-finding family is one open pair card, a multi-finding family collapses behind an outer card listing member locations then every finding as an equal diff card, self-clones labeled).
- [src/engines/](src/engines/) — `pipeline.rs` (`run_pipeline`: the 6-stage impl). `semantic.rs` (one-line delegate). `sonarqube.rs` (adapter: reads `CLONEHUNTER_SONAR_REPORT` env var, maps `duplications[]` → `Finding`s with `score=1.0`; no embedding/index). `mod.rs` (`get_engine`, `PipelineError`).
- [src/cli/](src/cli/) — `mod.rs` (clap derive; `Commands::Scan(Box<ScanArgs>)` boxed to avoid large-enum-variant; `run_scan` = build overrides → `resolve_config_root` walk-up → `load_config` → two-pass glob merge → engine.scan → reporter; `run_diff` = `changed_files` → full scan → filter findings to changed paths → reporter). `glob_merge.rs` (`REPO_TYPE_PRESETS`, `effective_repotypes`, `resolve_repotype_globs`, `merge_globs`, `validate_repotype`).
- [src/main.rs](src/main.rs), [src/lib.rs](src/lib.rs).
Expand All @@ -61,7 +61,7 @@ clonehunter diff --base REF [--format ...] [--out FILE] [--engine/-embedder/-ind

**Composite score** ([src/similarity/candidates.rs](src/similarity/candidates.rs)): `composite = (1 − lexical_weight)·embedding + lexical_weight·lexical`. A candidate is kept when `lexical ≥ lexical_min_ratio` **and** `composite ≥` the per-kind threshold (Func→`func`, Win→`win`, else→`exp`).

**Config defaults** ([src/core/config.rs](src/core/config.rs)): `engine="semantic"`; thresholds `func=0.92, win=0.90, exp=0.90, min_window_hits=1, lexical_min_ratio=0.5, lexical_weight=0.3`; windows `window_lines=40, stride=6, min_nonempty=4`; expansion `enabled=false, depth=1, max_chars=4000`; index `name="brute", top_k=25`; embedder `name="codebert", model="microsoft/codebert-base", revision=<pinned SHA>, max_length=256, batch_size=16, device="auto"`; cache `~/.cache/clonehunter`; `include_globs=["**/*.py"]`; `cluster_findings=false, cluster_min_size=2`.
**Config defaults** ([src/core/config.rs](src/core/config.rs)): `engine="semantic"`; thresholds `func=0.92, win=0.90, exp=0.90, min_window_hits=1, lexical_min_ratio=0.5, lexical_weight=0.3`; windows `window_lines=40, stride=6, min_nonempty=4`; expansion `enabled=false, depth=1, max_chars=4000`; index `name="brute", top_k=25`; embedder `name="codebert", model="microsoft/codebert-base", revision=<pinned SHA>, max_length=256, batch_size=16, device="auto"`; cache `~/.cache/clonehunter`; `include_globs=["**/*.py"]`.

**Glob layering** (`scan` only, applied after `load_config` in [src/cli/mod.rs](src/cli/mod.rs)): when `--repotype` is explicitly passed, the repotype preset **replaces** the config's include_globs entirely; when `--repotype` is omitted, the `monorepo` expansion is merged on top of config globs. Then `--include/--exclude-globs` are merged as the final CLI layer, with conflicts resolved in favour of the CLI layer. `--repotype none` produces empty include_globs → 0 files collected.

Expand Down
35 changes: 31 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,6 @@ Place a `clonehunter.toml` file in your repository root to configure CloneHunter

```toml
engine = "semantic"
cluster_findings = false
cluster_min_size = 2

[thresholds]
func = 0.92
Expand Down Expand Up @@ -250,8 +248,6 @@ clonehunter scan [PATHS...] [--format json|html|sarif] [--out FILE]
--expand-depth INT
--expand-max-chars INT
--cache-path PATH
--cluster
--cluster-min-size INT
--repotype dotnet|go|java|kotlin|monorepo|node|none|php|python|react|ruby|rust|swift|cpp
# repeatable preset globs
--include-globs GLOB # repeatable; merged with config includes
Expand Down Expand Up @@ -301,6 +297,37 @@ clonehunter diff --base HEAD --format json --out examples/clonehunter_diff.json
clonehunter diff --base HEAD --format html --out examples/clonehunter_diff_report.html
```

### Clone groups

When one function is duplicated across N files, detection emits the N·(N−1)/2 *pairwise*
findings. CloneHunter presents these as **clone groups** so an N-way duplicate reads as a single
group listing all its locations rather than a scatter of pairs.

**JSON** nests every pairwise finding under a top-level `groups` array (there is no flat
`findings` array):

```json
"groups": [
{ "id": 1,
"locations": [ { "file": {...}, "qualified_name": "...", "start_line": 1, "end_line": 10, "code_hash": "..." }, ... ],
"max_score": 0.98,
"max_duplicated_lines": 42,
"findings": [ { "function_a": {...}, "function_b": {...}, "score": 0.98, "duplicated_lines": 42, "compare": {...}, "reasons": [...] }, ... ] } ]
```

`locations` is the group's unique member functions; `findings` carries the pairwise evidence and
diffs. The shape is uniform: a 2-location clone is a **2-location, 1-finding** group, and
findings sharing a function merge into one N-location group. Consume it as
`for g in groups: for f in g["findings"]`.

`stats` gains **`group_count`** (number of clone groups) and **`grouped_function_count`** (unique
functions across all groups).

**HTML** always renders clone-family cards: a single-finding family opens directly as a pair diff,
while a larger family collapses behind one outer card that lists its member locations and renders
each finding as an equal side-by-side diff (self-clones labeled as internal duplication).
**SARIF** is unchanged — one result per finding.

---

## Tuning Tips
Expand Down
29 changes: 16 additions & 13 deletions benchmark/run_benchmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -439,22 +439,25 @@ def parse_metrics(
def _rel(p: str) -> str:
return p.removeprefix(prefix) if prefix else p

# Build sorted list of finding scores and file pairs for stable comparison
# Build sorted list of finding scores and file pairs for stable comparison.
# Findings are nested under groups (JSON schema #5); flatten groups[].findings[].
# Detection output is unchanged, so the extracted pairs/scores match the frozen baseline.
finding_scores: list[float] = []
finding_pairs: list[str] = []
for finding in warm_data.get("findings", []):
finding_scores.append(round(finding["score"], 6))
fa = finding["function_a"]
fb = finding["function_b"]
pair = "::".join(
sorted(
[
_rel(fa["file"]["path"]) + ":" + fa["qualified_name"],
_rel(fb["file"]["path"]) + ":" + fb["qualified_name"],
]
for group in warm_data.get("groups", []):
for finding in group.get("findings", []):
finding_scores.append(round(finding["score"], 6))
fa = finding["function_a"]
fb = finding["function_b"]
pair = "::".join(
sorted(
[
_rel(fa["file"]["path"]) + ":" + fa["qualified_name"],
_rel(fb["file"]["path"]) + ":" + fb["qualified_name"],
]
)
)
)
finding_pairs.append(pair)
finding_pairs.append(pair)

finding_scores.sort()
finding_pairs.sort()
Expand Down
3 changes: 0 additions & 3 deletions clonehunter.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,6 @@ exclude_globs = [
"**/__pycache__/**",
"**/site-packages/**",
]
cluster_findings = false
cluster_min_size = 2

[thresholds]
func = 0.92
win = 0.90
Expand Down
4 changes: 0 additions & 4 deletions docs/02-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ flowchart TD
subgraph S5 ["Stage 5 · Similarity"]
BLD["build vector index"] --> RET["retrieve_candidates<br/>(top-k neighbours + gates)"]
RET --> ROL["rollup_findings<br/>(group by function pair)"]
ROL --> CLU["cluster (optional)"]
end

S5 --> S6
Expand Down Expand Up @@ -135,9 +134,6 @@ This is where duplicates are actually found ([`src/similarity/`](../src/similari
3. **Roll up.** `rollup_findings` filters overlaps, applies the lexical gate a second
time, de-duplicates, normalizes each pair's orientation, groups matches by function
pair, and emits a `Finding` for each group that earns at least one reason.
4. **Cluster (optional).** If `--cluster` is set, findings are grouped into
connected components of related functions and small clusters are dropped.

The exact scoring and gate arithmetic is the subject of the
[next chapter](03-detection.md).

Expand Down
11 changes: 5 additions & 6 deletions docs/03-detection.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,13 +127,12 @@ The `duplicated_lines` on a finding answers "how much code is actually shared."

Both are order-independent — feed the same spans in any order, get the same count.

## Clustering (optional)
## Clone families

With `--cluster`, `cluster_findings` ([`clustering.rs`](../src/similarity/clustering.rs))
runs a union-find over function identities: every finding links its two functions,
and the connected components are the clusters. Clusters smaller than
`cluster_min_size` (default 2) are filtered out. Clustering only groups existing
findings for presentation — it never creates or removes a duplicate relationship.
`build_groups` ([`clustering.rs`](../src/similarity/clustering.rs)) runs a union-find
over function identities: every finding links its two functions, and the connected
components are the clone families shown in JSON/HTML. This grouping is always on for
presentation and stats, but it never creates or removes a duplicate relationship.

## Where the knobs live

Expand Down
4 changes: 2 additions & 2 deletions docs/05-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ flowchart TD
| [`snippets/`](../src/snippets/) | Comment-strip normalization, FUNC/WIN generators, call-expansion. |
| [`embedding/`](../src/embedding/) | The `Embedder` trait, four backends, and the SQLite cache. See [chapter 4](04-embeddings-and-backends.md). |
| [`index/`](../src/index/) | The `VectorIndex` trait and the brute-force cosine implementation. |
| [`similarity/`](../src/similarity/) | The detection heart: candidates, lexical, scoring, ranking, rollup, occurrences, clustering. See [chapter 3](03-detection.md). |
| [`similarity/`](../src/similarity/) | The detection heart: candidates, lexical, scoring, ranking, rollup, occurrences, and clone-family grouping. See [chapter 3](03-detection.md). |
| [`reporting/`](../src/reporting/) | HTML/JSON/SARIF writers + the shared `compare` selector. See [chapter 6](06-config-cli-and-reports.md). |
| [`engines/`](../src/engines/) | `pipeline.rs` (the semantic implementation), `semantic.rs` (delegate), `sonarqube.rs` (adapter), `get_engine`. |
| [`cli/`](../src/cli/) | clap-derive arg parsing, config resolution, glob merging, command dispatch. |
Expand All @@ -61,7 +61,7 @@ flowchart LR
- **`FileRef`** — a collected file: path, language, content hash, and the file bytes
(carried so parsing never re-reads disk; excluded from serialization).
- **`FunctionRef`** — a unit of code. `identity()` = `"{path}:{qname}:{start}:{end}"`
is the stable key used for grouping and clustering.
is the stable key used for grouping and family derivation.
- **`SnippetRef`** — the embedding/matching unit: `kind` (Func/Win/Exp), analysis
`text`, `display_text`, and `snippet_hash` (its index/cache identity).
- **`Embedding`** — a vector of `f32`.
Expand Down
21 changes: 15 additions & 6 deletions docs/06-config-cli-and-reports.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ share a common set of flags (paths, `--format`, `--out`, `--engine`, `--embedder
`--index`, `--device`):

- **`scan [PATHS…]`** carries the full tuning surface — thresholds, windows,
expansion, cache path, clustering, and glob/repotype selection.
expansion, cache path, and glob/repotype selection.
- **`diff --base REF`** carries only the common flags plus `--base`. It scans, then
keeps only findings touching a git-changed file.

Expand Down Expand Up @@ -104,13 +104,22 @@ flowchart TD
S -. does NOT use .-> CMP
```

- **JSON** ([`json.rs`](../src/reporting/json.rs)) — the richest. Full findings with
both functions, the best-match `compare` block (kind, span, similarity, unified
diff), duplicated-line count, reasons, plus top-level `stats`, `config`, `timing`,
and `degradations`. Schema-locked by golden snapshot tests.
- **JSON** ([`json.rs`](../src/reporting/json.rs)) — the richest. Findings are nested
under a top-level **`groups`** array (there is no flat `findings` array): each group
carries its unique member `locations`, `max_score`/`max_duplicated_lines`, and its
pairwise `findings` (both functions, the best-match `compare` block — kind, span,
similarity, unified diff — duplicated-line count, reasons). A 2-location clone is a
group with 2 locations and 1 finding; findings sharing a function merge into one
N-location group. Plus top-level `stats` (now including `group_count` and the
de-duplicated `grouped_function_count`), `config`, `timing`, and `degradations`.
Schema-locked by golden snapshot tests. Groups are derived at serialization time by
`similarity::build_groups` and are stable/re-numbered — detection output is unchanged.
- **HTML** ([`html.rs`](../src/reporting/html.rs)) — the same findings rendered for a
human: self-contained page (inline CSS/JS), side-by-side diff, self-clone-aware
display, client-side sorting, and a degradation banner.
display, client-side sorting, and a degradation banner. It always renders clone-family
cards: single-finding families open directly as pair diffs, while larger families
collapse behind an outer card that lists the member locations and renders every finding
as an equal side-by-side diff (self-clones labeled as internal duplication).
- **SARIF** ([`sarif.rs`](../src/reporting/sarif.rs)) — a lean SARIF 2.1.0 document of
`note`-level results with rule id, message, and physical location per finding. For
code-scanning integrations (e.g. GitHub Code Scanning); it deliberately carries **no
Expand Down
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Start at the top and stop when you know enough. Each doc is self-contained.
|---|-----|------------------------|
| 1 | [Concepts & glossary](01-concepts.md) | What a "clone" means here, and the vocabulary (snippet, FUNC/WIN/EXP, composite score) used everywhere else. Start here if you're new. |
| 2 | [The detection pipeline](02-pipeline.md) | The end-to-end flow: how source files become findings, stage by stage. **The core of the system.** |
| 3 | [Detection internals](03-detection.md) | How a candidate becomes a finding: composite scoring, the two retrieval gates (lexical floor + per-kind threshold), rollup, self-clones, clustering. |
| 3 | [Detection internals](03-detection.md) | How a candidate becomes a finding: composite scoring, the two retrieval gates (lexical floor + per-kind threshold), rollup, self-clones, and clone families. |
| 4 | [Embeddings & backends](04-embeddings-and-backends.md) | How code becomes a vector, the four interchangeable backends, the embedding cache, and the **Rust → C++ → C → Metal language handoffs**. |
| 5 | [Code architecture](05-architecture.md) | The module map, the core data types, and the control flow from `main` to the reporter. |
| 6 | [Config, CLI & reports](06-config-cli-and-reports.md) | How configuration is layered, the CLI surface, glob/repotype selection, `scan` vs `diff`, and the three report formats. |
Expand Down
Loading