Skip to content

Diff-aware detection and clone baseline: gate CI on new duplication only #944

Description

@kucherenko

Summary

Umbrella issue for a family of long-standing requests that all reduce to the same capability: distinguish new duplication from pre-existing duplication, and gate CI on the new part only. Consolidates #879, #938, #588, #468, and the earlier #254 into one coherent design for the Rust v5 engine.

Design revision (2026-08-19): an earlier draft proposed two parallel mechanisms — a stateless git-diff mode and a stateful baseline mode. This revision reduces the design to one mechanism: the clone-fingerprint baseline. Diff-vs-ref checking is expressible as an ephemeral baseline built from the base ref, so it becomes a thin convenience flag in a later phase rather than a parallel implementation.

The requests being consolidated

Issue Request Core need
#879 Detect and fail on clones introduced by a Git diff PR gate that ignores legacy duplication
#938 ThresholdReporter tolerates a full clone-instance swap under the aggregate percentage Baseline of clone fingerprints; fail when the clone set gains members, not only when the percentage rises
#588 Check one specific file (or the files being modified) against the whole repo Scan the full source set but report only clones touching a focus set of files
#254 (closed) Compare commit-diff files against all sources Same as #879; the git diff --name-only | xargs jscpd workaround misses clones between changed and unchanged files
#468 thresholdAbsolute — gate on clone count, immune to "add filler code to dilute the percentage" A gate that is not skewed by adding new clean code — satisfied by count-based gating on new clones

All five fail the same way today: detection and gating operate on the whole corpus at once, with a single aggregate percentage as the only exit signal. There is no notion of which clones are new relative to a recorded baseline.

Design: one mechanism, the clone-fingerprint baseline

A clone is new or known, decided by membership in a set of fingerprints of accepted clones. The fingerprint set normally lives in a committed .jscpd-baseline.json; a later phase can build it ephemerally from a git ref instead. Both feed the same gating and reporting pipeline.

Phase 1 — committed baseline (closes #938, #468, and the CI-gate core of #879/#254)

cpd --baseline .jscpd-baseline.json --fail-on-new-clones .   # gate against recorded state
cpd --baseline .jscpd-baseline.json --update-baseline .      # absorb current state

The PR-gate workflow needs no git logic at all: main carries the baseline, a PR run fails on any fingerprint not in it. Because fingerprints are content-hash based (not line numbers), unrelated edits in the same files don't fire; editing inside an existing clone changes its fingerprint and flags it as new — exactly the semantics the earlier diff-mode draft approximated with line-range overlap, without the overlap-definition ambiguity. Whole classes of diff-mode edge cases disappear: no shallow-checkout problem (the baseline is a file in the checkout), no fetch-depth: 0 docs, no git rename detection, no "which mode wins when both are active".

Fingerprint design question (settle early)

The earlier draft proposed fingerprint = content hash of the duplicated fragments + file-pair paths. Path-bearing fingerprints make a pure rename look like all-new clones (the diff-mode draft compensated with git rename detection, now dropped). Preferred alternative: content-hash-only fingerprints with a multiplicity count — a rename is invisible (content and count unchanged), while a genuinely new occurrence still bumps the count and fires. The gate doesn't need "which file pair", and the report still shows paths from the current scan. Decide and document before freezing the format.

Phase 2 (optional, later) — ephemeral baseline from a git ref (stateless variant of #879/#254)

cpd --baseline-from-ref origin/main --fail-on-new-clones .

Sugar over phase 1: read the base ref's tree in-process (gitoxide, already a dependency for blame — no shelling out, no worktree), scan it in memory to build a transient fingerprint set, then compare exactly as phase 1 does. Same fingerprint engine, same gating, same reporting; ~no new concepts.

Known cost, accepted: this scans the corpus twice (base tree + HEAD) instead of once plus a cheap git diff. It only affects users who choose not to commit a baseline; the Rust engine is fast enough for this to be acceptable. The shallow-checkout caveat returns for this flag only (the base ref's blobs must be present) — fail with a clear message and document the fix.

Decoupled — --focus (#588)

--focus <path,...> is orthogonal to newness: it's a report filter (full-corpus scan, report only clones touching the given files), not a newness criterion. It survives as its own small flag and can land independently of, before, or after the baseline work — or be split into its own issue.

Shared reporting surface

Baseline comparison populates fields that already exist in the data model and are currently unused in the Rust engine (StatRow.new_clones, StatRow.new_duplicated_lines; per-clone isNew in the v4 TypeScript schema):

  • console / console-full: new clones flagged (e.g. Clone found (javascript) [NEW]), summary line gains "N new".
  • json: per-clone isNew plus the existing newClones / newDuplicatedLines statistic fields — additive, schema-compatible.
  • sarif: new clones at level error, known ones stay warning, so GitHub code scanning highlights only the regression.
  • openmetrics (OpenMetrics reporter #422): jscpd_new_clones / jscpd_new_duplicated_lines gauges for GitLab MR metrics.
  • threshold / exit gate: --fail-on-new-clones composes with the existing --threshold.

Non-goals (tracked separately)

Acceptance criteria

Phase 1

  • Fingerprint engine: content-hash-based clone fingerprints with multiplicity (format question above settled and documented)
  • --baseline <file> marks clones absent from the baseline as new (full-corpus scan)
  • --update-baseline regenerates the baseline and prints added/removed counts
  • --fail-on-new-clones[=N] exits non-zero on new clones, independent of --threshold
  • Baseline file: versioned, sorted, stable, merge-friendly
  • isNew / newClones / newDuplicatedLines populated in json; new-clone markers in console, sarif (error level), openmetrics
  • Docs: CI recipes for GitHub Actions and GitLab CI (committed-baseline PR gate on new duplication only)

Phase 2 (optional)

  • --baseline-from-ref <git-ref> builds an ephemeral baseline from the base ref's tree (via a temporary git worktree — see implementation note below)
  • Clear error message for missing base refs in shallow checkouts, with documented fix

Decoupled

  • --focus <paths> reports only clones touching the given files (may be split into its own issue)

Closes / supersedes: #879, #938, #588, #468 (each can be closed against this once the corresponding checkbox lands).

Metadata

Metadata

Assignees

No one assigned

    Labels

    featureA new functionality request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions