All notable changes to cpd (Rust) are documented here. Releases follow Semantic Versioning.
- Rust dead code, read from the compiler. jscpd does not parse Rust, and does not need to. Every
cargo buildalready prints lines likefunctionreprintis never used, with real name resolution, trait dispatch and macro expansion behind them. The dead-code run now reads that output, from the JSON ofcargo check(orbuild,clippy,test --no-run), and reports it next to everything else, through the same categories, reporters and--min-confidence, at 100% confidence.bastatakes--rust-diagnostics <file>, or-for a pipe:cargo check --all-targets --message-format=json | basta . --rust-diagnostics -.- jscpd takes the same flag for
--dead-code,--dashboardand--health, so a Rust project gets its health badge from the check it already runs:cargo check --all-targets --message-format=json | jscpd . --health --rust-diagnostics - --reporters badge. The dead-code section of the config file takes the file asrustDiagnostics. - Neither tool runs cargo itself, because that would execute the project's build scripts and procedural macros.
- The compiler's span covers only the name, so the item's real size is read from the source. An item that
--all-targetsreports twice is counted once. A diagnostic inside a macro expansion, or for a crate outside the scanned paths, is left out. - A finding from a test target is handled like a test file in any other language: reported only with
--include-tests, at 85%. - In a workspace, cargo writes paths relative to the workspace root, and they are resolved as such. A relative
manifest_pathin a hand-edited or copied file is resolved from where the file lives. - The compiler never reports a
pubitem of a library, and it has no notion of an unused file, so jscpd reports neither for Rust. - The dashboard's dead-code heading no longer names three languages, and a project with no language the run could read says so in its health score.
- See
fixtures/dead-code-demo. (#1089)
-
Text around an embedded code block was counted as code. A
tsblock in a markdown file, a<script>in a single-file component: jscpd scans these under the embedded language's own format, and the block keeps the line numbers of the file it lives in. The statistics read those numbers as if the block ran the whole way, which put the markdown prose and the component templates into the embedded language's line counts — both the total and the duplicated lines. On a repository with about 3600 markdown files the typescript row said 51.30% of lines were duplicated where the same code, scanned with-f typescript, said 3.37%. Neither number was a typo and both came from the same run.An embedded source now counts the lines its blocks occupy, and a clone counts the block lines it covers rather than everything its fragment reaches across. The two percentage columns agree again, which is the quickest way to check a run: they measure the same duplication, so when the line column sits far below the token column it is counting text that holds no code. Ordinary files are counted exactly as before. See
fixtures/embedded-stats-demo. (#1091, closes #1090) -
Every clone was one line short. Duplicated lines came from
end - start, so a clone covering lines 10 through 19 counted nine — while the console printed10 linesfor it, from its own arithmetic. Line counts are inclusive now, and the header and the table come from one place. Expect duplicated-line totals to rise by about one line per clone, and percentages with them. (#1091)
- The dead-code engine in this release is basta 0.3.0, which is also published on its own:
bastaon npm, crates.io and GitHub.
-
--dashboardlists the largest code files. The Project section now ranks files by lines, with their tokens and size, next to the largest formats — the files worth splitting, beside the most complex ones further down. Only code is ranked: a lockfile, a changelog or a long HTML page is often the longest file in a repository and nothing anyone would refactor, so prose, data and markup files are left out, as they are from the complexity list.--summary-topsets the rows, and thejson,markdownandhtmlreporters carry the same list (project.largestFilesinjscpd-dashboard.json, an added key). Seefixtures/dashboard-demo. (#1086) -
basta now detects which frameworks a project uses. A framework runs files that nothing imports: a router's pages, a runtime's plugin directories, handlers it finds by convention. basta used to know three such cases, hard-coded: Nuxt, Nitro and WXT. It now reads them from a list of about fifty definitions built into the binary,
frameworks.yaml. The list covers Next.js, Nuxt, Nitro, WXT, Plasmo, Remix, React Router, SvelteKit, Astro, SolidStart, TanStack Start, Qwik City, Gatsby, RedwoodJS, Angular, NestJS, AdonisJS, Sails,@fastify/autoload, Strapi, Medusa, Ember, Quasar, React Native, Expo, Cloudflare Workers, Vercel, Netlify, Serverless, Trigger.dev, Docusaurus, VitePress, Eleventy, Storybook, Histoire, Jest, Vitest, Mocha, AVA, Playwright, Cypress, Cucumber, Prisma, Knex, Sequelize, TypeORM, Hardhat and Create React App.- A framework counts as present when basta finds any one of three things: its config file, its package among the
package.jsondependencies, or its section inpackage.json. basta checks every directory that holds scanned files, so each package of a monorepo is treated as its own project. - Several frameworks can apply at once, and each one keeps its own files alive. A Next.js application with Storybook, Vitest and Prisma counts as four.
- The files and directories a framework loads are matched relative to the directory where the framework was found. basta also reads the framework's config for the settings that move directories:
srcDir,entrypointsDir,appDirectory,sourceRootinnest-cli.json, andimports: false. - The console report lists what was detected, for example
Frameworks: next (apps/web), vitest. Bothbastaandjscpd --dead-codeprint it. - New
bastaflags:--list-frameworksprints the list.--framework <name>treats a framework as present, which helps when the scan starts below itspackage.json.--no-frameworksturns detection off.--frameworks-config <file>loads your own definitions, in YAML or JSON, in the same shape as the built-in list. basta also picks upbasta.frameworks.{yaml,yml,json}from the working directory. Your definitions can add a framework basta does not know, or replace a built-in one that has the same name. If the file fails to load, basta stops with an error, because running without it would report as dead the very files the definitions protect. - A definition can also list the names its framework looks up in your code, under
globals. The built-in list hasgetServerSideProps,generateMetadataand the route segment config for Next;loader,actionandmetafor Remix and React Router;load,actions,prerenderand the hooks for SvelteKit;getStaticPathsfor Astro; the Gatsby Node, browser and SSR APIs; the lifecycle methods of Angular and Nest;onRequest*for Cloudflare Pages Functions;handlerfor Netlify and Serverless functions; and more. basta treats a declaration with such a name as used. It never reports it, including under--include-entry-exportsand as an unused member, and everything the declaration calls stays reachable. - A plain name in
globalsapplies to every file of the project. The{ names, files }form limits names to the files the framework reads them from, soloadercounts for Remix under the app directory and is an ordinary name everywhere else. This also fixes a React Router route named fromroutes.ts. basta reaches that file through a string, so it is not an entry point, and itsloaderused to be reported as an unused export. - Nitro now follows
srcDirand treatstasks/as loaded by the framework. - See
fixtures/dead-code-demo. (#1087)
- A framework counts as present when basta finds any one of three things: its config file, its package among the
-
.jscpd.jsoncan hold dead-code settings in a section of their own. ThedeadCodekey (also spelleddead-codeorbasta) used to be a boolean that turned the mode on. It can now also be an object with these keys:enabled,categories,minConfidence,minLines,entry,ignore,includeTests,includeEntryExports,threshold, and the framework settingsframeworks(definitions written inline, in the shape offrameworks.yaml),frameworksConfig,frameworkandnoFrameworks.- The object only supplies settings. It turns the mode on only when it says
"enabled": true, so you can keep clone settings and dead-code settings in one file and choose the run on the command line."deadCode": truestill works as the switch. - A flag wins over the section. The section wins over the older top-level keys (
minConfidence,entry,deadCodeCategories, …), which still work. minLinesandthresholdexist only inside the section, because the top-level keys with those names are about clones. The top-levelminLinesis the smallest clone to report and is never applied to a dead-code run. The top-levelthresholdis a limit for duplicated lines. A dead-code run still falls back to it when the section sets nothreshold, as before.- A misspelled key inside the section is reported by name.
jscpd --dead-codeand--dashboardnow also pick upbasta.frameworks.{yaml,yml,json}from the working directory.- The standalone
bastabinary reads the same section from the same file. It has a new-c, --config <file>flag. Without it, basta looks in the working directory for.jscpd.json,.config/jscpd.jsonorpackage.json. Both tools give the same result when you start them in the same directory. - See
fixtures/dead-code-demo. (#1087)
- The object only supplies settings. It turns the mode on only when it says
We ran basta next to knip and fallow on 55 repositories: the GitHub trending lists for JavaScript, TypeScript, Vue, Svelte and Astro, plus the source code of Nuxt, Next.js, Svelte, Gatsby and Astro. The run found these problems in basta, all fixed in [#1087](#1087:
--dead-codewas very slow on projects with many path aliases. basta tested every import against every alias in the project, and it did the slow check first: comparing the importer's path with the directory of the config that declared the alias, one path segment at a time. A monorepo with 150 packages, each listing a few hundredpaths, took 198 s (ever-gauzy). Next.js took 83 s. basta now checks first whether the alias pattern matches the import at all, which fails on the first character for almost every alias. The same two projects now take 3.3 s and 4.4 s, and the findings are byte for byte the same.- A catch-all alias made every package import slow. With
"*": ["./*", "../../node_modules/*"], an import ofreactwent through the resolver from every file, and each miss built and hashed about twenty candidate paths. The module index can now tell in two lookups whether a path could name any module, under any extension or as a directory index, and the answer is exact. LibreChat went from 4.0 s to 0.86 s with identical findings. - Vite's
'@': '/src'alias was read as the root of the file system. In a Vite alias target a leading/means the project root, as it does in a URL, and many projects write the alias that way. basta lost every@/import in such a project and reported its components as unused files at 95% confidence. MoeKoeMusic went from 55 unused files to 0. require(`./x`)with backticks was ignored. A template literal with nothing interpolated is as static as a quoted string, and Gatsby writes every string that way. Gatsby went from 584 unused files to 352.- An arrow function in a Svelte or Astro attribute broke parsing. In
<script on:load={() => { … }} src=…>, basta took the>of=>for the end of the tag and passed the rest of the attribute to the JavaScript parser as the component's script. The file failed to parse, and everything it imports was reported as unused. cobalt went from 37 unused files to 8. - Django migrations and management commands were reported as unused files. So were
admin.py,apps.py, template tags, and the modules that settings name by dotted path. Django, Alembic and Scrapy are now in the framework list, detected bymanage.py,alembic.iniandscrapy.cfg. AdventureLog went from 144 unused Python files to 6. - A file named only by a path string without an extension is no longer reported at full confidence.
resolve(distDir, 'runtime/handlers/island')is how a framework registers a file it loads itself. It is not an import, and basta does not treat it as one. But a file whose own path ends the same way now gets a new reason,path-appears-in-string("its path appears in a string literal"), which costs 40 points. That puts the file under the default threshold of 60, and it still shows up with--min-confidence 0. Nuxt went from 83 unused files to 46. The reason is a new possible value ofreasonsin the JSON report. --dead-coderead a WXT browser extension as almost entirely dead (#1082): the framework'sentrypoints/directory — background, content scripts, popup pages — was not recognized as entry points, and its@/~→srcDir,@@/~~→ root aliases live only in the generated.wxt/tsconfig.json, which no repository commits, so the whole tree dangled and cascaded (Tencent/BrowserSkill: 27.9% "unused", 115 unused files — now 0.4% and 5). Awxt.config.*now marks the entrypoints and auto-import directories as entries, honoringsrcDir,entrypointsDirandimports: false, and declares the conventional aliases, the same waynuxt.config.*roots Nuxt's directories andsvelte.config.*supplies$lib. (#1083)- Markup, stylesheet and template files (HTML, XML, SVG, CSS, Handlebars, …) were assigned a complexity, counting words like
if,foror a media query'sandas branches — an HTML page could top the "Most complex files" list. These formats now have complexity0, like prose and data files already did, and are therefore no longer counted as code by the health score: an all-markup project reports "no code files" instead of being scored on its markup. Component formats (Vue, Svelte, Astro) still count in full through their script blocks. The markup block of a component file (tokenized ashtml) is now also excluded from the duplication share, matching thecss/scssblocks that already were. (#1081) (#1084)
- The dead-code engine in this release is basta 0.2.0, which is also published on its own:
bastaon npm, crates.io and GitHub.
-
--dashboardshows the whole project on one screen: size and largest formats, duplication (clone count per kind, breakdown by format), complexity (most complex files), and dead code (JavaScript/TypeScript/Python, largest findings), all under a health badge. It runs the clone and dead-code scans side by side, so it costs about as long as the slower one, not their sum;--workersbudgets the pair.--summary-topsets rows per list; detection options (--min-tokens,--kind,--ignore, …) and dead-code options (--entry,--dead-code-categories,--min-confidence) apply to their own sections. Reporters:console,json(jscpd-dashboard.json),badge(jscpd-health-badge.svg),markdown/html(jscpd-dashboard.md/.html). Clone-run exit gates apply (--threshold,--exit-code,--fail-on-empty); the baseline family needs a clone report this mode doesn't 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. Duplication, dead code and complexity — each a share of code lines — are mixed with a "typical project" prior, scored on a half-life curve, and combined into one weighted geometric mean and a letter grade. Duplication skips markup, stylesheet and template blocks (HTML, CSS, Handlebars, …), since a repeating style rule isn't the same maintenance problem as repeating logic; component/script languages (Vue, Svelte, Astro, GraphQL) still count in full. The console line names what it measured and, when relevant, what it left out (5.4% in typescript (no text)). A dimension that can't 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 clean.--health-input FILE(config keyhealthInput) folds in metrics from other tools; the same object can live under ahealthkey in.jscpd.jsonto tune the built-in dimensions' half-life and weight. Reporters:console(badge),ai(one line),json,badge,markdown/html. Seefixtures/dashboard-demo. -
--complexityruns the complexity half of--summarywithout the clone scan: same walk, same tokenizing, same filters, but detection — the part that scales with codebase size — never starts. Reporters:console,ai,json(jscpd-complexity.json). Can't combine with--dead-code,--dashboardor--mcp, and warns instead of 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 real cyclomatic complexity — the old estimate was one path per file plus every branch keyword it could see, matching neither the name nor any language consistently. Detection, tokenization and clone output are untouched; everything below is scoped to the summary.Short-circuit operators now count: only the JavaScript tokenizer emitted
&&as one token, so for the other 200-odd formats&&/||were unreachable branch entries — 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 its language spells it — Rust's
matcharms by their=>(one branch fewer than arm count), Swift'sguardand Go'sselectadded,?counted as a ternary only where it actually opens one (told apart fromString?/x?.yby the preceding token), and?:read as Elvis in Kotlin/Groovy but as an optional property in TypeScript. One global keyword list couldn't have handled all of that at once.Complexity is now one path per function, not per file: where a language has a reliable marker (
def,fn,func,function,fun,=>for JS arrow functions) the baseline is the function count; elsewhere it stays at one, as before. A docstring no longer adds branches either — the tokenizer now closes a"""string against the literal it actually arrives in (so a three-line docstring doesn't get read as three branch-bearing words), for the nine languages where"""is a real delimiter (Python, Kotlin, Scala, Groovy, Swift, Java, Julia, Elixir, Dart) and not where it's an escape (C# verbatim strings, VB.NET, SQL, Pascal).A word a file binds as a name is that file's name, not a branch keyword:
case,whenandcondare keywords in some languages and ordinary identifiers in others, and the tokenizer can't tell — but what a file assigns to, reads off an object, or lists as a parameter is now excluded, socase = 3orf(case, when)no longer counts as a branch.C, C++, Java, Objective-C and C# declare a function with no keyword at all (
head(args) {), told apart from anif/switch/for's closing) {by tracking what preceded the open paren — 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, every project improving. C++ moved from 0.71 to 0.94, C# from 0.82 to 0.99. Remaining differences are intentional: lizard counts a
matchonce regardless of arm count, and doesn't count??.fixtures/summary-demohas one file per language whose complexity can be counted by hand. -
--dead-code: find code nothing runs — a new engine (basta) that ships inside the jscpd binary and also stands alone as abastacommand. It builds the import graph from a project's entry points and walks it, reporting unused files, exports, module-private declarations, 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 is mapped back to its source.--entry <glob>adds more.It's a graph traversal, not a reference count, so dead code cascades: a helper whose only caller is dead is reported too. Every finding carries a confidence score (0-100) and, below 100, a reason it might be wrong — an
eval/getattrcall, an unrecognised decorator, a wildcard re-export, a name that only appears in a string literal, a file that failed to parse.--min-confidencesets the floor (default 60).CommonJS is read alongside ESM (
require, destructuredrequire,module.exports,exports.a, dynamicimport()), so a Node project that never touchedimportisn't read as a pile of dead files. Import aliases resolve throughtsconfig.json/jsconfig.jsonpaths/baseUrl, including monorepoextendschains, so a Next.js@/components/xalias reaches its file instead of looking missing. A path named only as a string —new URL('./w.ts', import.meta.url), a build entry, avitest.config.tssetup file — counts as used. Python honors quoted type annotations underTYPE_CHECKINGand resolvessrc/-layout imports; files that fail to parse are listed understatistics.unparsedFiles.Single-file components (
.vue/.svelte/.astro) are read whole, script and markup together — the script is masked in place rather than extracted, so reported positions stay real, and the markup is scanned for kebab-case component names,{{ … }}interpolations, actions/transitions and attribute expressions. Skipping this would report every component import as dead and every module a component alone imports as unused. Framework directory conventions (Nuxt'scomponents/,composables/, etc., in both v3 and v4 layouts) are read from the project's own config rather than guessed from names, so a real import isn't silenced.Import paths are read the way the project's own build reads them:
vite.config.*/svelte.config.*aliases jointsconfig.jsonpaths, SvelteKit's$libconvention is read directly since its generated tsconfig isn't committed, computed specifiers (import(`./services/${type}.vue`)) resolve as globs,import.meta.glob()patterns are followed, and markup-expressionimport()counts as an edge while the same text in a comment doesn't. Nitro'sapi//routes//middleware/are read as file-system routes like Nuxt's.Monorepo package names (
@acme/ui,@acme/ui/date) now resolve through the owningpackage.json'sexports/main/module, found by walking up to the workspace root (pnpm-workspace.yaml,lerna.json,turbo.json, or a rootworkspacesmanifest) rather than stopping at the package directory.Measured on thirty Vue, Svelte and Astro trending projects against the previous release, reported unused files fell from 1093 to 481, and the share with a real importer in the tree dropped from 83% to 62%. Components imported only from
.mdxare still reported — basta doesn't read MDX, so use--entryto mark them used.It ships on npm and crates.io as
basta(crates.io), version 0.1.0, with its ownbasta-<platform>packages and its own release trigger (abasta-v*tag, not the push to master that releases jscpd), so neither release drags on the other. Inside the jscpd binary, the same engine answers tojscpd --dead-code. Languages plug in through one trait (basta::lang::Analyzer);docs/basta-extending.mdwalks through adding one.The mode reuses what a jscpd user already knows — the same walker and filters (
--ignore,--format,.gitignore,--max-size,--follow-symlinks), the same fifteen reporter names, the same--threshold/--exit-codegates.--dead-code-categoriesnarrows what's reported;--include-testsand--include-entry-exportswiden it. Seefixtures/dead-code-demoand the docs.
- 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, so only the ones that actually fail to parse are stripped and reported. - 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 console table rows, since
--dashboard,--healthand theirmarkdown/htmlreporters didn't sanitize 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 like 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, unlike every other mode's--fail-on-empty.--min-confidenceabove 100 was accepted by--dashboard/--healtheven though standalone--dead-codealready 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 but still counted in full toward the denominator, so excluding it barely moved the score. It's now a hard exclusion on both sides, and a project whose code is entirely markup skips the duplication dimension instead of scoring it from the size prior alone.
- Windows report paths used backslashes (
src\route.js) where every other output already normalized to forward slashes. bastabumped itsoxc_*parser crates to 0.150 (from 0.147).
--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, 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 underhistoryand the GitHub Action takes ahistoryinput. (#1002, #1050, #1052)- Exit codes you can gate on, and
--fail-on-empty— an unknown--format, a missing scan path, or a reporter that can't write now print an error and exit 1 instead of passing with an empty report.--fail-on-empty(configfailOnEmpty, action inputfail-on-empty) turns "analyzed no files" into a failure, so a mistyped path or an over-broad ignore can't look like a clean run. (#1047, #1049) - PyPI:
pip install jscpd— eight platform wheels built from the same prebuilt binaries as npm and the GitHub release, sopip install jscpd/uvx jscpdget the Rust engine with no Python code and no Node.js involved. The repository pre-commit hook now installs from PyPI instead of npm. (#1037, #1039)
- An open clone could be stretched past the file it started in — growing a clone accepted a continuation from any stored occurrence of the next window, so a third file sharing the same text but continuing differently could extend a fragment beyond its own file (
fixtures/haxereported mismatched ends between two files). The match now checks whether the clone's own anchor continues before extending, so N-way copies no longer lose pairs. (#1033, #1034) - The XML report could be rejected by every parser — a byte XML 1.0 can't represent was written verbatim (
xmllintrefused withPCDATA invalid Char value 27),]]>inside a fragment closed the CDATA section early, and attributes 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 symlinked file was reported by its resolved real path (sometimes outside the scan root), so the report and--ignoredisagreed about its name; a file reachable two ways counted twice, and a symlink beside its target reported as a clone of itself. Files now keep the path they were found at, and each real file is scanned once. (#1059, #1060)
- Symlinks are skipped by default in v5 — v4 followed them unless
--noSymlinkswas set; v5 needs--follow-symlinks(configfollowSymlinks). True in every 5.x release but undocumented until now, and it silently drops a corpus mounted through a symlink. (#1059) CITATION.cffand a Citation section — GitHub's "Cite this repository" button and a BibTeX entry, version and date kept in step bysync-version.mjs. (#1051)- Docs: jscpd is language-aware — detection runs on language tokens with per-format comment/string syntax and the oxc parser for JS/TS, not on raw text. (#1048)
- Agent skills know about clone kinds, the summary and their noise — the bundled
jscpdanddry-refactoringskills document--summaryand the Type-2/Type-3 flags, and warn that normalized passes surface look-alike code, with conservative defaults and a triage step. (#1056, #1057) console-fullprints the--historyblock likeconsoledoes; test scaffolding behind the CLI, MCP, reporter and finder suites was deduplicated. (#1053)- CI: the npm platform-package gate polls a 5-minute deadline instead of a fixed sleep, so a slow registry no longer fails a release that would have succeeded. (#1032)
- 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)
- @mnahkies for correcting the ignore examples in the README —
--ignore-patternhas no short flag, and a barenode_modulesdoesn't match since globs match against the whole path (#1038)
- Type-2 clone detection:
--ignore-identifiers,--ignore-literals,--ignore-annotations— three opt-in flags that normalize token classes before hashing, so blocks differing only in names, literal values or annotations are found. Identifiers hash as one class while keywords keep their value, strings/numbers stay distinct, and@Name(...)runs are dropped in Java, Kotlin, Scala, Groovy, Python, Dart, Swift, JS and TS (@interfacekept). Every clone now carries akind:exactorrenamed. A run without the flags is unchanged apart from the additive"kind": "exact"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; now, clones of one file pair whose fragments follow each other with at most N unmatched lines between them merge into one clone of kindsimilarwith asimilarityvalue. A merge below0.5similarity is refused, duplicated-line stats count only matched lines, and a merge of renamed halves reports assimilar. Seefixtures/type3-demo. (#999, #1020, #1030) - Function-level similarity for JavaScript and TypeScript with
--similarity RATIO— compares every function/method/arrow function by the bag of 4-grams over its syntax-tree node types (MinHash-indexed), reporting pairs at or above the ratio assimilarclones spanning whole functions. Names and literals don't count: a renamed copy scores1.0, one inserted line about0.9, two inserted statements plus renames about0.75. Eachsimilarclone records itsmethod(gaporast) since the two scores aren't on the same scale. The MCPcheck_duplicationtool accepts the same argument. (#999, #727, #1021) - Clone kinds in every reporter — console prints
Clone found (javascript, renamed)/similar (gap) ~0.91,aiappends(renamed)/[~0.91 gap], JSON addskind/similarity/methodplusrenamedClones/similarClonesstats, XML/HTML/Xcode show the same, and SARIF/Code Climate add rulesjscpd/renamed-codeandjscpd/similar-code. (#1019, #1021, #1030) - Tips are skipped when stdout is not a terminal — a pipe, file, CI log or agent hook no longer gets the tips/sponsor lines.
JSCPD_NO_TIPSjoinsCIas an environment switch;--no-tipsstays the explicit one. (#1008, #1029, thanks @7487) - MCP: fully described tool definitions — the four tools now carry a title, read-only annotations, parameter examples/defaults, and descriptions of when to use each and what it returns; names and schemas are unchanged. (#1028)
- Config-file
ignorePatternentries without*or?silently did nothing — treated as relative paths and joined onto the config directory, so a plain string matched nothing while the same value via--ignore-patternworked. Entries are now applied verbatim, and an invalid regex warns 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 it never paired with a well-formed file. Tokens now come from the lexer whenever the parser didn't fail outright; clone counts on such codebases change as a result. (#1023, #1024)
- Markdown inherited the C comment style — a
/*(a glob likedocs/**) or//(a URL) in prose opened a comment that swallowed the rest of the file. Markdown now has no comment syntax. (#1026, thanks @kwesolowski) - Vue template clones were reported with wrong ranges — wrapper tags and template body were appended to the html token stream out of source order. The stream is now in source order and wrapper tags are excluded from it, so a template clone gets the template's own range. See
fixtures/sfc-demo. (#1031, thanks @zero-stroke)
- Runnable demos under
fixtures/— every feature and fix above ships a demo directory whose README lists each command and its expected output; the same files feed the pull-request smoke scan. - Docs: ignore patterns and inline markers —
--ignore-pattern/ignorePatternand thejscpd:ignore-start/-endmarkers, with license-header recipes and a Rust regex note. (#993, #996, thanks @w3lld1) - GitHub Action inputs
ignore-identifiers,ignore-literals,ignore-annotations,max-gap-lines,similarityfor the features above.
- Add
regex1 to thejscpdcrate for--ignore-patternvalidation (#997) - Bump
taiki-e/install-actionfrom 2.87.2 to 2.87.3 in/.github/workflows(#995)
- @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)
- Linux ARM64 musl prebuilt binaries — npm installs on Alpine and other musl-based ARM64 Linux systems now get a native binary from
jscpd-linux-arm64-musl, bringing the prebuilt platform count to 8. The GitHub release ships the matching.tar.gz. (#988) cargo binstall jscpd— the crate now carriescargo-binstallmetadata 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 published with every release, taggedlatest,5,5.1and the exact version, with SLSA provenance and an SBOM. Run asdocker run --rm -v "$PWD:/src" ghcr.io/kucherenko/jscpd; see docs/ci-and-hooks.md. (#988)
jscpd --versionandjscpd --helpnow sayjscpd— both binaries share source and the command name was hardcoded tocpd, sojscpd --versionprintedcpd 5.1.1. The name now follows the invoked executable. (#988)- Windows: drive-anchored
--patternvalues are treated as absolute — the Windows check for patterns likeC:\src\**\*.tscompared the wrong character and could never match. 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 v5 rejects. The hook now passes--exit-code 1. (#989) - Unsupported-platform error is actionable — the npm launchers now name the host (
os/arch (libc)), list supported platforms, and point tocargo install jscpdinstead of a bare "Unsupported platform". (#988)
- Repository split:
masteris v5-only — the TypeScript v4 engine moved tomaster-v4, releasing under thelatest-4npm dist-tag.masterkeeps the Rust workspace,fixtures/, the Action, Dockerfile and flake.README-v4.mdcovers TypeScript 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. (#988) - crates.io metadata — every crate declares
repository,documentation,keywords,categories;jscpdexcludestests/from the package and ships an expanded docs.rs README; npm packages carryfunding. (#988) - Signed release assets — each archive and
checksums.txtnow has a Sigstore keyless signature, verifiable withcosign verify-blob. (#988) - CI — Windows joined the pull-request build matrix, a smoke test runs the release binary against
fixtures/on every push, and a nightly job runscargo audit/cargo deny. (#988, #989)
- Bump
quick-xmlto 0.42.0 in/rust(#991)
-
jscpdon npm installed the 5.0.16 engine instead of 5.1.0 — thejscpdwrapper'soptionalDependencieswere pinned to the5.0.16platform binaries, sonpm i jscpd@5.1.0resolved a binary one release behind. Everything 5.1.0 fixed was absent forjscpdusers, including the Windows--baseline-from-reffix; thecpdpackage and the platform packages were unaffected.Cause:
scripts/sync-version.mjsupdated the wrapper's version and its platform pins behind oneversion !== npmVersionguard, so once anything setversionfirst, the guard read "already up to date" and skipped the pins. The two now update independently, and the script verifies every npm version and platform pin matches the release version before finishing — this is the same defect that produced the 5.0.13 republish, this time covering thejscpdwrapper too.
- Declared MSRV corrected to 1.96 — the workspace advertised
rust-version = "1.87"on crates.io, a floor it couldn't actually build on (theoxccrates need 1.96.0). CI now derives the toolchain fromrust-versionand builds at exactly that version.
- Windows on ARM support — npm installs select a native
aarch64-pc-windows-msvcbinary fromjscpd-windows-arm64-msvc. - Clone baseline (
--baseline,--update-baseline,--fail-on-new-clones) — gate CI on new duplication only. A committed baseline file records content-hash fingerprints of accepted clones; clones absent from it are new, and--fail-on-new-clones[=N]exits 1 past N (default 0) — independent of--threshold, so legacy duplication is tolerated while regressions fail the build.--update-baselinerewrites the file and prints added/removed counts. New-clone info flows through the reporters:[NEW]markers in console,isNew/newClones/newDuplicatedLinesin JSON, levelerrorin SARIF, matching gauges in OpenMetrics. (#944) - Ephemeral baseline from a git ref (
--baseline-from-ref) — stateless PR gating without a committed file: checks the base ref out into a temporary worktree, scans it, and compares in memory. Costs a second scan where the committed file needs only one; fails with a clear hint when the ref is missing in a shallow checkout. (#944) - OpenMetrics reporter (
--reporters openmetrics) — writesjscpd-metrics.txt, ready for a GitLab CIartifacts:reports:metricsartifact. (#422) - CodeClimate / GitLab Code Quality reporter (
--reporters codeclimate, aliasgitlab) — writesgl-code-quality-report.jsonforartifacts:reports:codequality, with a deterministic per-fingerprint id so GitLab can tell new issues from old ones across runs. Severity isminor, escalating tomajorpast a baseline or--threshold. (#958) - Config discovery in
.config/— also checks.config/jscpd.jsonper the dot-config convention, between the root.jscpd.jsonandpackage.json'sjscpdkey. A root file still wins. (#979)
- Unknown
--formatvalues warn instead of silently matching nothing — a typo likecsused to scan 0 files and exit 0. Now prints a stderr warning naming the value and pointing to--list. (#964) - Nix flake builds again — the flake pinned a mutable manifest hash that broke on a Rust point release. Now pinned to the exact patch version. (#976)
- Windows:
--baseline-from-refno longer reports every clone as new — the format-suffix stripper mistook the drive colon in Windows verbatim paths for a format suffix, truncating source ids and breaking every fingerprint lookup silently. A colon before a separator is now read as structural; fingerprints are also line-ending agnostic (CR stripped before hashing).
- Glama MCP listing — a
glama.jsonmanifest and aDockerfilefor the stdio MCP server, used by Glama for its server listing. - Signed releases — SLSA provenance on release artifacts; piped downloads in workflows are pinned (OpenSSF Scorecard).
- 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
- @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)
- MCP server over stdio (
--mcp) —cpd --mcp /path/to/projectserves MCP on stdin/stdout, no port or network policy needed. The project is scanned once at startup and kept in memory, socheck_duplicationsnippet checks answer in milliseconds. Tools:check_duplication,get_file_clones,get_statistics,check_current_directory; all lists sorted biggest-first, capped by an optionallimit(default 100). Implements protocol2025-06-18, accepting older clients; standard detection options apply to snippet checks too. (#891) - Codebase summary (
--summary) — opt-in hotspot overview: top files/folders by tokens, lines, size, or a token-based complexity estimate, each with its duplication share.--summary-top <n>and--summary-by tokens|lines|size|complexitycontrol it. Renders in console, compactly inai, and as an additivesummaryJSON key (absent when off). (#934) - Isolated folder groups (
--skip-isolated) — skip duplication between monorepo folders owned by different teams (--skip-isolated "packages/team-a|packages/team-b,libs/a|libs/b"); duplication inside one folder or against shared code is still reported. Ports #628 to the Rust engine. (#942)
- Supply-chain hardening (OpenSSF Scorecard) — every Action pinned to a commit SHA, least-privilege workflow tokens, and a
SECURITY.mdwith private disclosure.
- GitHub "Latest" release badge stays on v5 — Rust v5 releases use
--latest; v4 andcpd v*releases opt out, so a v4 maintenance release can't steal the badge.
- npm package page polish — README links are absolute so they resolve on npmjs.com; description and keywords improved.
- Bump Rust toolchain to 1.97 and
oxccrates to 0.144 in/rust - Bump
serdeto 1.0.229,clapto 4.6.6,memchrto 2.8.3,xxhash-rustto 0.8.18 in/rust
- @hanzhangyu for proposing isolated folder groups and the original
skipIsolatedimplementation (#628), ported here to the Rust engine
- SARIF: size-based severity —
--sarif-error-tokens <N>: clones with at least N tokens report at levelerror, smaller ones staywarning; past--threshold, all results becomeerror. (#908) - SARIF: clone fingerprints — each result carries a
partialFingerprintsentry (jscpdCloneHash/v1), order-insensitive, for cross-run identity in tools like GitHub code scanning. (#909) - SARIF: related-location messages — the counterpart location gets a message and an embedded link so GitHub code scanning displays it. (#911)
- SARIF: richer rule metadata —
jscpd/duplicate-codenow has a display name, description and quality tags for SARIF viewers and Azure DevOps. (#914)
- Scan-root-relative report paths — fragments store their scan root separately, so paths are relative to the scanned directory again while reporters can still resolve source files. Fixes empty snippets and unresolvable paths on multi-root scans. (#872, #892)
- Report version stamping — SARIF and HTML report versions now match
cpd --version. (#915) - Multi-root blame attribution — git blame data is now keyed by resolved path, so a second scan root no longer inherits the first root's authors.
- Git root discovery — walking up from a relative scan path no longer stops short of the repository root.
- Bump
serde_jsonto 1.0.151,ignoreto 0.4.32,anyhowto 1.0.104 in/rust
This release was shaped by community contributions:
- @chrisc-onaorg for SARIF clone fingerprints (#910), related-location messages (#912), richer rule metadata (#914), plus reporting #909, #911, #915
- @darronz for the scan-root-relative paths fix (#913)
- @nvuillam for proposing size-based SARIF severity (#908)
--cross-formats— detect clones across related formats sharing one comparison pool, e.g."javascript,typescript"or thejs-tspreset. When a group mixes TS with JS, TS files compare with erasable types stripped, sofunction f(a: number): voidmatchesfunction f(a). Also configurable ascrossFormatsin.jscpd.json/package.json. (#810)
- Prose-only Markdown files are now analyzed — the tokenizer only extracted fenced code blocks, so
.mdfiles without fences produced zero tokens and were silently skipped. Prose is now tokenized too. (#883)
- Bump
regexto 1.13.1,globsetto 0.4.19,ignoreto 0.4.28,xxhash-rustto 0.8.16 in/rust
npm-only release: republished cpd so its optionalDependencies point at the 5.0.12 platform binaries (it still referenced 5.0.11's). No code changes; the version sync script now keeps these in sync automatically.
- Bump
askamato 0.16.0,logto 0.4.33,env_loggerto 0.11.11,rustc-hashto 2.1.3 in/rust
- Razor (.razor) support — new tokenizer parsing HTML content and Razor keyword blocks (thanks @chrisc-onaorg in #829)
- Fix
cargo teston Windows viaCARGO_BIN_EXE_cpd/.exesuffix to locate the test binary - Fix
cargo fmtformatting across the workspace
- Extract methods and optimize intervals in
cpd-core(bumped to 0.1.6)
cpd-coreto 0.1.6,cpd-tokenizerto 0.1.7 across dependent crates
- Emit scan-root-relative paths in all reporters by default.
jscpd /abs/pathfrom another CWD used to leave absolute paths in every report, and Windows/macOS canonicalization could leave\\?\or./prefixes. Paths now normalize against the canonicalized scan root and strip any leading./. Fixes #827 - Fix
--skip-localto match v4 semantics: filters clones where both fragments share a scan root, not just a parent directory
- DRY duplication in reporters: shared helpers moved into
cpd-reporter/src/shared.rs, reused by console, console-full, CSV, JSON, HTML, Markdown, silent, XML and SARIF — cutting the monorepo's own reported duplication from 5.0% to 0.56% and fixing a latent--absolutebug along the way - Move blame enrichment from
gitoxidetogit blame --porcelain - Resolve
needless_borrowclippy warnings in CSV and Markdown reporters
- Add Nix and Homebrew install instructions. #818
- Point project homepage URLs at
https://jscpd.dev, add curl install instructions, clean up outdated badges - Remove defunct Universal Analytics tracking pixels from READMEs
- GitHub Action for jscpd (Rust v5) on the Actions Marketplace:
uses: kucherenko/jscpd/.github/workflows/action.yml@v5
- Resolve platform binary resolution when
cpdis installed as a nested dependency. Fixes #816
- Prevent mmap exhaustion when scanning repos with more files than
vm.max_map_count(default 131,072 on Linux): each worker now opens and drops its mapping within its own closure instead of holding one live per file. Fixes #813 - Fix
--patternnot matching relative paths when the scan root is absolute. Fixes #811 - Fix trailing-newline off-by-one in the line-count filter
- Prevent stack overflow on deeply-nested JS/TS files (e.g. Bun's
test/bundler): OXC's recursive-descent parser allocates one stack frame per nesting level, exceeding the default 8 MiB thread stack. Fixed with a local 64 MiB-stack thread pool - Default
--max-sizeto1mb, matching v4, so OXC never sees megabyte-scale generated files --workers Nnow takes effect on everyrun()call, not just the first
- v4 config backward compatibility for
.jscpd.jsonfieldspath,pattern,ignore,ignorePattern ignoreandignorePatternare now distinct: file-level globs vs. code-level regex (previously conflated).jscpd.jsonpathfield support, resolved against the config file's directoryjscpdnpm wrapper package, publishing the same binary under a second name--exit-codenow matches v4: optional integer value, independent of--threshold- Performance: memory-mapped file I/O, SIMD line counting,
flat_mapdetection pipeline, no source-string cloning in the JS tokenizer (thanks @auterium, #808)
- Fixed
--exit-codeto match v4's optional-integer behavior - Fixed unique temp dir generation in reporter tests under parallel runners
- CLI alignment with v4:
--absolute,--ignore-case,--formats-exts,--formats-names; fixed--threshold, improved--max-size - Detection and statistics aligned with jscpd v4
- Side-by-side blame comparison in console-full
- Clone list display in console
- HTML reporter now outputs
jscpd-report.htmlat theoutput_dirroot - Resolved all clippy warnings across the workspace
- Fixed unique temp dir generation in tests
- Rust-based cpd CLI with full feature parity to TypeScript jscpd
- Cross-platform npm binaries (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, CLI short-form aliases, ReportContext data structure, crates.io Trusted Publishing via OIDC
- 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
- Initial Rust workspace with cpd-core, cpd-tokenizer, cpd-finder, cpd-reporter, jscpd crates
- Cross-format detection for Vue SFC, Svelte, Astro, Markdown
- Shebang detection for extensionless scripts
- First stable Rust release — replaces the TypeScript CLI with a native binary
- Reporter trait signature changed to use ReportContext instead of Statistics directly