Single-file component support (Vue/Svelte/Astro), bundler alias
resolution (vite.config, svelte.config, tsconfig paths) and monorepo
workspace package-name resolution landed on master after basta 0.1.1
was tagged (#1062); the published crate and npm packages don't have
them yet.
- Console tables (project/duplication-by-format/complexity/dead-code) and
the "largest:" summary line interpolated file paths and format names
raw: the Markdown escaping fix in the previous commit only covered
Markdown, not console. A newline in a path or a custom --formats-exts
name forged an extra table row with none of the other columns' data.
Sanitized at table()'s one choke point and at the "largest:" line.
- format_list() (the --health console/ai duplication line) had the same
gap for the formats it lists, independent of the console table fix.
- --complexity --fail-on-empty returned before the reporter loop ran, so
-r json never wrote a report; the clone-detection and dead-code paths
write reports first and apply the gate to the exit code afterward.
--complexity now does the same.
- dead_code::config()'s early Ok(None) for an unsupported --format
returned before parsing --dead-code-categories or clamping
--min-confidence, so --dashboard --format css --dead-code-categories
bogus silently succeeded despite the comment above it saying that must
stay a refusal. Moved both checks before the format check.
8 new tests.
Two independent reviews of the health/dashboard work landing since v5.2.1,
before cutting 5.3.0.
Scoring:
- Markup lines stayed in the duplication denominator while excluded from
the numerator, so adding one unique, un-duplicated HTML file could swing
a project from grade D to grade B. The denominator now excludes markup
too, and an all-markup project skips the dimension entirely instead of
scoring ~75 from the small-project prior alone.
- is_markup() matched file extensions (plantuml, puml, tpl) instead of the
tokenizer's actual format names (plant-uml, smarty), so those formats
were never really excluded.
- A health-input weight of 1e308 overflowed the weighted geometric mean to
NaN/100 with exit 0; validate() now rejects non-finite or unreasonably
large weights.
- External metric ids could duplicate each other or collide with a
built-in dimension id.
Output safety:
- Markdown output (dimension ids, format names, file paths, dead-code
finding names) was interpolated unescaped: a hostile path or
--health-input id could break a table or inject raw HTML/a heading
(GitHub renders HTML embedded in Markdown), or split console/ai output
via an embedded newline.
CLI:
- A config file with a known field of the wrong JSON type (e.g. "entry":
"src/index.js" where an array is expected) discarded the entire config,
--threshold included, instead of just that field — on any
auto-discovered .jscpd.json, no new flags required.
- --dashboard --format css hard-errored instead of dropping the dead-code
section.
- --dead-code/--complexity didn't conflict with --mcp.
- --complexity silently ignored --threshold, --exit-code,
--fail-on-empty, the baseline family, --history and --kind.
- --dead-code --min-confidence 200 silently reported nothing instead of
clamping to 100, unlike the standalone basta binary.
- --dashboard --max-lines let the dead-code section silently cover files
the other sections skipped (basta needs whole files for its import
graph).
- duplication.percentage/deadCode.percentage in dashboard JSON carried raw
float division noise instead of the two decimals the console already
shows.
26 new tests. Full default-path parity re-verified against the released
5.2.1 binary (13 reporters, option matrix) — byte-identical except timing.
display_source_path() returned OS-native separators via to_string_lossy(),
so a summary path (dashboard's complexity/duplication sections) read
"src\route.js" on Windows instead of "src/route.js" everywhere else — the
convention this codebase already follows in mcp.rs, ai.rs and
summary.rs's parent_dir(). Fixes the Windows CI failure in
dashboard_has_the_health_badge_and_a_json_report, pre-existing before
this branch's dashboard/health work.
- Console health badge: reverse-video grade chip, eighth-block gauges per
dimension, one row per sub-score with what it was measured from.
- markdown/html reporters for --dashboard and --health, alongside the
existing console/json/badge; the health SVG badge moves into badge.rs.
- --dashboard's Duplication section lists a breakdown by format instead of
by file, sourced from the same jscpd statistics as everywhere else.
- Duplication no longer counts markup, stylesheet and template clones
(HTML, CSS, Handlebars, ...): a repeating style rule is not the
maintenance problem repeating programming logic is. The exclusion
follows the clone's own format, not the file it lives in, so a .svelte
or .vue file's style/template block is left out even though the
component itself is full weight.
- The duplication line and format-breakdown table now name what they
actually measured and, only for categories the project has files in,
what was left out (e.g. "5.4% in typescript (no text)") instead of a
fixed disclaimer.
One 0-100 score with an A-E grade, shown on top of --dashboard and alone
with --health.
- Dimensions are shares of the code lines, so size cancels out:
duplication (jscpd's percentage over code files), dead code, and the
share of code in complex files (complexity 50+). Prose and data files
are not counted.
- Each share is scored on a half-life curve (100 at zero, 50 at one
half-life), calibrated on 42 open-source projects so the median project
scores 75 per dimension; shares are mixed with a 2000-line prior so one
finding does not sink a small project.
- Sub-scores combine as a weighted geometric mean. A dimension that
cannot be measured is left out and named, and dead code weighs as much
as the share of the code it could read.
- --health-input FILE and the health config object add metrics from
other tools (a ready score, or a value with a half-life) and tune the
built-in half-lives and weights.
- Reporters: console badge, ai one-liner, json (jscpd-health.json,
jscpd-dashboard.json with every dashboard section) and badge
(jscpd-health-badge.svg). Exit gates apply as in the dashboard.
CpdClone::matched_lines moves to cpd-core so statistics and health count
duplicated lines the same way.
- --dashboard applies the exit gates of a clone run (--threshold,
--exit-code, --fail-on-empty) instead of always returning 0; the
baseline family warns and --fail-on-new-clones is refused, since no
clone report is written.
- A refused dead-code configuration (bad --dead-code-categories, a
--format no analyzer supports) now exits 1 as in --dead-code, rather
than printing a dashboard with an empty dead-code section.
- --workers is a budget for the whole dashboard run: the two concurrent
scans get half each, so neither the thread count nor the 64 MiB
tokenizer stacks double. --workers 1 runs them sequentially.
- --complexity scans with similarity disabled, so --similarity no longer
pays for function extraction it cannot use.
- --complexity honours a summaryBy from the config file, not only the
--summary-by flag (Options::summary_by_set).
- reStructuredText joins the prose formats at complexity 0.
--dashboard prints one screen: project size and largest formats,
duplication with clones per kind and the most duplicated files, total and
mean complexity with the most complex files, and dead code by category
with the largest findings (JavaScript, TypeScript, Python). The dead-code
scan runs beside clone detection; --summary-top sets the rows per list.
Data formats (JSON, YAML, TOML, CSV, ...) join markdown at complexity 0,
so a lock file's version ranges no longer top the complexity ranking.
Docs for --kind, --complexity and --dashboard; fixtures/dashboard-demo,
plus --kind and --complexity sections in the type3 and summary demos.
--kind exact,renamed,similar,gap,ast keeps only the clones of those kinds
(config key "kind"). The filter runs before statistics, so percentages
describe what is reported. An unknown kind is an error; a kind whose
detector is off (ast without --similarity, ...) is a warning, and
detection is never switched on implicitly.
--complexity walks and tokenizes like a normal run, skips clone
detection, and prints the --summary tables ranked by complexity without
the DUP% column (console, ai; json writes jscpd-complexity.json).
Markdown prose now has complexity 0: "if" in a README is a word.
Folding the symlink test assertions into a helper put it between
#[cfg(unix)] and symlink_layout, so the attribute moved to the helper and
std::os::unix::fs::symlink no longer compiled on Windows.
b72abf09 also searched the directory of each scanned path. fixtures/ has
a .jscpd.json with "silent": true, so `cd rust && jscpd ../fixtures -r ai`
started printing only the summary line instead of the clones that
`npx jscpd` lists. Config discovery is back to the working directory;
output now matches jscpd 5.2.1 across all reporters.
- FinderError was never constructed; run() cannot fail, so its error type
is Infallible and the documented run(&config).unwrap() keeps compiling.
- tokenize_sfc_block_inner and tokenize_to_detection_inner forwarded to
tokenize_format_to_detection; call it directly.
- extract_line_number was Some(error.line()).
- Timer wrapped Instant; use Instant.
serde, anyhow, thiserror and log from cpd-finder; thiserror from
cpd-reporter; xxhash-rust and dev serde_json from cpd-tokenizer; anyhow,
log and env_logger from cpd. None of them is referenced in code.
`main` was the most complex function in the workspace: one 437-line body
with a cyclomatic complexity of 84 and thirteen `process::exit` calls,
running config loading, flag validation, detection, baselines, blame,
history, reporter planning, output and exit codes in sequence.
It is now a short dispatch over functions that each do one of those
things: `load_options`, `check_format_names`, `validate_baseline_flags`,
`scan_paths`, `run_config`, `detect_and_report`, `display_paths`,
`apply_baseline`, `blame`, `history`, `plan_reporters`, `run_reporters`,
`print_time_and_tips` and `exit_status`. A run that ends early returns
`Err(Exit(code))`, and only `main` exits the process. The largest function
left has a complexity of 15.
Reporter planning is a pure function now, and has tests. One of them pins
the rule that took care to carry over: a `--reporters silent` run prints no
timing line, but adding `--threshold` adds a reporter that prints.
Behaviour is unchanged. The previous and the new binary agree on stdout,
stderr, exit code and written report across 28 invocations covering --list,
--debug, every warning and validation error, a missing path and config,
--silent and --threshold in combination, unknown reporters, --exit-code
with and without clones, empty scans with and without --fail-on-empty,
--absolute, --summary, --baseline with --update-baseline, and --blame; and
the fixtures corpus reports in every mode are byte-identical.
`jscpd rust/` reported 48 clones. This folds every one that was repetition
rather than a deliberate copy, leaving 34 across `rust/` and 14 in
`rust/crates`, all of the kinds the #1053 pass kept on purpose: fixture
corpora, `use` blocks, the TypeScript file and its JavaScript twin,
`impl Reporter` headers and the `ts_strip` visitor pair.
- basta's JSONC reader copied a string-skipping loop into both `strip_jsonc`
and `drop_trailing_commas`. It is now `strip_comments` followed by a comma
pass over `lang::outside_strings`, the quote-aware scanning the bundler
config readers already share.
- The JavaScript and Python analyzer tests each built their own module
index, import lookup, binding-reference lookup and member-read list. They
live once in `test_scan` as `index_of`, `import`, `binding_reference` and
`member_reads`.
- `test_scan::TempTree` writes a test's files and removes them when dropped,
so a failing assertion no longer leaves its directory behind; the script
and `extends` tests that repeated the setup use it.
- Four tsconfig `paths` tests with one shape are one table-driven test.
- The CLI integration tests share `duplicate_shapes`, `history_stdout` and
`assert_only_the_real_file_was_scanned`.
- `scripts/publish-npm.sh` repeated a 43-line "publish the platform package,
then the main packages under --force-main" tail in two branches, and the
"publish unless already live, or show the dry run" block five times. They
are `publish_platform_target` and `publish_package`. Run against the old
script with the build, the registry check and `npm` stubbed, all eleven
combinations of --target, --all, --force-main, --dry-run, --provenance, a
failed build and missing platforms print the same lines and exit the same.
Behaviour is unchanged: the fixtures corpus reports in every mode, the
summary, the console output and the dead-code demo are byte-identical.
basta reads JavaScript, TypeScript and Python, so `jscpd --dead-code` has no
view of the Rust crates, and the compiler does not warn about an unused `pub`
item. Every crate in the workspace is `publish = false`, though, so a `pub`
item nothing in the workspace uses is dead as surely as a private one. A scan
of every `pub` item's uses across the crates, their tests and the fuzz
targets, checked by hand, found:
- `CloneKind::is_exact`: used nowhere.
- `ConfigResult::has_diagnostics`, `basta::lang::language_of`,
`TokenizeOptions::normalizes` and `TokenizeOptions::with_code_ignore_patterns`:
called only by tests written for them. The language test now asks
`analyzer_for` directly.
- `stats_with_pct` is a reporter test fixture that was compiled into the
library; it moves inside the `#[cfg(test)]` fixtures module that re-exported it.
- Fields kept alive by `#[allow(dead_code)]`: `MarkdownFence::front_matter`
and `SfcBlock::tag` are written and never read, and `Options::list` is never
read because `main` reads `cli.list`.
- `scripts/regression_check.sh` and its 1528-line `tests/golden.json`: a gate
for the Wave 2/3 migration that nothing runs, against a report schema that
has since changed.
Behaviour is unchanged: the JSON reports for the fixtures corpus in mild, weak
and strict modes, with Type-2 normalization, and with the complexity summary,
the console output and the dead-code demo output are byte-identical before and
after.
Two failures on the branch, neither on macOS, which is why they surfaced
only in CI.
`cargo deny` rejected `unicode_names2`, which `ruff_python_parser` pulls in
and which carries `Unicode-DFS-2016` alongside its MIT/Apache dual licence.
That licence covers the character tables and is the older wording of
`Unicode-3.0`, already on the allow list; added beside it.
The two new config-discovery tests asserted on `../project/.jscpd.json`,
and Windows prints the separator it joined with, so both failed there.
They now normalise backslashes before comparing — the assertion is about
which file was read, not about how the path is spelled.
Claude-Session: https://claude.ai/code/session_01WztdFKZAwW9b51iJe9zhtV
`cargo publish` verifies by building the packaged crate against the
*published* versions of its dependencies, not the local paths. basta uses
`cpd_core::deadcode`, `cpd_reporter::deadcode` and a public `LineIndex` —
none of which exist in the versions on crates.io — so every attempt failed
with "failed to verify package tarball", and basta could not reach crates.io
until a jscpd release happened to carry those three crates along.
That is the coupling this workflow exists to avoid. It now walks basta's own
dependency graph, publishes in topological order whatever is missing, and
waits for the index between crates. jscpd is excluded by construction: it is
a sibling, not a dependency, and releasing it is not this workflow's job.
Bumps the three crates that actually changed: cpd-core 0.1.13 -> 0.1.14
(the `deadcode` module), cpd-tokenizer 0.1.15 -> 0.1.16 (`LineIndex` made
public, `.pyi`), cpd-reporter 0.1.14 -> 0.1.15 (the dead-code reporters).
cpd-finder is untouched and stays where it is.
Claude-Session: https://claude.ai/code/session_01WztdFKZAwW9b51iJe9zhtV
`basta@0.1.0` published with `files: ["run-basta.js", "platform-map.js"]`,
so npmjs.com/package/basta shows an empty page — the one place most people
will look before installing anything. npm takes the README from the
published tarball, so a version bump is the only way to put one there.
The README is npm-facing rather than a copy of the crate's: it leads with
`npm install -g basta` and `npx basta src`, and it names the known gaps —
no Vue/Svelte/Astro, class members matched by name, PEP 420 namespace
packages — so a reader can judge whether the tool fits before running it.
Claude-Session: https://claude.ai/code/session_01WztdFKZAwW9b51iJe9zhtV
`cd rust && jscpd --basta ..` silently ignored the repository's own
`.jscpd.json`: config discovery only ever tried `.jscpd.json`,
`.config/jscpd.json` and `package.json` relative to the working
directory. The configuration is a property of the code being scanned,
not of where the shell happens to be, so a scan aimed at a project read
none of its ignores, formats or thresholds.
The cwd is still searched first and still wins, so nothing about an
existing invocation changes; each scanned path's directory is searched
after it, and a path that is also the cwd is not searched twice. A file
argument contributes the directory holding it. `ConfigSource` now
carries the path it was read from, so the "Using config from" line names
the file that was actually used rather than assuming it was the local one.
Claude-Session: https://claude.ai/code/session_01WztdFKZAwW9b51iJe9zhtV
Adds a second question about the same tree: not "what is duplicated" but
"what does nothing reach". A new `basta` crate 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 and Python.
It ships two ways: `jscpd --dead-code` inside the existing binary, and a
standalone `basta` command. Both reuse the walker, the filters, all fifteen
reporters and the `--threshold` / `--exit-code` gates, so nothing new has to
be learned to run it.
Because it is a graph traversal rather than a reference count, dead code
cascades: a helper whose only caller is itself dead is reported too. Every
finding carries a confidence score and, below 100, the reasons it might be
wrong — `eval`, an unrecognised decorator, a wildcard re-export, a name seen
only in a string, a file in the scan that did not parse. `--min-confidence`
sets the floor at 60.
Entry points come from `package.json`, `pyproject.toml`, framework and
file-name conventions, and anything a shell script, CI workflow, Makefile or
Dockerfile names by path. CommonJS is read alongside ESM. A project that
renames its own import paths is read on its own terms: `paths` and `baseUrl`
from `tsconfig.json` and `jsconfig.json` are resolved, through a relative
`extends` chain and per package in a monorepo. A file named only by a path
string — a worker spawned through `new URL`, a build entry, a vitest setup
file — counts as used.
Languages plug in through one trait, `basta::lang::Analyzer`, which owns
parsing, specifier resolution, entry-point conventions, manifests, alias
configs and path traits. Nothing outside `lang/` is language-specific;
`docs/basta-extending.md` walks through adding one.
Validated against knip, fallow, vulture, skylos, fossil-mcp and repowise on
trending repositories. On a corpus of ten of them — 11k files, 3.3M lines —
every one of basta's 1,806 findings was checked against an import graph
rebuilt independently of it: no unused-file, unused-import or unused-symbol
claim survived as wrong.
Claude-Session: https://claude.ai/code/session_01WztdFKZAwW9b51iJe9zhtV
With `--follow-symlinks` the source id was the canonical path, so a file
reached through a link showed up as an absolute path outside the scan
root, `--ignore` (matched on the walked path) and the report disagreed
about its name, and a file reachable through two paths was two sources
(a file symlink next to its target even became a clone of itself).
The walked path, anchored at the canonical scan root, is now the id:
what reports show, what `--ignore` matches, what `--absolute` prints.
The canonical path travels alongside (`DiscoveredFile::real_path`,
`PreparedSource::real_path`) for reading the file and for the
`--skip-local` / `--skip-isolated` prefix checks. When links are
followed, files whose canonical path was already seen are dropped; the
entry that is the real file wins, else the first walked path.
Docs: `--follow-symlinks` row in the option table, the v4 default in the
migration table and README. Fixture `fixtures/follow-symlinks-demo`.
Closes#1059
Claude-Session: https://claude.ai/code/session_01WztdFKZAwW9b51iJe9zhtV
print_appendices gave console-full the history block it lacked; the
History section now names both console reporters and an integration
test checks console-full renders it.
Claude-Session: https://claude.ai/code/session_01WztdFKZAwW9b51iJe9zhtV
Phase 3 of the DRY pass: report_result / report_to_file_timed fixtures
for the stdout and time-printing reporters, rule_ids and
first_result_properties in the sarif tests, cross_formats_with in the
cli tests, run_isolating in the skip-isolated finder tests, and a
tests/common module for the cpd crate holding the invoice/credit-note
snippets the CLI and MCP tests both used.
rust/crates: 12 clones / 0.4% left, all deliberate (fixture corpora,
use blocks, a TS file and its JS twin, impl headers, AST visitors).
971 workspace tests pass, clippy clean.
Claude-Session: https://claude.ai/code/session_01WztdFKZAwW9b51iJe9zhtV
Phase 2 of the DRY pass. cpd/tests/integration.rs was 23% duplicated:
every reporter test rebuilt the same temp root, wrote the same greet
pair, ran cpd with the same assertion block and parsed the report by
hand. Now: GREET_DUP / greet_root / write_greet_pair, run_ok_in,
read_json, scan_json (json+silent scans returning the parsed report and
stderr), run_scratch (exit code + stderr for a scratch dir), config_dir
/ stderr_in_dir for config discovery, commit_dates for --history and
baseline_root_with_pair for the baseline suites. mcp_stdio.rs gains
spawn_mcp / drive and INITIALIZE / INITIALIZED constants; cpd-finder's
five integration tests share tests/common (temp_dir, duplicate_js,
fixtures); mcp.rs unit tests share scratch_dir.
rust/crates: 69 clones / 2.4% before the pass, 21 / 0.7% after.
971 workspace tests pass, clippy clean.
Claude-Session: https://claude.ai/code/session_01WztdFKZAwW9b51iJe9zhtV
Closes#1002 (the --history half; --ratchet is deliberately not
implemented, the report shows the headroom instead).
`jscpd src --history v5.0.0..HEAD` (or `--history-since 2026-01-01`)
lists the commits with `git log`, checks each one out into a temporary
detached worktree (the --baseline-from-ref machinery, now shared through
baseline_ref::map_scan_paths), scans it with the run's own configuration
and keeps the totals; the current run is the series' last point.
`--history-every N` keeps every Nth commit counted from the newest,
`--history-limit N` (default 30) samples a long series evenly keeping
both ends. Nothing runs unless one of the flags is given.
Output: the console reporter appends a sparkline of the duplication
percentage, a table (commit, date, files, lines, clones, dup lines,
dup%, change, subject) with the change column red when duplication rose
and green when it fell, the overall trend line, and, when --threshold is
set and the latest value is under it, the headroom with the value the
threshold could be tightened to. The json reporter adds a `history`
object; the ai reporter prints one compact line per point. Other
reporters are unchanged.
New cpd_core::history (model, sparkline, change/headroom helpers with
unit tests), cpd::history (git log parsing, sampling, worktree scans),
cpd_reporter::history_render. Config keys history / historySince /
historyEvery / historyLimit; action input `history`. Integration tests
build a four-commit repo with fixed dates and check the JSON series,
the console block, every/limit thinning and the error paths. 969
workspace tests pass, clippy clean.
Docs: History section and option rows in docs/rust.md, README bullet,
action input row, fixtures/history-demo/README.md with a script that
builds the demo repository and the expected output.
Claude-Session: https://claude.ai/code/session_01WztdFKZAwW9b51iJe9zhtV
Closes#1047.
Four situations used to write an empty report and exit 0, so a CI job
that checks only the exit code saw a green run:
- `--format` naming a format that is not supported (a warning since #964)
is now `Error: --format: 'x' is not a supported format`, exit 1. Names
introduced through --formats-exts or --formats-names stay accepted.
- A scan path that does not exist is `Error: path does not exist: <p>`,
exit 1, before anything runs.
- A reporter that fails to write its output still prints the existing
`Reporter '<name>' error: ...` line; the run now ends with
`ERROR: a reporter failed to write its output` and exit 1, after the
other reporters have run.
- A scan that analyzes no files (paths exist, but nothing matched the
--format/--ignore/--pattern filters or every file was below
--min-tokens) prints `Warning: jscpd analyzed no files` and still
exits 0, so an intentionally empty tree passes. The new
`--fail-on-empty` flag (config key `failOnEmpty`, action input
`fail-on-empty`) makes it exit 1. Reports are written before the check.
Docs: an "Exit codes" section in docs/rust.md, option rows in
docs/rust.md and docs/ci-and-hooks.md, a README feature bullet.
Fixtures: fixtures/fail-on-empty-demo with a `code/` clone pair, a
`nothing/` comment-only file, and a README quoting every command's
output and exit code. Tests: the #964 warning test becomes an error
test, plus integration tests for each new exit path and for the config
key; 531 tests pass, clippy clean.
Claude-Session: https://claude.ai/code/session_01WztdFKZAwW9b51iJe9zhtV
NO_COLOR asks for plain output and nothing else, and many people export
it globally while still working in an interactive terminal. Treating it
as a tips switch would hide the tips from them for good. The non-TTY
check at the print site already covers pipes, files and CI logs, so the
environment switches stay CI and JSCPD_NO_TIPS.
Help text, docs and the skill table updated; the options test asserts
that NO_COLOR does not enable no_tips.
Claude-Session: https://claude.ai/code/session_01WztdFKZAwW9b51iJe9zhtV
Print the tips/sponsor lines only when stdout is a terminal, so CI logs,
pipes and agent hooks no longer get them without --no-tips. Also honor
NO_COLOR and JSCPD_NO_TIPS next to the existing CI env check.
Fixes#1008
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Each tool now states when to use it, what it returns (top-level keys and
per-item fields), its thresholds and limits, and that it is read-only,
with a title, MCP annotations (readOnlyHint, destructiveHint,
idempotentHint, openWorldHint), examples and defaults on parameters, and
additionalProperties: false where no input is taken. The server
instructions describe the intended workflow across the four tools. A unit
test guards title, annotations and parameter descriptions.
Motivation: Glama's quality score weighs purpose clarity, usage guidance,
behavioural transparency and parameter semantics per tool, plus
disambiguation across tools; agents read the same text.
A JavaScript/TypeScript file with any parse diagnostic (a redeclaration,
a recoverable syntax error) was handed to the word-split fallback
tokenizer. Its tokens then never matched an oxc-tokenized file, so every
cross-file clone involving it vanished while its self-clones survived —
the symptom reported in #1023, where a second copy of a function in one
file made all a↔b clones disappear.
Tokens come from the lexer, not the parser: only `panicked` (or an empty
token stream) now triggers the fallback. Function extraction for
--similarity uses the partial AST on recoverable errors the same way.
Closes#1023.
Function extraction is now a language plug-in: `FunctionExtractor`
(grammar id, served formats, extract) with a static `EXTRACTORS` registry
consulted by `extract_functions` / `supports_functions` /
`supported_function_formats`. `OxcExtractor` is the first entry (JS/TS/
JSX/TSX). Every RawFunction/FunctionSig records its grammar and the scorer
refuses pairs across grammars, so a tree-sitter-backed extractor for
another language is a self-contained addition with no change to scoring,
CLI, MCP or reporters. Module docs describe the two steps; the MCP note
lists supported formats from the registry.
Off by default. `--similarity RATIO` (config `similarity`, Action input
`similarity`, MCP `check_duplication` argument) summarizes every function,
method and arrow function of a JavaScript/TypeScript/JSX/TSX file by the
bag of 4-grams over the pre-order sequence of its AST node types (oxc),
indexes the bags with MinHash + LSH banding, and reports pairs whose
weighted Jaccard index reaches RATIO as `similar` clones with a
`similarity` value. Names and literal values are not part of the summary:
a renamed copy scores 1.0, one inserted line ~0.9, two inserted statements
plus renames ~0.75.
Functions must clear --min-tokens/--min-lines, nested functions are never
paired with their parent, and pairs already covered by an exact, renamed
or merged clone are skipped. Values outside (0, 1] warn and disable the
option. Reporting reuses the Type-3 vocabulary (console `similar ~0.75`,
JSON kind/similarity, SARIF jscpd/near-miss-code).
New: cpd-core::similarity (FunctionSig, SimilarityIndex, bag_jaccard,
minhash), cpd-tokenizer::functions (oxc visitor extractor), MCP function
index built lazily per scan. fixtures/type3-demo/similar-functions with
README numbers, docs, unit + integration + MCP stdio tests. Also adds the
missing max_gap_lines/similarity fields to the --debug config output.
Off by default (0). When set to N, clones of the same file pair whose
fragments follow each other in both files with at most N unmatched lines
between them are merged into one clone of kind `similar`, with `tokens` =
matched tokens and `similarity` = matched tokens over the tokens of the
longer merged span. Adjacent exact windows that share a boundary token are
counted once; chains merge transitively; overlapping or reordered fragments
never merge. The pass runs after every other filter and returns its input
untouched at 0, so default output is unchanged.
Reporting: console `Clone found (fmt, similar ~0.91)`, ai `[~0.91]`, JSON
"kind"/"similarity" (rounded to three decimals), SARIF rule
jscpd/near-miss-code with a similarity property (declared only when
referenced), Code Climate check_name.
Config key maxGapLines (kebab alias), GitHub Action input max-gap-lines,
docs section, fixtures/type3-demo with a README of runnable commands, unit
tests for the merge pass and reporters, and a binary-level integration test.
Closes#998.
Three opt-in flags (config keys ignoreIdentifiers, ignoreLiterals,
ignoreAnnotations) normalize token classes before hashing so that blocks
which differ only in names, values or annotations are detected:
- identifiers hash as `$id`; keywords are kept (the JS/TS tokenizer
classifies them, other languages use a built-in common-keyword list)
- string literals hash as `$str`, numeric literals as `$num`
- `@Name`, `@a.b.Name` and `@Name(...)` runs are dropped in Java, Kotlin,
Scala, Groovy, Python, Dart, Swift, JavaScript and TypeScript
Each clone now carries `kind: exact | renamed`. DetectionToken gains a
raw_hash (equal to hash unless normalization rewrote the token); the
PreparedSource materializes raw hashes only when a token was normalized,
and the detector compares raw hashes over the matched run to classify a
pair. A dropped annotation salts the raw hash of the token before it, so a
pair differing only in annotations inside the matched run is `renamed`.
Reporters: console prints `Clone found (fmt, renamed)`, ai appends
`(renamed)`, json adds "kind", sarif uses rule jscpd/similar-code (declared
only when referenced), codeclimate the same check_name. Default runs are
unchanged apart from the additive "kind": "exact" JSON field.
Also: GitHub Action inputs, docs section, fixtures/type2-demo with a README
of runnable commands, unit tests in tokenizer/core/reporter, CLI flag
tests, and a binary-level integration test.
`resolve_config_paths` treated `ignorePattern` entries without `*` or `?`
as relative paths and joined them onto the config directory, a leftover
from when `ignore` and `ignorePattern` were conflated. A plain regex such
as `"Copyright 2026 Example Authors"` in `.jscpd.json` therefore matched
nothing, while the same string via `--ignore-pattern` worked. Config
entries are now passed through untouched.
Regexes that fail to compile were also dropped silently by the tokenizer;
the CLI now prints a `Warning: --ignore-pattern: invalid regex ...` line
for each one so a typo no longer disables a pattern without feedback.
Adds integration tests for both behaviors.
First release from the v5-only master. Bumps jscpd to 5.1.2 and every
sub-crate (cpd-core 0.1.11, cpd-tokenizer 0.1.13, cpd-finder 0.1.14,
cpd-reporter 0.1.12) so the new crates.io metadata from #988 and the
Windows drive-path fix in cpd-finder are republished. Adds the 5.1.2
changelog section (linux-arm64-musl package, cargo-binstall, Docker
image, jscpd --version naming, pre-commit hook args, repository split)
and refreshes the version examples in docs/.
Pre-release checks: cargo fmt, clippy -D warnings, cargo test --locked
(22 suites), cargo check --all-targets --target x86_64-pc-windows-msvc,
release-notes extraction dry-run against rust/CHANGELOG.md.
Rust / crates.io
- report the invoked binary name in --version/--help (jscpd vs cpd)
- add repository, documentation, keywords, categories to all crates
- exclude tests/ from the jscpd crate package; add cargo-binstall metadata
- expand the crates.io README and render it on docs.rs
npm
- add funding field to jscpd, cpd and jscpd@4 packages
- add jscpd-linux-arm64-musl platform package and clearer unsupported-platform error
- enable provenance for the v4 changeset publish
Pipelines
- rust.yml: windows-latest in the matrix, drop dead schedule guard
- release.yml: cosign-sign release assets, floating v5 tag, LICENSE in tarballs,
invoke the new docker.yml (GHCR multi-arch image) after each release
- audit.yml: nightly cargo audit, cargo deny and pnpm audit
- Dockerfile: multi-stage image from release binaries (distroless), mcp target for Glama
Docs / community
- README: shields badges, single primary install per OS, GitHub Action snippet,
link to jscpd.dev, v5.1 features, 15 reporters, 224 formats, 8 platforms
- docs: @v5 action tag, Docker usage, baseline section, current crate versions
- YAML issue forms with engine selector, config.yml, CODEOWNERS
- remove stale .github/CONTRIBUTING.md stub, custom.md, TODO.md; untrack rust/jscpd-report.json
- examples: mark as v4 API, add a Rust cpd-finder example
Republishes the jscpd npm wrapper with platform pins that match the
release. jscpd@5.1.0 shipped optionalDependencies pinned to the 5.0.16
binaries, so `npm i jscpd@5.1.0` resolved an engine one release behind
and `jscpd --version` printed `cpd 5.0.16` — every 5.1.0 fix, including
the Windows --baseline-from-ref fix, was absent for jscpd users. The cpd
package, the platform packages and the crates.io release were all correct;
only the wrapper's pins were stale.
Root cause in scripts/sync-version.mjs: the wrapper's version and its
platform pins were written behind one `version !== npmVersion` guard, so
once anything set version before the script ran, the guard read "already
up to date" and skipped the pins. Version and pins are now updated
independently, and the script ends by verifying that every npm version and
platform pin equals the release version, exiting non-zero otherwise.
release.yml runs this script, so the same mismatch now fails the release
rather than publishing. This is the 5.0.13 defect again — that fix covered
the cpd package but not the jscpd wrapper.
Sub-crates bumped for the code that changed since the published 5.1.0
(cpd-tokenizer 0.1.12, cpd-finder 0.1.13, cpd-reporter 0.1.11); cpd-core
is unchanged at 0.1.10. The only source delta is the clippy collapsible_if
rewrites unmasked by the MSRV correction, which are behaviour-preserving.
Pre-release verification: cargo fmt --check, clippy --workspace
--all-targets -D warnings, 847 tests, cargo +1.96 check at the declared
MSRV, and — per the Windows-only blind spot that blocked 5.0.15, 5.0.16
and 5.1.0 — cargo check --workspace --all-targets --target
x86_64-pc-windows-msvc, all clean. Release-notes extraction for 5.1.1
dry-run against rust/CHANGELOG.md returns the right section.
Raising rust-version to 1.96 turned on 11 clippy::collapsible_if errors
across four crates, failing the Clippy step with -D warnings.
The lint is MSRV-gated: its suggested fix is a let-chain, stabilised in
Rust 1.88, so clippy stayed silent while the workspace claimed 1.87. The
violations were always there — the wrong MSRV was suppressing them.
Applied with `cargo clippy --fix` (machine-applicable suggestions only),
then `cargo fmt` to repair the indentation the fixer leaves behind. Every
site is a plain nested if with no else branch, so each collapse is a pure
rewrite with no change in control flow.
cpd-tokenizer javascript.rs, razor.rs, sfc.rs, ts_strip.rs (1 each)
cpd-finder walker.rs (5)
cpd-reporter sarif.rs (2)
jscpd main.rs (3)
Verified: clippy --workspace -- -D warnings clean, cargo fmt --check
clean, 847 tests pass, and cargo +1.96 check --workspace --all-targets
--locked still passes (let-chains predate the MSRV by eight releases).
Revert the 5.1.1 bump (its release run was cancelled before publishing
anything; crates.io already has 5.1.0 and npm has nothing newer than
5.0.16, so the registry-based detect resumes the 5.1.0 release on this
push) and fold the fingerprint fix into the 5.1.0 changelog section.
Claude-Session: https://claude.ai/code/session_01VvWzWfEK6oV4HTe6EoGkff
Auto-discovery now also checks .config/jscpd.json (and
.config/.jscpd.json) between the root .jscpd.json and the package.json
jscpd key, per https://dot-config.github.io/. Root .jscpd.json keeps
precedence, so the new location is optional and never changes behavior
for existing setups. Paths inside the config still resolve relative to
the working directory, as with other auto-discovered sources.
Closes#979
Claude-Session: https://claude.ai/code/session_01VvWzWfEK6oV4HTe6EoGkff