Skip to content

fix(checks): detect clones that differ only in literal values (CD-331) - #216

Merged
TAJD merged 1 commit into
mainfrom
fix/cd-331
Aug 11, 2026
Merged

fix(checks): detect clones that differ only in literal values (CD-331)#216
TAJD merged 1 commit into
mainfrom
fix/cd-331

Conversation

@TAJD

@TAJD TAJD commented Aug 11, 2026

Copy link
Copy Markdown
Owner

The bug, and the correction to its diagnosis

Refactor.DuplicateBlock missed structurally identical blocks whose literals differ — the three 23-line worker files in the report produced zero findings.

The ticket's diagnosis was wrong. It blamed unmodified identifiers. Identifiers were already canonicalised to window-relative positional indices; I verified this with a three-file fixture in which every identifier differs and the check fires correctly. Change only the string literals and it goes silent. Literals were the gap: HashOp::Str/HashOp::Num were hashed by value.

hash_ops now computes two hashes per window in a single pass — normalised (literals as positional placeholders, Str#/Num# prefixes distinct from the identifier ones) and exact. Behind normalize_literals, default on.

Two things real-repo validation caught

Validated against all five local repos that use cofferdam rather than fixtures alone.

Import blocks became mutual duplicates. Normalising module specifiers made every run of import statements hash equal — projektor went from 8 findings to 66, almost all on line 1. An import list cannot be extracted into a shared helper, so the finding is never actionable. Imports, export * from and export { x } from are now excluded from statement windows outright — not merely as window starts, so a window cannot straddle into real code. A plain export { x } or export function still participates.

The remainder would have turned CI red. After that fix the estate still rose several-fold, dominated by test setup blocks differing in fixture values. Real duplication, but --fail-on defaults to medium and this is a medium check. Emitting near-clones at a lower severity is not possible — the engine stamps severity per check id in a post-pass, so a check cannot vary it per issue. Hence a second id:

  • Refactor.DuplicateBlock — unchanged id, priority, medium severity and message; now reports verbatim clones only.
  • Refactor.NearDuplicateBlock — new, low, reports the literal-drift groups. Prints without failing a build; raise its severity in config to gate on it.

Both read one corpus slot (only DuplicateBlock::run writes) and share a single grouping-and-overlap-claim pass, so the two never report the same region twice.

Measured outcome

Repo DuplicateBlock (medium) NearDuplicateBlock (low) medium+ baseline
personal-blog 4 18 4 6
bestefforttools 8 16 8 10
poker-puzzle 0 3 0 0
projektor 9 42 9 8
start-line 1 10 1 1

The missed clone class is now caught and the gate-tripping count fell on four of five repos.

projektor's +1 is explained and accepted: apps/api/src/routes/task-statuses.ts:13 is a genuine duplicate whose author suppressed it with a comment anchored to a preceding import line. Excluding imports moved the primary span out from under that anchor. Detection was right both times; only the anchor moved. Documented as an upgrade note in the CHANGELOG and on the check's page — move the comment to the first statement of the block. The underlying fragility is filed as CD-337 rather than fixed here, since it is engine-level suppression machinery.

Full-run wall time on projektor is unchanged (0.78s vs 0.79s warm), so the two checks sharing the grouping pass costs nothing measurable.

Verification

cargo build --workspace, cargo test --workspace (1437 passed), cargo clippy --workspace --all-targets -D warnings, cargo fmt --check, gen-docs --check via pre-commit. Plus the original reproducer, which now reports under Refactor.NearDuplicateBlock at low.

🤖 Generated with Claude Code

https://claude.ai/code/session_019afugU2bTYCtXn6Vy9dn1v

`Refactor.DuplicateBlock` missed structurally identical blocks whose
literals differ. The ticket blamed unmodified identifiers; that was
wrong — identifiers were already canonicalised to window-relative
positional indices, verified with a fixture that renames every one and
still fires. Literals were hashed by value, so three worker files
differing in three strings produced nothing.

Literals are now normalised to positional placeholders too, behind
`normalize_literals` (default on). Two hashes are computed per window in
one pass so a group can be told apart as verbatim or merely near.

Validating against five real repos surfaced two consequences:

- Normalising module specifiers made every run of `import` statements
  match every other one (one repo went 8 -> 66 findings). Imports and
  re-exports are now excluded from statement windows outright.
- The remaining rise was dominated by test setup blocks differing in
  their fixture values. Since `--fail-on` defaults to medium, reporting
  those under the existing check would turn CI red for duplication
  nobody chose to gate on. Severity is stamped per check id, not per
  issue, so near-clones move to a new `Refactor.NearDuplicateBlock` at
  low severity. Medium-and-above counts now sit at or below baseline on
  four of five repos.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019afugU2bTYCtXn6Vy9dn1v
@github-actions

Copy link
Copy Markdown

Criterion benchmark comparison (PR head vs base branch)

�[1m�[92m   Compiling�[0m cofferdam-checks v0.4.2 (/home/runner/work/cofferdam/cofferdam/crates/cofferdam-checks)
�[1m�[92m   Compiling�[0m cofferdam-engine v0.4.2 (/home/runner/work/cofferdam/cofferdam/crates/cofferdam-engine)
�[1m�[92m    Finished�[0m `bench` profile [optimized] target(s) in 51.30s
�[1m�[92m     Running�[0m benches/engine_bench.rs (target/release/deps/engine_bench-e0ce420cbfc0fb1d)
Gnuplot not found, using plotters backend
Benchmarking full_run_no_cache
Benchmarking full_run_no_cache: Warming up for 3.0000 s
Benchmarking full_run_no_cache: Collecting 20 samples in estimated 7.1571 s (420 iterations)
Benchmarking full_run_no_cache: Analyzing
full_run_no_cache       time:   [16.363 ms 16.395 ms 16.428 ms]
                        change: [-11.196% -6.9662% -3.6656%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 2 outliers among 20 measurements (10.00%)
  2 (10.00%) high mild

Benchmarking single_file_edit_incremental
Benchmarking single_file_edit_incremental: Warming up for 3.0000 s

Warning: Unable to complete 20 samples in 5.0s. You may wish to increase target time to 9.7s, enable flat sampling, or reduce sample count to 10.
Benchmarking single_file_edit_incremental: Collecting 20 samples in estimated 9.7392 s (210 iterations)
Benchmarking single_file_edit_incremental: Analyzing
single_file_edit_incremental
                        time:   [6.5080 ms 6.5802 ms 6.6484 ms]
                        change: [-14.806% -13.722% -12.657%] (p = 0.00 < 0.05)
                        Performance has improved.


@TAJD
TAJD merged commit 3b567e5 into main Aug 11, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant