63 KiB
Changelog
All notable changes to cpd (Rust) are documented here. Releases follow Semantic Versioning.
5.3.0
New Features
-
--dashboardprints the whole picture of a project on one screen: size and largest formats, duplication with the clone count per kind and a breakdown by format, total and mean complexity with the most complex files, and, for JavaScript, TypeScript and Python, dead code by category with the largest findings, all under a health badge (below). It runs a clone scan and a dead-code scan side by side, so it costs about as long as the slower of the two rather than their sum, and--workersis a budget for the pair rather than for one scan.--summary-topsets the rows per list, and every detection option (--min-tokens,--kind,--ignore, …) and dead-code option (--entry,--dead-code-categories,--min-confidence) applies to its own section. Reporters:console,json(jscpd-dashboard.json),badge(jscpd-health-badge.svg), and nowmarkdown/html(jscpd-dashboard.md/.html). The exit gates of a clone run apply (--threshold,--exit-code,--fail-on-empty); the baseline family needs a clone report this mode does not write, so--baseline/--baseline-from-ref/--update-baselinewarn and are ignored and--fail-on-new-clonesis refused. Seefixtures/dashboard-demo. -
--healthscores a codebase from 0 to 100. Three shares of the code lines — duplication, dead code, complexity — are each mixed with a "typical project" prior and scored on a half-life curve, then combined into one weighted geometric mean and a letter grade. Duplication does not count a duplicated markup, stylesheet or template block (HTML, CSS, Handlebars, …), since a repeating style rule is not the maintenance problem repeating programming logic is; component/script languages (Vue, Svelte, Astro, GraphQL) still count in full, and the console line says what it measured and, only when the project actually has files in that category, what it left out (5.4% in typescript (no text)). A dimension that cannot be measured — no JavaScript/TypeScript/Python for dead code, no complex files — is left out and named (dead code n/a) rather than scored as if it were clean.--health-input FILE(config keyhealthInput) folds in metrics from other tools (coverage, security, …), and the same object can live under ahealthkey in.jscpd.jsonto tune the built-in dimensions' half-life and weight. Reporters:console(the badge),ai(one compact line),json,badge, and nowmarkdown/html. Seefixtures/dashboard-demo. -
--complexityanswers the complexity half of--summarywithout the clone run: files are walked and tokenized with the same filters, complexity is counted, and detection — the part that scales with the size of the codebase — never starts. Reporters:console,aiandjson(jscpd-complexity.json). It cannot be combined with--dead-code,--dashboardor--mcp, and warns rather than silently ignoring options it has nothing to apply to (--threshold,--exit-code, the baseline family,--history,--kind). -
--kindfilters clones by how they were found:exact,renamed,similar, or one of the two mechanisms behindsimilar—gap(--max-gap-lines) andast(--similarity). Statistics,--thresholdand every reporter see the filtered list. It never turns a detector on by itself:--kind astwithout--similaritywarns that no such clones can be found, and an unknown kind is an error rather than a typo that silently reports a clean scan. -
--summary --summary-by complexitynow reads like cyclomatic complexity — the estimate was one path per file plus every branch keyword it could see, which was neither what the name promised nor consistent between languages. Three things changed, all inside the summary; detection, tokenization and clone output are untouched.Short-circuit operators actually count now. Only the JavaScript tokenizer emits
&&as a single token; the generic one splits every punctuation run into characters, so for the other 200-odd formats the&&and||entries in the decision list had been unreachable — the most common branch operator in C, C++, C#, Java, Go, Rust, PHP, Swift and Kotlin contributed nothing. Adjacent punctuation is now joined before matching, which also stops??being counted twice.A branch is counted the way the language spells it. One global keyword list cannot be right for both Rust, where
matcharms are the branch and have no keyword at all, and Swift, where?marks an optional rather than a ternary. Per-language rules count Rust's match arms by their=>— every arm, less one permatch, since three arms are three paths and so two branches — and addguardfor Swift andselectfor Go, and treat?as a branch only where it opens a ternary — told apart fromString?andx?.yby whether it is written against the token before it.?:counts as Elvis in Kotlin and Groovy, and as an optional property in TypeScript, which has no Elvis operator.Complexity is one path per function, not per file. Where a language has a marker the scan can trust (
def,fn,func,function,fun, and=>for JavaScript arrow functions) the baseline is the number of functions; elsewhere it stays at one, as before. A docstring no longer adds branches either. The tokenizer closes strings at end of line, so a"""body used to arrive as ordinary words and a docstring saying "if the value is big" charged the file three branches; the quote characters on each line are now read back from the literals they arrive in, so"""Summary.opens a string, a lone"""closes it and"""One line."""leaves it as it was. This applies only to languages where"""really is a delimiter — Python, Kotlin, Scala, Groovy, Swift, Java, Julia, Elixir and Dart — because where a doubled quote is an escape (C# verbatim strings, VB.NET, SQL, Pascal) three quotes in a row are ordinary string content, and reading them as a delimiter swallowed the rest of the file.A word the file binds as a name is that file's name.
case,whenandcondare branch keywords in some languages and ordinary variables in others, and the tokenizer cannot help:classify_wordreturnsIdentifierfor every word, soifin Python is tagged exactly like a variable calledcase. What a file does reveal is what it assigns to, what it reads off an object and what it lists as a parameter or argument, and a word it writescase = 3,x.caseorf(case, when)for is no longer counted as a branch in that file.The C family declares a function with no keyword at all.
head(args) {is the only marker C, C++, Java, Objective-C and C# give, and it has to be told from the) {that closes anif, aswitchor afor. Tracking what preceded each open paren does that, so those languages now count functions like the rest instead of falling back to one path per file.Measured against lizard on ten GitHub-trending projects across nine languages, agreement on file ranking rose from 0.83 to 0.92 (median Spearman) and the median ratio to lizard's own figure went from 0.75 to 1.00, with every one of the ten projects improving. C++ moved from 0.71 to 0.94 and C# from 0.82 to 0.99, both ratios landing on 1.00. Where the two still differ they differ on purpose: lizard counts a
matchonce however many arms it has, and does not count??.fixtures/summary-demohas one file per language whose complexity can be counted by hand. -
--dead-code: find code nothing runs — a second question about the same tree, answered by a new engine (basta) that ships inside the jscpd binary and also stands alone as abastacommand. It builds the import graph from the project's entry points and walks it, reporting unused files, unused exports, unused module-private declarations, unused imports and (opt-in) unused class members, across JavaScript, TypeScript, JSX, TSX, Vue, Svelte, Astro and Python.Entry points come from
package.json(main,module,bin,exports,scripts),pyproject.toml([project.scripts]and entry-point tables) and conventions (src/index.ts,__main__.py, framework routes,*.config.ts,.d.ts, shebangs,if __name__ == "__main__", every__init__.py); a manifest naming a built file (./dist/index.js) is mapped back to the source it was built from.--entry <glob>adds more.Because it is a graph traversal and not a reference count, dead code cascades: a helper whose only caller is itself dead is reported too. Every finding carries a confidence score from 0 to 100 and, below 100, the reasons it might be wrong — a file that calls
evalorgetattr, an unrecognised decorator, a wildcard re-export, a name that appears in a string literal, a file in the scan that did not parse.--min-confidencesets the floor (default 60).CommonJS is read alongside ESM —
require('./x'),const { a } = require('./x'),module.exports = { a, b },exports.a = …and a literalimport('./x')are all edges — so a Node project that never touchedimportdoes not read as a pile of unreachable files. A project that renames its own import paths is read on its own terms:compilerOptions.pathsandbaseUrlfromtsconfig.jsonorjsconfig.jsonare resolved, including through a relativeextendschain and per-package in a monorepo, so the@/components/xalias that ships in the default Next.js template reaches the file it names instead of looking like a missing dependency. A file named only by a path string — a worker spawned throughnew URL('./w.ts', import.meta.url), a build entry, a setup file listed invitest.config.ts— counts as used. On the Python side a quoted type annotation is read as the forward reference it is, so theif TYPE_CHECKING:import that supplies it is not reported as unused, and asrc/layout resolvesimport mypkg.thingfrom scripts that belong to no package. Entry points also include source files underpackage.json'sfilesand any file a shell script, CI workflow, Makefile or Dockerfile in the tree names by path. Files that fail to parse are listed in the console trailer and understatistics.unparsedFilesin the JSON report.Single-file components are read whole. A
.vue,.svelteor.astrofile is a JavaScript or TypeScript module wrapped in markup, and the markup is where a component's imports are used:<Foo />is the only thing that usesimport Foo from './Foo.vue'. Both halves are analyzed — the script is masked in place rather than extracted, so every reported position is a position in the real file, and the markup is scanned for the names it reads, including Vue's kebab-case component spellings,{{ … }}interpolations, Svelte actions and transitions, props, and expressions inside attributes. Without that a component project would not merely lose findings; every component import in it would be reported as dead, and every module only a component imports would be reported as an unused file.pages/,layouts/,src/routes/,app.vueanderror.vueare rooted as file-system routes. A framework that loads whole directories is read from its own config rather than guessed at from directory names, which would silence real findings in the projects that do write the import: anuxt.config.*beside the tree rootscomponents/,composables/,utils/,middleware/,plugins/,modules/andserver/, in Nuxt 3's layout, Nuxt 4'sapp/layout and whateversrcDirnames. When no config declares them,~/x,@/xand~~/xfall back to the project root — what they mean in Nuxt, whose own alias table is generated into.nuxt/and never scanned; a project that points@atsrc/says so in itsvite.config.*ortsconfig.json, and that declaration wins.Import paths are read the way the project's own build reads them, because on real projects that is where most of the answer lives.
resolve.aliasfromvite.config.*andkit.aliasfromsvelte.config.*jointsconfig.json'spaths— a JS project that never adopted TypeScript keeps its alias table only in the bundler config, and SvelteKit's$libis declared in a.svelte-kit/tsconfig.jsonthatsvelte-kit syncgenerates and no repository commits, so the convention itself is read instead. A computed specifier is read as the glob it stands for —import(`./services/${type}.vue`)reachesservices/*.vue,*staying within one directory as it does for the bundler — andimport.meta.glob('./lang/**/*.ts')with it, every pattern of an array form included; both used to produce nothing at all, so a router or an i18n table loaded that way reported every file it reached as dead. Animport()written in a component's markup expression ({#await import('./Heavy.svelte')}) is an edge too, while the same words in a comment or in plain attribute text are not, and Astro's client<script>blocks are read as the separate modules Astro bundles them into. A quoted attribute value is read as an expression only on a directive (:prop,@click,v-if), soclass="card"credits nothing. A path literal in a build config resolves against the project root as well as against the file holding it, which is whatcontextmeans to a bundler. Nitro'sapi/,routes/andmiddleware/are file-system routes like Nuxt's directories.Inside a monorepo a package is imported by its name —
@acme/ui,@acme/ui/date— from anywhere in the tree, and its ownpackage.jsonis the only thing that says which directory that name means. Those names now resolve:exportsis read subpath by subpath, preferring the source conditions over the built ones because./dist/index.mjsis a file the repository does not contain, andmain/modulefall back through the same build-output mapping as a published entry. The name reaches as far as the workspace does — found by walking up to apnpm-workspace.yaml, alerna.json, aturbo.jsonor a root manifest withworkspaces— rather than as far as the package directory, since an app importing a package is never underneath it.Measured on thirty Vue, Svelte and Astro projects from GitHub trending against the previous release, reported unused files fall from 1093 to 481, and the share of them that some import in the tree names drops from 83% to 62%. A bundler query on an import (
./script.js?raw) is stripped before resolving, since it says how a file is loaded, not which. Components imported only from.mdxcontent are still reported: basta does not read MDX, and--entryis the way to say they are used.It ships on npm as
bastaand on crates.io asbasta, with its own version line (0.1.0), its ownbasta-<platform>prebuilt packages and its own release trigger — abasta-v*tag, not the push to master that releases jscpd — so a jscpd release never drags it along and it never holds one up. Inside the jscpd binary the same engine answers tojscpd --dead-code.Languages plug in through one trait (
basta::lang::Analyzer) that owns parsing, specifier resolution, entry-point conventions, manifests and path traits; nothing outsidelang/is language-specific, anddocs/basta-extending.mdwalks through adding one.The mode reuses everything a jscpd user already knows: the same walker and filters (
--ignore,--format,.gitignore,--max-size,--follow-symlinks), the same fifteen reporter names, the same--thresholdand--exit-codegates.--dead-code-categoriesnarrows what is reported;--include-testsand--include-entry-exportswiden it. Seefixtures/dead-code-demoand the docs.
Fixes
- A single wrong-typed field in
.jscpd.json("entry": "src/index.js"where an array was expected) discarded the whole config instead of just that field. Each top-level key is now checked in isolation, and only the ones that actually fail to parse are stripped and reported, so the rest of the config still applies. - A hostile file path, custom format name or external health-metric id could break a Markdown table, inject raw HTML into a rendered dashboard, or forge extra table rows in the console output, since none of
--dashboard,--healthor theirmarkdown/htmlreporters sanitized untrusted strings before interpolating them. Every such value is now escaped for its destination. --dashboard/--healthrefused the whole report when--formatexcluded every language the dead-code engine analyzes, instead of dropping just that section the way a project with none of those files already does; a bad--dead-code-categoriesor--min-confidencecould also skip validation entirely when combined with such a format. Both are fixed: an unsupported format drops the dead-code section, and its options are always validated first.--complexity --fail-on-emptyon an empty scan skipped writing reports (e.g.-r json) instead of writing them and then failing the process, unlike every other mode's--fail-on-empty.--min-confidenceabove 100 was accepted by--dashboard/--health(which build basta's config directly) even though the standalonebasta/--dead-codeCLI already clamped it with a warning; the clamp now applies everywhere the option is read.- A health-score regression: markup-format duplication (HTML, CSS, templates, …) was weighted down for scoring but still counted in full toward the denominator, so excluding it barely moved the score on a real project. It is now a hard exclusion on both sides, and a project whose code is entirely markup skips the duplication dimension rather than scoring it from the size prior alone.
- Windows report paths used backslashes (
src\route.js) where every other output already normalized to forward slashes, breaking any comparison against a fixture or a previous run. bastabumped itsoxc_*parser crates to 0.150 (from 0.147).
5.2.1
New Features
--history: duplication trend over git history —jscpd src --history v5.0.0..HEADscans every commit in the range in a detached worktree and prints a bar chart, a per-commit table with the change between points, the overall trend and how far--thresholdcould be tightened without failing the build.--history-since,--history-every Nand--history-limit Nnarrow the range; the JSON reporter carries the points under ahistorykey and the GitHub Action takes ahistoryinput. (#1002, #1050, #1052)- Exit codes you can gate on, and
--fail-on-empty— an unknown--format, a scan path that does not exist and a reporter that cannot write its file now print an error and exit 1 instead of passing with an empty report.--fail-on-empty(config keyfailOnEmpty, action inputfail-on-empty) turns "analyzed no files" into a failure, so a mistyped path or an over-broad ignore cannot look like a clean run. (#1047, #1049) - PyPI:
pip install jscpd— the release now publishes eight platform wheels built from the same prebuilt binaries as the npm and GitHub Release artifacts, sopip install jscpdanduvx jscpdget the Rust engine with no Python code and no Node.js runtime involved. The repository-hosted pre-commit hook installs from PyPI instead of npm, which removes Node.js from the pre-commit path. (#1037, #1039)
Bug Fixes
- An open clone could be stretched past the file it started in — while growing a clone the detector accepted a continuation from any stored occurrence of the next window, so a third file that shared the same text but continued differently could extend a fragment beyond what its own file contains. The clone was then dropped or reported with mismatched ends (
fixtures/haxereportedfile1.hx [1:1 - 62:76]againstfile2.hx [1:1 - 62:2]). The match now asks first whether the clone's own anchor continues, and starts a new clone when it does not, so N-way copies no longer lose pairs. (#1033, #1034) - The XML report could be rejected by every parser — a clone containing a byte XML 1.0 cannot represent (an ANSI escape, a form feed) was written verbatim, and
xmllintrefused the file withPCDATA invalid Char value 27;]]>inside a fragment closed the CDATA section early, and attribute values were escaped twice. Such characters are now replaced with U+FFFD,]]>is split across two CDATA sections, and paths are escaped once. (#375, #1055) --follow-symlinksrenamed and double-counted linked files — a file reached through a symlink was reported by its resolved real path, which could be an absolute path outside the scan root, so the report and--ignoredisagreed about its name; a file reachable through two paths counted as two sources, and a file symlink next to its target was reported as a clone of itself. Files now keep the path they were found at, and each real file is scanned once. (#1059, #1060)
Other
- Symlinks are skipped by default in v5 — v4 followed them unless
--noSymlinkswas set; v5 needs--follow-symlinks(config keyfollowSymlinks, and a v4noSymlinks: falsestill maps to following). This was true in every 5.x release but undocumented, and it silently drops a corpus mounted through a symlink. Now in the README and the migration table. (#1059) CITATION.cffand a Citation section — GitHub's "Cite this repository" button and a BibTeX entry for the papers that use jscpd as their detector. The version and release date are kept in step bysync-version.mjs. (#1051)- Docs: jscpd is language-aware — the README and the Rust docs now say that detection runs on language tokens, per-format comment and string syntax with the oxc parser for JavaScript/TypeScript, rather than on raw text. (#1048)
- Agent skills know about clone kinds, the summary and their noise — the bundled
jscpdanddry-refactoringskills (npx skills add kucherenko/jscpd) document--summary, the Type-2 and Type-3 flags with their kind suffixes, and warn that normalized passes surface look-alike code, with conservative defaults and a triage step before refactoring. (#1056, #1057) console-fullprints the--historyblock likeconsoledoes, and the test scaffolding behind the CLI, MCP, reporter and finder suites was deduplicated. (#1053)- CI: the npm platform-package gate polls against a 5-minute deadline instead of a fixed sleep, so a slow registry no longer fails a release that would have succeeded. (#1032)
Dependencies
- Bump
askamafrom 0.16.0 to 0.16.1 in/rust(#1045) - Bump
taiki-e/install-actionfrom 2.87.3 to 2.87.8 in/.github/workflows(#1046)
Thank You ❤️
- @mnahkies for correcting the ignore examples in the README —
--ignore-patternhas no short flag and a barenode_modulesdoes not match, since globs are matched against the whole path (#1038)
5.2.0
New Features
- Type-2 clone detection:
--ignore-identifiers,--ignore-literals,--ignore-annotations— three opt-in flags (config keysignoreIdentifiers,ignoreLiterals,ignoreAnnotations, GitHub Action inputs of the same names) normalize token classes before hashing, so blocks that differ only in names, literal values or annotations are found. Identifiers hash as one class while keywords keep their value, strings and numbers stay distinct classes, and@Name(...)runs are dropped in Java, Kotlin, Scala, Groovy, Python, Dart, Swift, JavaScript and TypeScript (@interfacedeclarations are kept). Every clone now carries akind:exactorrenamed. A run without the flags is unchanged apart from the additive"kind": "exact"JSON field. Seefixtures/type2-demo. (#998, #1019) - Near-miss clone merging with
--max-gap-lines N— a copy with a line inserted, removed or changed in the middle used to show up as two shorter clones. With--max-gap-lines N(configmaxGapLines, Action inputmax-gap-lines, default0= off) clones of one file pair whose fragments follow each other in both files with at mostNunmatched lines between them are merged into one clone of kindsimilarwith asimilarityvalue (matched tokens over the merged span). A merge whose similarity would fall below0.5is refused, duplicated-line statistics count only the matched lines, and a merge of renamed halves is reported assimilar. Seefixtures/type3-demo. (#999, #1020, #1030) - Function-level similarity for JavaScript and TypeScript with
--similarity RATIO— edits spread through a function rather than concentrated in one gap still escape a token window.--similarity(configsimilarity, Action inputsimilarity, a number in(0, 1]; the default1means exact matches only, so nothing runs until you lower it) compares every function, method and arrow function by the bag of 4-grams over its syntax-tree node types, indexed with MinHash, and reports pairs at or above the ratio assimilarclones spanning the whole functions. Names and literals do not take part: a renamed copy scores1.0, one inserted line about0.9, two inserted statements plus renames about0.75. Everysimilarclone records itsmethod(gaporast) because the two scores are not on the same scale. The MCPcheck_duplicationtool accepts the samesimilarityargument. (#999, #727, #1021) - Clone kinds in every reporter — console prints
Clone found (javascript, renamed)andClone found (javascript, similar (gap) ~0.91),aiappends(renamed)/[~0.91 gap], JSON addskind,similarityandmethodto each duplicate andrenamedClones/similarClonesto the statistics, XML adds the same attributes, HTML shows a badge, Xcode a suffix, and SARIF and Code Climate use the rulesjscpd/renamed-codeandjscpd/similar-codenext tojscpd/duplicate-code. (#1019, #1021, #1030) - Tips are skipped when stdout is not a terminal — the tips and sponsor lines are printed only on an interactive terminal; a pipe, a file, a CI log or an agent hook no longer receives them.
JSCPD_NO_TIPSjoinsCIas an environment switch and--no-tipsstays the explicit one;NO_COLORonly removes the colours. (#1008, #1029, thanks @7487) - MCP: fully described tool definitions — the four tools now carry a title, read-only annotations, parameter descriptions with examples and defaults, and descriptions that say when to use each tool and what it returns; the server instructions describe the workflow across them. Tool names and schemas are unchanged. (#1028)
Bug Fixes
- Config-file
ignorePatternentries without*or?silently did nothing — such entries were treated as relative paths and joined onto the config directory, so"ignorePattern": ["Copyright 2026 Example Authors"]matched nothing while the same string via--ignore-patternworked. Config entries are now applied verbatim, and an invalid regex prints aWarning:line instead of being dropped silently. Seefixtures/ignore-demo. (#997) - JavaScript/TypeScript files with a recoverable parse error could not match clean files — any parser diagnostic sent the file to the word-split fallback tokenizer, so a file containing, say, a redeclared function was tokenized differently from every well-formed file and never paired with one. Tokens now come from the lexer whenever the parser did not fail outright. Clone counts on codebases with such files change; that is the correction. (#1023, #1024)
- Markdown inherited the C comment style — a
/*(a glob likedocs/**) or//(any URL) in prose opened a comment that swallowed the rest of the file, so two files sharing a paragraph after such a line were never reported. Markdown now has no comment syntax. (#1026, thanks @kwesolowski) - Vue template clones were reported with wrong ranges — the wrapper tags of the file and the template body were appended to the html token stream out of source order, so a clone across the seam took its endpoints from opposite ends of the file. The stream is now in source order, and the wrapper tags (
<template>,<script>,<style>and their closing tags) are left out of it altogether, so a template clone is reported with the template's own line range and the script and style bodies are not counted as duplicated html. Seefixtures/sfc-demo. (#1031, thanks @zero-stroke)
Other
- Runnable demos under
fixtures/— every feature and fix above ships a demo directory (ignore-demo,type2-demo,type3-demo,parse-errors-demo,sfc-demo) whose README lists each command with its expected output, and the same files feed the smoke scan that runs on every pull request. - Docs: ignore patterns and inline markers —
--ignore-pattern/ignorePatternsource-region filtering and thejscpd:ignore-start/jscpd:ignore-endmarkers are documented in the v5 reference, with license-header recipes and a note on the Rust regex syntax. (#993, #996, thanks @w3lld1) - GitHub Action inputs
ignore-identifiers,ignore-literals,ignore-annotations,max-gap-linesandsimilarityfor the features above.
Dependencies
- Add
regex1 to thejscpdcrate for--ignore-patternvalidation (#997) - Bump
taiki-e/install-actionfrom 2.87.2 to 2.87.3 in/.github/workflows(#995)
Thank You ❤️
- @7487 for skipping the tips on a non-terminal stdout (#1029)
- @zero-stroke for the Vue template clone ranges (#1031)
- @kwesolowski for the Markdown comment-style fix (#1026)
- @w3lld1 for documenting ignore patterns and inline markers (#996)
5.1.2
New Features
- Linux ARM64 musl prebuilt binaries — npm installs on Alpine and other musl-based ARM64 Linux systems now select a native binary from the new
jscpd-linux-arm64-muslplatform package, bringing the prebuilt platform count to 8. The GitHub release ships the matchingjscpd-linux-arm64-musl.tar.gzasset. (#988) cargo binstall jscpd— the crate now carriescargo-binstallmetadata pointing at the release tarballs for every supported target, socargo binstall jscpddownloads a prebuilt binary instead of compiling theoxcparser stack from source. (#988)- Docker image
ghcr.io/kucherenko/jscpd— a multi-arch (amd64/arm64) distroless image built from the release binaries is published with every release, taggedlatest,5,5.1and the exact version, with SLSA provenance and an SBOM attached. Run it asdocker run --rm -v "$PWD:/src" ghcr.io/kucherenko/jscpd; see docs/ci-and-hooks.md. (#988)
Bug Fixes
jscpd --versionandjscpd --helpnow sayjscpd— both binaries are built from the same source and the command name was the literalcpd, sojscpd --versionprintedcpd 5.1.1and the usage line readUsage: cpd. The name is now taken from the invoked executable (jscpdorcpd). (#988)- Windows: drive-anchored
--patternvalues are treated as absolute — the Windows-only check for patterns likeC:\src\**\*.tscompared the first character against:and\after already requiring it to be a letter, so it could never match and such patterns were also given the relative**/variant. The check is now a platform-independent helper with a unit test that runs everywhere. (#988) - pre-commit hook passed v4-only flags —
.pre-commit-hooks.yamlstill invoked--gitignore --exitCode '1', which the v5 CLI rejects, sorepo: https://github.com/kucherenko/jscpdhooks failed on every run. The hook now passes--exit-code 1. (#989) - Unsupported-platform error is actionable — when no prebuilt binary matches, the
jscpdandcpdnpm launchers now name the host (os/arch (libc)), list the supported platform keys and point tocargo install jscpdinstead of printing a bare "Unsupported platform". (#988)
Other
- Repository split:
masteris v5-only — the TypeScript v4 engine (apps/,packages/, changesets, Node.js CI) moved to the long-livedmaster-v4branch and releases from there under thelatest-4npm dist-tag.masterkeeps the Rust workspace, the sharedfixtures/corpus, the GitHub Action, Dockerfile and flake.README-v4.mddescribes the TypeScript version in one page;FORMATS.mdis now generated from the Rust tokenizer (224 formats). (#989, #990) - Floating
v5tag for the GitHub Action —uses: kucherenko/jscpd@v5follows the latest 5.x release; the release workflow moves the tag on every stable release. (#988) - crates.io metadata — every crate now declares
repository,documentation,keywordsandcategories; thejscpdcrate excludestests/from the published package, ships an expanded README rendered on docs.rs, and npm packages carry afundingfield. (#988) - Signed release assets — in addition to SLSA provenance, each release archive and
checksums.txtnow has a Sigstore keyless signature (<asset>.sigstore.json) verifiable withcosign verify-blob; the release notes include the exact commands. (#988) - CI — Windows joined the pull-request build matrix, a smoke test runs the release binary against the
fixtures/corpus on every push, and a nightly job runscargo auditandcargo deny. (#988, #989)
Dependencies
- Bump
quick-xmlto 0.42.0 in/rust(#991)
5.1.1
Bug Fixes
-
jscpdon npm installed the 5.0.16 engine instead of 5.1.0 — thejscpdwrapper package published itsoptionalDependenciespinned to the5.0.16platform binaries, sonpm i jscpd@5.1.0resolved a native binary one release behind andjscpd --versionreportedcpd 5.0.16. Everything 5.1.0 fixed was therefore absent forjscpdusers, including the Windows--baseline-from-reffix. Thecpdpackage was pinned correctly and is unaffected, as are the platform packages themselves — only the wrapper's pins were stale.The cause was in
scripts/sync-version.mjs: the wrapper's version and its platform pins were updated together behind a singleversion !== npmVersionguard, so once anything setversionbefore the script ran, the guard read "already up to date" and left the pins untouched. The two are now updated independently, and the script ends by verifying that every npm version and platform pin matches the release version, exiting non-zero if any disagree — the release workflow runs this script, so a repeat of this mismatch now fails the release instead of publishing. This is the same defect that produced the 5.0.13 republish; the earlier fix covered thecpdpackage but not thejscpdwrapper.
Other
- Declared MSRV corrected to 1.96 — the workspace advertised
rust-version = "1.87"on crates.io, a floor the crate could not build on: theoxcparser crates require 1.96.0, andignore,globsetandaskamarequire 1.88. The value had been set when the Rust workspace was created and never revisited, and no CI job built at the declared MSRV, so the drift went unnoticed. CI now derives the toolchain fromrust-versionand checks against exactly that version.
5.1.0
New Features
-
Windows on ARM support — npm installs now select a native
aarch64-pc-windows-msvcbinary from thejscpd-windows-arm64-msvcplatform package on Windows ARM64. -
Clone baseline (
--baseline,--update-baseline,--fail-on-new-clones) — gate CI on new duplication only. A committed baseline file (e.g..jscpd-baseline.json) records content-hash fingerprints of accepted clones (the same hash the SARIF reporter emits aspartialFingerprints["jscpdCloneHash/v1"], with a multiplicity count per fingerprint); clones absent from it are reported as new, and--fail-on-new-clones[=N]exits 1 when more than N (default 0) new clones are found — independently of--threshold, so legacy duplication is tolerated while regressions fail the build.--update-baselinerewrites the file from the current run (creating it if missing) and prints added/removed fingerprint counts so baseline growth stays visible in CI logs and PR review. The baseline file is versioned, sorted one fingerprint per line for reviewable diffs and trivial merges, and configurable via thebaseline/failOnNewClonesconfig keys. New-clone info flows through the reporters:[NEW]markers and a "(N new)" found-count inconsole/console-full, per-cloneisNewplus thenewClones/newDuplicatedLinesstatistics injson, levelerrorinsarif, andjscpd_new_clones/jscpd_new_duplicated_linesgauges inopenmetrics. (#944) -
Ephemeral baseline from a git ref (
--baseline-from-ref) — stateless variant of the clone baseline for PR gates without a committed file:cpd --baseline-from-ref origin/main --fail-on-new-clones .checks the base ref's tree out into a temporary detached git worktree (removed afterwards; shells out togitlike blame does), scans it with the same detection configuration, and compares the current run against that in-memory fingerprint set — clones absent from the base ref are new. Costs a second scan of the corpus, where the committed--baselinefile needs only one. When the ref is missing (shallow CI checkout) it fails with a clear hint togit fetch origin mainor usefetch-depth: 0. Config keybaselineFromRef; conflicts with--baseline/--update-baseline. (#944) -
OpenMetrics reporter (
--reporters openmetrics) — writesjscpd-metrics.txtin the OpenMetrics text exposition format, ready to be declared as a GitLab CIartifacts:reports:metricsartifact so merge requests show duplication metric changes against the target branch. Exposes gauges for files/lines/tokens analyzed, clones found, duplicated lines/tokens with percentages (project total plus aformat-labeled sample per format), and detection duration in seconds. (#422) -
CodeClimate / GitLab Code Quality reporter (
--reporters codeclimate, aliasgitlab) — writesgl-code-quality-report.json(the filename GitLab's docs use) in the CodeClimate issue format, restricted to the subset GitLab defines as its Code Quality report format, ready to be declared as anartifacts:reports:codequalityartifact so duplicates appear as code quality issues in merge requests — unlike the SARIF reporter, which GitLab ingests as security vulnerability findings. Each clone yields an issue per fragment (each describing the other location, plus the CodeClimateother_locationsfield), with a deterministic fingerprint derived from the clone's content hash so GitLab can tell new issues from pre-existing ones across pipeline runs. Severity isminor, escalating tomajorfor clones absent from a configured baseline or when the run exceeds--threshold. (#958) -
Config discovery in
.config/(dot-config convention) — auto-discovery now also checks.config/jscpd.json(and.config/.jscpd.json) per the dot-config convention, between the root.jscpd.jsonand thepackage.jsonjscpdkey. A root.jscpd.jsonstill wins, so existing setups are unaffected; paths inside the config resolve against the working directory, as with other auto-discovered sources. (#979)
Bug Fixes
- Unknown
--formatvalues warn instead of silently matching nothing — a typo like--format cs(instead ofcsharp) used to scan 0 files and exit 0, indistinguishable from a clean codebase in CI. The CLI now prints a stderr warning naming the unsupported value and pointing to--list; custom formats declared via--formats-extsstay accepted. (#964) - Nix flake builds again — the flake pinned the hash of the mutable
channel-rust-1.97.tomlmanifest, which broke with a fixed-output hash mismatch when Rust 1.97.1 was published. The toolchain is now pinned to the exact patch version (immutable manifest), so the hash can no longer drift. (#976) - Windows:
--baseline-from-refno longer reports every clone as new — the format-suffix stripper treated the drive colon in Windows verbatim paths (\\?\C:\..., the formcanonicalizereturns) as a:formatsuffix and truncated the base scan's source ids to\\?\C, so every snippet read behind the fingerprint computation failed silently and the ephemeral baseline never matched. A colon followed by a path separator is now recognized as structural. Clone fingerprints are also line-ending agnostic now (CR stripped before hashing), so committed baselines survive CRLF/LF differences between platforms.
Other
- Glama MCP listing — the repository now ships a
glama.jsonmaintainer manifest and aDockerfilethat runs the stdio MCP server (jscpd --mcp), used by Glama to build and score the server listing - Signed releases — release artifacts are signed with SLSA provenance, and piped downloads in workflows are pinned (OpenSSF Scorecard)
Dependencies
- Bump Rust toolchain to 1.97.1 and
oxccrates to 0.147 in/rust - Bump
thiserrorto 2.0.20,globsetto 0.4.20,ignoreto 0.4.33,logto 0.4.34 in/rust
Thank You ❤️
- @luchsamapparat for contributing Windows on ARM support (#963)
- @dmromanov for proposing the OpenMetrics reporter (#422)
- @beanaroo for proposing the GitLab / CodeClimate Code Quality report format (#958)
- @MRDGH2821 for proposing config discovery from the
.config/subfolder (#979) - @zbcoding for reporting the silent unknown-
--formatbehavior (#964) - @eaves-dropper for reporting the Nix build failure (#976)
5.0.16
New Features
- MCP server over stdio (
--mcp) —cpd --mcp /path/to/projectserves the Model Context Protocol on stdin/stdout, the transport MCP clients spawn and manage themselves (no port, no network policy). The project is scanned once at startup and kept in memory as detection-ready token hashes, socheck_duplicationsnippet checks answer in milliseconds. Tools:check_duplication(accepts format names or file extensions),get_file_clones(clones involving one file — new over the HTTP server),get_statistics, andcheck_current_directory(returns the clone list). All clone/match lists are sorted biggest-first and capped by an optionallimitargument (default 100) with the untruncated total always reported. Implements protocol revision2025-06-18(accepting2025-03-26/2024-11-05clients); all standard detection options (--min-tokens,--format,--cross-formats, ...) apply to the scan and to snippet checks. (#891) - Codebase summary (
--summary) — opt-in refactoring-hotspot overview appended to the run output: top files and folders ranked by tokens, lines, size, or a token-based cyclomatic-complexity estimate, with each file's duplication share.--summary-top <n>sets the list length,--summary-by tokens|lines|size|complexitypicks the ranking metric (config file:summary,summaryTop,summaryBy). Renders inconsole/console-full, as a compact one-line-per-entry block in theaireporter, and as an additivesummarykey in the JSON report (absent when the flag is off, so the schema is unchanged for existing consumers). Computed after detection from data already in memory — runs without--summaryare unaffected. (#934) - Isolated folder groups (
--skip-isolated) — skip duplication between monorepo folders owned by different teams: declare isolation groups as comma-separated lists of pipe-separated folders (--skip-isolated "packages/team-a|packages/team-b,libs/a|libs/b"), and clones whose two fragments fall under two different folders of the same group are dropped. Duplication inside a single folder, against shared code, or across unrelated groups is still reported. The config file accepts the nested-array shape"skipIsolated": [["packages/a", "packages/b"]](kebab-caseskip-isolatedworks too), and the option applies to MCP project scans as well. Ports #628 to the Rust engine. (#942)
Security
- Supply-chain hardening (OpenSSF Scorecard) — every GitHub Action in the release and CI pipelines is pinned to a full commit SHA (kept fresh by Dependabot), workflow tokens follow least privilege (top-level
contents: read, write grants scoped to the jobs that need them), and the repository now has aSECURITY.mdwith private disclosure channels, private vulnerability reporting, and a protectedmasterbranch
Bug Fixes
- GitHub "Latest" release badge stays on v5 — Rust v5 releases are created with
--latest; legacy TypeScript v4 andcpd v*releases explicitly opt out, so a v4 maintenance release can no longer take the Latest badge from the v5 line
Other
- npm package page polish — README links are absolute GitHub URLs so they resolve on npmjs.com, and the package description and keywords better describe what jscpd does
Dependencies
- Bump Rust toolchain to 1.97 and
oxccrates to 0.144 in/rust - Bump
serdeto 1.0.229 in/rust - Bump
clapto 4.6.6 in/rust - Bump
memchrto 2.8.3 in/rust - Bump
xxhash-rustto 0.8.18 in/rust
Thank You ❤️
- @hanzhangyu for proposing isolated folder groups for monorepos and contributing the original
skipIsolatedimplementation (#628), which this release ports to the Rust engine
5.0.15
New Features
- SARIF: size-based severity — new
--sarif-error-tokens <N>flag (alsosarifErrorTokensin.jscpd.json): clones with at least N tokens are reported at levelerrorwhile smaller ones staywarning. When overall duplication exceeds--threshold, all SARIF results are emitted aserror, matching the threshold check that fails the build. Default output is unchanged when neither option is set. (#908) - SARIF: clone fingerprints — each result carries
token_countand aclone_hashin its properties bag, plus apartialFingerprintsentry (jscpdCloneHash/v1) for cross-run result identity in consumers like GitHub code scanning. The hash is order-insensitive, so the same clone pair produces the same hash regardless of file discovery order. (#909) - SARIF: related-location messages — the duplicate's counterpart location now has a message (
Duplicated at <path>:<line>), and the primary message references it via a SARIF embedded link so GitHub code scanning displays it. (#911) - SARIF: richer rule metadata — the
jscpd/duplicate-coderule now includes a display name, full description, default configuration, and quality tags for better presentation in SARIF viewers and Azure DevOps. (#914)
Bug Fixes
- Scan-root-relative report paths — fragments store their scan root separately (
source_root), so report paths are relative to the scanned directory again (as in 4.x) while reporters can still resolve and read source files; SARIF emitsoriginalUriBaseIdswith per-root base ids. Fixes empty snippets and unresolvable paths when scanning from outside the target directory, including multi-root scans. (#872, #892) - Report version stamping — the SARIF
tool.driver.version(previously hardcoded5.0.3) and the HTML report version now matchcpd --version, bundled at build time. (#915) - Multi-root blame attribution — with multiple scan roots containing the same relative path, git blame data is now keyed by resolved path, so the second root no longer inherits the first root's authors.
- Git root discovery — walking up from a relative scan path no longer terminates early before reaching the repository root.
Dependencies
- Bump
serde_jsonto 1.0.151 in/rust - Bump
ignoreto 0.4.32 in/rust - Bump
anyhowto 1.0.104 in/rust
Thank You ❤️
This release was shaped by community contributions — huge thanks to:
- @chrisc-onaorg for the SARIF clone fingerprints (#910), related-location messages (#912), and richer rule metadata (#914), plus reporting #909, #911, and #915
- @darronz for the scan-root-relative paths fix (#913)
- @nvuillam for proposing size-based SARIF severity (#908)
5.0.14
New Features
--cross-formats— detect clones across related formats via format equivalence groups sharing one comparison pool, e.g.--cross-formats "javascript,typescript"or thejs-tspreset (javascript,jsx,typescript,tsx). When a group mixes TypeScript with JavaScript, TS files are compared with erasable type syntax stripped (positions still reference the original source), sofunction f(a: number): voidmatchesfunction f(a). Also configurable ascrossFormatsin.jscpd.json/package.json(string, array-of-strings, or array-of-arrays). Cross-format clones are attributed to one member format in per-format statistics. (#810)
Bug Fixes
- Prose-only Markdown files are now analyzed — the Markdown tokenizer only extracted fenced code blocks, so
.mdfiles without code fences produced zero tokens and were silently skipped (-f markdownmatched 0 files in Markdown-only projects). Prose is now tokenized too, so duplicated prose is detected as clones, while embedded code fences keep being detected under their own sub-format pools. (#883)
Dependencies
- Bump
regexto 1.13.1 in/rust - Bump
globsetto 0.4.19 in/rust - Bump
ignoreto 0.4.28 in/rust - Bump
xxhash-rustto 0.8.16 in/rust
5.0.13
npm-only release: republished the cpd package so its optionalDependencies point at the 5.0.12 platform binaries (the cpd@5.0.12 package still referenced the 5.0.11 binaries). No code changes. The version sync script now keeps these in sync automatically.
5.0.12
Dependencies
- Bump
askamato 0.16.0 in/rust - Bump
logto 0.4.33 in/rust - Bump
env_loggerto 0.11.11 in/rust - Bump
rustc-hashto 2.1.3 in/rust
5.0.11
New Features
- Razor (.razor) support — new tokenizer for Razor files, parsing HTML content and Razor keyword blocks (thanks to @chrisc-onaorg in #829)
Bug Fixes
- Fix
cargo teston Windows by usingCARGO_BIN_EXE_cpdor.exesuffix to locate the test binary - Fix
cargo fmtformatting issues across the workspace
Refactoring
- Extract methods and optimize intervals in
cpd-core(bumped to 0.1.6)
Dependencies
cpd-coreupdated to 0.1.6,cpd-tokenizerupdated to 0.1.7 across all dependent crates
5.0.10
Bug Fixes
- Emit scan-root-relative paths in all reporters when
absolute: false(or the default). Previously,jscpd /abs/pathfrom a different CWD left absolute paths in SARIF/JSON/XML/HTML/CSV/Markdown/console output, and Windows/macOS path canonicalization could leave\\?\or./prefixes. Paths are now normalized against the canonicalized scan root (with CWD fallback) and stripped of any leading./or.\\component. Fixes #827 - Fix
--skip-localto match jscpd v4 TypeScript semantics: it now filters clones where both fragments are under the same scan root, instead of only skipping clones in the same parent directory
Refactoring
- DRY duplication in reporters: extract shared helpers (
print_clone_header,print_clone_locations,print_snippet,write_report_file, report statistics, test fixtures, etc.) intocpd-reporter/src/shared.rs. Console, console-full, CSV, JSON, HTML, Markdown, silent, XML, and SARIF reporters now reuse the same implementation, reducing the monorepo's reported duplication ratio from 5.0% to 0.56% and fixing a latent--absolutepath relativization bug in the same pass - Move blame enrichment from
gitoxidetogit blame --porcelain; capture elapsed time after blame so timing includes blame work - Resolve
needless_borrowclippy warnings in CSV and Markdown reporters
Documentation
- Add Nix and Homebrew install instructions to Rust READMEs. #818
- Update project homepage URLs to
https://jscpd.devin allCargo.tomland npmpackage.jsonfiles, add curl install method to READMEs, clean up outdated badges - Remove defunct Universal Analytics tracking pixels from all READMEs
5.0.9
New Features
- GitHub Action for jscpd (Rust v5) —
jscpd-copy-paste-detectoraction for GitHub Actions Marketplace. Scan your repo for copy/paste in CI withuses: kucherenko/jscpd/.github/workflows/action.yml@v5
Bug Fixes
- Resolve platform binary resolution when
cpdis installed as a nested dependency (e.g. in a project'snode_modulesvia a parent package). The runner now correctly locates the platform-specific binary relative to the installed package rather than assuming a top-level install. Fixes #816
5.0.8
Bug Fixes
- Prevent mmap exhaustion crashes when scanning repositories with more files than
vm.max_map_count(default 131 072 on Linux). The walker previously held a liveMmapper discovered file; each rayon worker now opens and drops its mapping within the processing closure, capping concurrent mappings to the thread-pool size (typically 8–32). Fixes #813 - Fix
--patternnot matching relative paths when the scan root is absolute (e.g. CWD). Patterns likesrc/**/*.tsnow match correctly by comparing against both the relative path and the full absolute path, and bare patterns like*.tsgain a**/prefix to match at any depth. Fixes #811 - Fix trailing-newline off-by-one in line-count filter: files not ending with
\nnow count the final line correctly
5.0.7
Bug Fixes
- Prevent stack overflow when scanning directories containing deeply-nested JS/TS files (e.g. Bun's
test/bundlerwith 320K+ nested for-loops). OXC's recursive-descent parser allocates one stack frame per AST nesting level; pathological inputs now exceed the default 8 MiB thread stack. Fixed by building a local rayonThreadPoolwith 64 MiB stacks instead of using the global pool (which silently fails on re-init) - Default
--max-sizeto1mb— files exceeding the limit are skipped at walk time, consistent with jscpd v4'smaxSizebehavior. This prevents OXC from ever seeing megabyte-scale generated files that would overflow the stack --workers Nnow correctly takes effect on everyrun()call (previouslybuild_global()silently no-op'd after the first invocation)
5.0.6
New Features
- v4 config backward compatibility —
.jscpd.jsonfieldspath,pattern,ignore, andignorePatternare now read and applied, matching jscpd v4 behavior ignoreandignorePatternare now distinct:ignorematches file-level globs,ignorePatternmatches code-level regex patterns (previously conflated).jscpd.jsonpath config support — reads scan directories from thepathfield, resolving relative paths against the config file's directoryjscpdnpm wrapper package — publishes the same Rust binary under thejscpdname on npm with v5.x versioning--exit-codenow matches v4 behavior: accepts optional integer value (--exit-codeexits 1,--exit-code 2exits 2);--thresholdand--exit-codeare now independent- Performance improvements: memory-mapped file I/O (via
memmap2) eliminates heap copies of file contents; SIMD-accelerated line counting (viamemchr); parallel detection pipeline usesflat_mapto avoid intermediate allocations; JS tokenizer no longer clones source strings before parsing (thanks to @auterium, #808)
Bug Fixes
- Fixed
--exit-codeto match jscpd v4's--exitCodebehavior (was boolean, now optional integer) - Fixed unique temp dir generation in reporter tests (added PID to prevent race conditions under parallel test runners)
5.0.4
New Features
- CLI alignment with jscpd v4: new
--absolute,--ignore-case,--formats-exts,--formats-namesflags; fixed--threshold, improved--max-size - Detection and statistics aligned with jscpd for consistent output across Rust and TypeScript versions
- Side-by-side blame comparison in console-full reporter
- Clone list display in console reporter
Bug Fixes
- HTML reporter now outputs
jscpd-report.htmlat theoutput_dirroot - Resolved all clippy warnings across workspace
- Fixed unique temp dir generation in tests (use
as_nanos()instead ofsubsec_nanos())
5.0.3
New Features
- Rust-based cpd CLI with full feature parity to TypeScript jscpd
- Cross-platform binary distribution via npm platform packages (linux-x64-gnu, linux-arm64-gnu, linux-x64-musl, darwin-arm64, darwin-x64, windows-x64-msvc)
- 13 reporters: json, console, xml, csv, html, markdown, sarif, ai, badge, xcode, threshold, silent, console-full
- Time reporter for execution timing
- CLI short-form aliases matching TypeScript jscpd conventions
- ReportContext data structure for extensible reporter signatures
- Trusted Publishing support for crates.io via OIDC
5.0.2
Bug Fixes
- Fixed Vue SFC tokenization to dispatch each block to its own sub-format
- Fixed entire-file duplicates silently dropped by RabinKarp store flush logic
- Fixed ReDoS hang on Lisp/Elisp files
- Fixed crash on malformed package.json when reading config
5.0.1
New Features
- Initial Rust workspace with cpd-core, cpd-tokenizer, cpd-finder, cpd-reporter, and jscpd crates
- Cross-format detection for Vue SFC, Svelte, Astro, and Markdown files
- Shebang detection for extensionless scripts
5.0.0
Breaking Changes
- First stable Rust release — replaces the TypeScript-based CLI with a native binary
- Reporter trait signature changed to use ReportContext instead of Statistics directly