The agent's favourite shortcut
Copying is the cheapest way for an agent to add behaviour. Find a
function that nearly does the job, paste it, change one value. Each
copy is correct on the day it lands. Six months later a bug is fixed
in one copy and not the other four.
jscpd finds repeated runs of
tokens across a codebase. The tickets repository runs it with a
threshold of 0%: any clone at all fails the build. AGENTS.md calls
the threshold "non-negotiable".
The real knob is minTokens
A 0% threshold sounds absolute, but what decides how hard jscpd looks
is minTokens: the shortest run of tokens that counts as a clone. The
tickets scans set it low: 19 tokens for production code.
The ignore flags: seeing through renames
jscpd 5 has three options that change what it compares.
#2866 turned
them on in the four strict scans:
ignoreIdentifiers treats every variable and function name as the
same token, so total and sum match.
ignoreLiterals does the same for strings and numbers, so "admin"
and "owner" match.
ignoreAnnotations does the same for type annotations.
With all three on, jscpd finds blocks that have the same shape even
when every name and value is different. That is the "paste and
rename" copy an agent makes. jscpd then sorts each match it finds
into a kind: an exact clone has identical raw tokens, and a
renamed clone differs only in names, values or types.
The strict scans add "kind": ["exact"], so they still fail only on
code that is token-for-token the same. The renamed matches go to the
renamed-clone gate, described below. The flags still
catch more, because jscpd splits a long renamed match at the tokens
that differ. If a renamed copy leaves an identical stretch of
minTokens or more, the strict gate reports that stretch and asks
for the merge.
{
"threshold": 0,
"ignoreIdentifiers": true,
"ignoreLiterals": true,
"ignoreAnnotations": true,
"kind": ["exact"],
"minLines": 1,
"minTokens": 19,
"path": ["src", "e2e-payments", "scripts"]
}
The four configs repeat this block by hand, so a small test,
test/scripts/jscpd/config.test.ts,
fails when one of them drifts. A check on a check sounds excessive,
but it costs ten lines and stops one config quietly losing a flag.
Eight scans, each tuned to its tree
One threshold does not fit a whole repository. Test setup repeats more
than production code, and CSS repeats short declaration runs by
design. So deno task cpd runs several scans, each with its own
config, and
docs/test-duplication.md
records what each step down would cost.
Scanning src together with the test helpers matters. It catches a
test helper that re-implements a production function instead of
importing it.
The renamed-clone gate
The strict scans report only exact clones, so a function that was
copied and renamed in full slips past them. The renamed scan runs
jscpd's --similarity 0.99 gate, which compares functions by the
shape of their syntax tree with names and values left out. A renamed
copy scores 1.0. Existing matches live in .jscpd.renamed-baseline.json as
fingerprints, and --fail-on-new-clones fails on any new one. This
replaced two hand-rolled scanners
(#2380), which
is its own lesson: when the tool grows the feature, delete your
version.
Duplication in proofs
The TLA+ models
are code too, and they get copy-pasted too.
scripts/tla-cpd.ts
lexes each .tla file into a form jscpd can read, keeping
identifiers, literals and prime marks, and dropping comments. Shared
rules move into verification/ModelRules.tla.
A flagged pair is one merge
The hardest part of duplication detection is not the tool, it is what
people (and agents) do when it fires. AGENTS.md is explicit:
- Write the helper, or give the shared tail its parameter. Two
functions that differ only in a value, a path, a field name, a
message, or a callback are one function without its parameter
yet.
- Never restructure code so the matcher stops matching while the
two parallel implementations stay standing.
- After a dedup, search for the other places that now fold into the
new helper.
The second bullet is the important one. An agent can always reorder
a few statements until jscpd stops matching. That passes the check
and keeps the problem. The rule names that move and forbids it.
The only sanctioned repeat is import blocks, wrapped in
jscpd:ignore-start / jscpd:ignore-end markers. Everything else is
a merge.