diff --git a/CLAUDE.md b/CLAUDE.md index 297dd3d..d83316a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ... --include-globs GLOB... --exclude-globs GLOB... # repeatable; layered (see below) clonehunter diff --base REF [--format ...] [--out FILE] [--engine/-embedder/-index/-device ...] @@ -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 @@ -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)` 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). @@ -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=, 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=, 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. diff --git a/README.md b/README.md index 2eee3a1..1a069e7 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 diff --git a/benchmark/run_benchmark.py b/benchmark/run_benchmark.py index 7b121bf..aba9c61 100644 --- a/benchmark/run_benchmark.py +++ b/benchmark/run_benchmark.py @@ -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() diff --git a/clonehunter.example.toml b/clonehunter.example.toml index c5e3372..5614ac6 100644 --- a/clonehunter.example.toml +++ b/clonehunter.example.toml @@ -16,9 +16,6 @@ exclude_globs = [ "**/__pycache__/**", "**/site-packages/**", ] -cluster_findings = false -cluster_min_size = 2 - [thresholds] func = 0.92 win = 0.90 diff --git a/docs/02-pipeline.md b/docs/02-pipeline.md index d4670e4..3511443 100644 --- a/docs/02-pipeline.md +++ b/docs/02-pipeline.md @@ -43,7 +43,6 @@ flowchart TD subgraph S5 ["Stage 5 · Similarity"] BLD["build vector index"] --> RET["retrieve_candidates
(top-k neighbours + gates)"] RET --> ROL["rollup_findings
(group by function pair)"] - ROL --> CLU["cluster (optional)"] end S5 --> S6 @@ -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). diff --git a/docs/03-detection.md b/docs/03-detection.md index 39324ea..8b4ac9d 100644 --- a/docs/03-detection.md +++ b/docs/03-detection.md @@ -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 diff --git a/docs/05-architecture.md b/docs/05-architecture.md index 2b2082e..b6c2a5f 100644 --- a/docs/05-architecture.md +++ b/docs/05-architecture.md @@ -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. | @@ -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`. diff --git a/docs/06-config-cli-and-reports.md b/docs/06-config-cli-and-reports.md index 24b3b48..aed50a0 100644 --- a/docs/06-config-cli-and-reports.md +++ b/docs/06-config-cli-and-reports.md @@ -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. @@ -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 diff --git a/docs/README.md b/docs/README.md index ecb42a8..0f5cd91 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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. | diff --git a/examples/clonehunter_diff.json b/examples/clonehunter_diff.json index b354a58..9f533a9 100644 --- a/examples/clonehunter_diff.json +++ b/examples/clonehunter_diff.json @@ -1,112 +1,68 @@ { - "schema_version": "1.0.0", - "findings": [ - { - "function_a": { - "file": { - "path": "src/clonehunter/reporting/html_reporter.py", - "content_hash": "754e80b29620599f356ec9e533e15d0996a57e614b4388620e6c53440d1a2164", - "language": "python" - }, - "qualified_name": "_select_compare", - "start_line": 147, - "end_line": 151, - "code_hash": "68939e2cef98849b38b2c0b80ca6f3025b6d0ef3e550c53afcdfc48b83a6b814" - }, - "function_b": { - "file": { - "path": "src/clonehunter/reporting/json_reporter.py", - "content_hash": "a44cae11cce29b3d0ed30b95b0f81ca886d358a25bee7934bc57c88179f28fd9", - "language": "python" - }, - "qualified_name": "_select_compare", - "start_line": 65, - "end_line": 69, - "code_hash": "249bc300b134cf58bfef1cde6dc6a8113a19283b8f7a59fda64ba7daa380ab25" - }, - "score": 0.9619782452779875, - "duplicated_lines": 5, - "compare": { - "kind_a": "FUNC", - "kind_b": "FUNC", - "span_a": { - "start_line": 147, - "end_line": 151 - }, - "span_b": { - "start_line": 65, - "end_line": 69 - }, - "similarity": 0.9619782452779875, - "diff": "--- \n+++ \n@@ -2,4 +2,4 @@\n compare = select_compare(matches)\n if compare is None:\n return None\n- return _compare_payload(compare, matches)\n+ return _serialize_evidence(compare)" - }, - "reasons": [ - "func_threshold" - ], - "metadata": {} + "config": { + "cache": { + "path": "~/.cache/clonehunter" + }, + "embedder": { + "batch_size": 16, + "device": "auto", + "max_length": 256, + "model_name": "microsoft/codebert-base", + "name": "stub", + "revision": "3b0952feddeffad0063f274080e3c23d75e7eb39" + }, + "engine": "semantic", + "exclude_globs": [ + "**/.venv/**", + "**/venv/**", + "**/__pycache__/**", + "**/site-packages/**" + ], + "expansion": { + "depth": 1, + "enabled": false, + "max_chars": 4000 + }, + "include_globs": [ + "**/*.py" + ], + "index": { + "name": "brute", + "top_k": 25 }, - { - "function_a": { - "file": { - "path": "src/clonehunter/reporting/html_reporter.py", - "content_hash": "754e80b29620599f356ec9e533e15d0996a57e614b4388620e6c53440d1a2164", - "language": "python" - }, - "qualified_name": "_merge_spans", - "start_line": 339, - "end_line": 350, - "code_hash": "c8fd6f3a37b2b76735230cb0a0eeecb239c167f9ef889da1c9eff45c723de046" - }, - "function_b": { - "file": { - "path": "src/clonehunter/similarity/rollup.py", - "content_hash": "4b04e4587a6712aeb0633b9bf81137f32332e06fee97b34fd4e1c0d36888c130", - "language": "python" - }, - "qualified_name": "_covered_lines", - "start_line": 152, - "end_line": 162, - "code_hash": "12123916b1586177c8d79c2c8d684616d838a3dced27eb25a411615022364c4c" - }, - "score": 0.9211043177265966, - "duplicated_lines": 11, - "compare": { - "kind_a": "FUNC", - "kind_b": "FUNC", - "span_a": { - "start_line": 339, - "end_line": 350 - }, - "span_b": { - "start_line": 152, - "end_line": 162 - }, - "similarity": 0.9211043177265966, - "diff": "--- \n+++ \n@@ -1,12 +1,11 @@\n-def _merge_spans(spans: list[tuple[int, int]]) -> list[tuple[int, int]]:\n+def _covered_lines(spans: list[tuple[int, int]]) -> int:\n if not spans:\n- return []\n- merged: list[tuple[int, int]] = []\n+ return 0\n+ merged: list[list[int]] = []\n for start, end in sorted(spans):\n if not merged or start > merged[-1][1] + 1:\n- merged.append((start, end))\n+ merged.append([start, end])\n continue\n- prev_start, prev_end = merged[-1]\n- if end > prev_end:\n- merged[-1] = (prev_start, end)\n- return merged\n+ if end > merged[-1][1]:\n+ merged[-1][1] = end\n+ return sum((end - start + 1 for start, end in merged))" - }, - "reasons": [ - "func_threshold" - ], - "metadata": {} + "thresholds": { + "exp": 0.9, + "func": 0.92, + "lexical_min_ratio": 0.5, + "lexical_weight": 0.3, + "min_window_hits": 1, + "win": 0.9 + }, + "windows": { + "min_nonempty": 4, + "stride_lines": 6, + "window_lines": 40 } - ], - "stats": { - "file_count": 83, - "function_count": 256, - "snippet_count": 886, - "candidate_count": 1268, - "finding_count": 2, - "cache_hits": 886, - "cache_misses": 0 }, - "config": { - "engine": "semantic" + "degradations": [], + "groups": [], + "schema_version": "2.0.0", + "stats": { + "cache_hits": 251, + "cache_misses": 263, + "candidate_count": 144, + "file_count": 21, + "finding_count": 0, + "function_count": 103, + "group_count": 0, + "grouped_function_count": 0, + "snippet_count": 514 }, "timing": { - "collect_files": 0.039527708999230526, - "extract_functions": 0.03558491700096056, - "generate_snippets": 0.10963258300034795, - "embed": 2.590705499998876, - "similarity": 6.573691042001883 + "collect_files": 0.160465625, + "embed": 0.095335208, + "extract_functions": 0.014595708, + "generate_snippets": 0.210038041, + "similarity": 0.031721709 } } \ No newline at end of file diff --git a/examples/clonehunter_diff_report.html b/examples/clonehunter_diff_report.html index 9dbd02d..ea95c9b 100644 --- a/examples/clonehunter_diff_report.html +++ b/examples/clonehunter_diff_report.html @@ -1,4 +1,3 @@ - @@ -35,12 +34,20 @@ .diff_add { background: #e6ffed; } .diff_chg { background: #fff5b1; } .diff_sub { background: #ffeef0; } + .degradations { background: #fff5b1; border: 1px solid #e0c000; border-radius: 6px; + padding: 8px 12px; margin-bottom: 12px; } + .degradations ul { margin: 4px 0 0; padding-left: 20px; } + .group-locations { margin: 8px 0; padding-left: 20px; color: #444; font-size: 0.9em; + word-break: break-all; } + .family-stack { display: flex; flex-direction: column; gap: 12px; margin-top: 12px; } + .pair-note { margin: 0 0 12px; }

CloneHunter Report

-

Schema: 1.0.0

-

Findings: 2

+

Schema: 2.0.0

+

No clones found.

+
- -
- -
-
-
_select_compare
-
- src/clonehunter/reporting/html_reporter.py:147-151 -
-
-
-
_select_compare
-
- src/clonehunter/reporting/json_reporter.py:65-69 -
-
-
0.962
-
5 duplicated lines
-
-
-
Function AFunction B
147def _select_compare(matches: list[CandidateMatch]) -> dict[str, object] | None:65def _select_compare(matches: list[CandidateMatch]) -> dict[str, object] | None:
148 compare = select_compare(matches)66 compare = select_compare(matches)
149 if compare is None:67 if compare is None:
150 return None68 return None
151 return _compare_payload(compare, matches)69 return _serialize_evidence(compare)
-
- - -
- -
-
-
_merge_spans
-
- src/clonehunter/reporting/html_reporter.py:339-350 -
-
-
-
_covered_lines
-
- src/clonehunter/similarity/rollup.py:152-162 -
-
-
0.921
-
11 duplicated lines
-
-
-
Function AFunction B
339def _merge_spans(spans: list[tuple[int, int]]) -> list[tuple[int, int]]:152def _covered_lines(spans: list[tuple[int, int]]) -> int:
340 if not spans:153 if not spans:
341 return []154 return 0
342 merged: list[tuple[int, int]] = []155 merged: list[list[int]] = []
343 for start, end in sorted(spans):156 for start, end in sorted(spans):
344 if not merged or start > merged[-1][1] + 1:157 if not merged or start > merged[-1][1] + 1:
345 merged.append((start, end))158 merged.append([start, end])
346 continue159 continue
347 prev_start, prev_end = merged[-1]160 if end > merged[-1][1]:
348 if end > prev_end:161 merged[-1][1] = end
349 merged[-1] = (prev_start, end)162 return sum((end - start + 1 for start, end in merged))
350 return merged
-