Files
apk adbf5ecf3d feat(cli): --history, duplication trend over git history
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
2026-09-12 07:50:18 +02:00

490 lines
17 KiB
YAML

name: jscpd-copy-paste-detector
description: "Copy/paste detector for source code — find duplicated blocks across 223+ languages (Rust v5)"
branding:
icon: copy
color: blue
inputs:
path:
description: "Paths to scan for duplicates (space-separated)"
required: false
default: "."
config:
description: "Path to .jscpd.json config file"
required: false
default: ""
min-tokens:
description: "Minimum number of tokens to consider a duplicate"
required: false
default: "50"
min-lines:
description: "Minimum number of lines to consider a duplicate"
required: false
default: "5"
max-lines:
description: "Maximum lines per block to consider"
required: false
default: ""
mode:
description: "Detection mode: mild, weak, or strict"
required: false
default: "mild"
format:
description: "Comma-separated list of file extensions/formats to check"
required: false
default: ""
ignore:
description: "Comma-separated glob patterns to ignore"
required: false
default: ""
ignore-pattern:
description: "Comma-separated regex patterns to skip matching tokens"
required: false
default: ""
reporters:
description: "Comma-separated list of reporters (console, json, xml, csv, html, markdown, badge, sarif, ai, xcode, console-full, threshold, silent)"
required: false
default: "console"
output:
description: "Output directory for file reporters"
required: false
default: "report"
threshold:
description: "Maximum duplication percentage before exit 1"
required: false
default: ""
baseline:
description: "Path to a clone baseline file (e.g. .jscpd-baseline.json): clones absent from it are reported as new"
required: false
default: ""
update-baseline:
description: "Rewrite the baseline file from the current run (requires baseline)"
required: false
default: "false"
fail-on-new-clones:
description: "Exit 1 when new clones are found (requires baseline or baseline-from-ref). Use 'true' for zero tolerance, or an integer N to allow up to N new clones"
required: false
default: ""
fail-on-empty:
description: "Exit 1 when the scan analyzes no files: the paths exist but nothing matched the format, ignore or pattern filters"
required: false
default: "false"
baseline-from-ref:
description: "Compare against an ephemeral baseline built from a git ref's tree (e.g. origin/main); needs the ref fetched (fetch-depth: 0). Conflicts with baseline"
required: false
default: ""
history:
description: "Duplication trend over git history: scan every commit in this range (e.g. v5.0.0..HEAD) and print a chart and table in the log; needs the range fetched (fetch-depth: 0)"
required: false
default: ""
blame:
description: "Enrich clones with git blame data"
required: false
default: "false"
exit-code:
description: "Exit with code when duplicates found. Use 'true' for default code 1, or an integer like '2' for a custom code"
required: false
default: ""
pattern:
description: "Glob pattern to find files to scan"
required: false
default: ""
max-size:
description: "Skip files larger than SIZE (e.g. 100kb, 1mb)"
required: false
default: ""
skip-local:
description: "Skip clones where both fragments are in the same directory"
required: false
default: "false"
ignore-case:
description: "Ignore case of symbols (experimental)"
required: false
default: "false"
ignore-identifiers:
description: "Treat all identifiers as equal (Type-2 clones: renamed variables, functions, types)"
required: false
default: "false"
ignore-literals:
description: "Treat all string and numeric literals as equal"
required: false
default: "false"
ignore-annotations:
description: "Skip annotations and decorators (@Name, @Name(...))"
required: false
default: "false"
max-gap-lines:
description: "Merge clones separated by at most N unmatched lines into one near-miss clone (Type-3); 0 disables"
required: false
default: "0"
similarity:
description: "Report JavaScript/TypeScript function pairs whose syntax-tree similarity reaches this ratio (a number in (0, 1]) as near-miss clones; 1 means exact matches only, e.g. 0.85 enables it"
required: false
default: "1"
follow-symlinks:
description: "Follow symbolic links"
required: false
default: "false"
no-gitignore:
description: "Do not respect .gitignore files"
required: false
default: "false"
absolute:
description: "Use absolute paths in reports"
required: false
default: "false"
formats-exts:
description: "Custom format-to-extension mappings (e.g. javascript:es,es6;dart:dt)"
required: false
default: ""
formats-names:
description: "Custom format-to-filename mappings (e.g. makefile:Makefile,GNUmakefile;docker:Dockerfile)"
required: false
default: ""
version:
description: "Version of jscpd to install (default: latest)"
required: false
default: "latest"
install-prefix:
description: "Installation directory for the cpd binary (default: /usr/local/bin on Linux/macOS, npm global bin on Windows)"
required: false
default: ""
skip-install:
description: "Skip installation (use when jscpd/cpd is already installed)"
required: false
default: "false"
extra-args:
description: "Additional arguments passed directly to cpd/jscpd"
required: false
default: ""
upload-report:
description: "Upload the report directory as a workflow artifact"
required: false
default: "false"
upload-sarif:
description: "Upload SARIF report to GitHub Code Scanning"
required: false
default: "true"
outputs:
duplication-percentage:
description: "Percentage of duplicated code found"
value: ${{ steps.parse-output.outputs.duplication-percentage }}
clones-found:
description: "Number of clone pairs found"
value: ${{ steps.parse-output.outputs.clones-found }}
duplicated-lines:
description: "Number of duplicated lines"
value: ${{ steps.parse-output.outputs.duplicated-lines }}
total-lines:
description: "Total number of lines scanned"
value: ${{ steps.parse-output.outputs.total-lines }}
files-count:
description: "Number of source files scanned"
value: ${{ steps.parse-output.outputs.files-count }}
report-path:
description: "Path to the output directory"
value: ${{ steps.parse-output.outputs.report-path }}
sarif-path:
description: "Path to the SARIF report file (empty if not generated)"
value: ${{ steps.parse-output.outputs.sarif-path }}
exit-code:
description: "Exit code from jscpd (non-zero if threshold exceeded or --exit-code used)"
value: ${{ steps.run-jscpd.outputs.exit-code }}
runs:
using: composite
steps:
- name: Install jscpd (Linux/macOS)
if: runner.os != 'Windows' && inputs.skip-install != 'true'
shell: bash
env:
JSCPD_VERSION: ${{ inputs.version }}
JSCPD_PREFIX: ${{ inputs.install-prefix }}
run: |
ARGS=""
if [ -n "$JSCPD_VERSION" ] && [ "$JSCPD_VERSION" != "latest" ]; then
ARGS="$ARGS --version $JSCPD_VERSION"
fi
if [ -n "$JSCPD_PREFIX" ]; then
ARGS="$ARGS --prefix $JSCPD_PREFIX"
fi
# Try install script first (downloads from GitHub Releases, falls back to npm)
for attempt in 1 2 3; do
if curl -fsSL --retry 3 --retry-delay 5 https://jscpd.dev/install.sh | bash -s -- $ARGS; then
break
fi
echo "Install attempt $attempt failed, retrying in 10s..."
sleep 10
done
# Fallback: if install script failed, try npm directly
if ! command -v cpd >/dev/null 2>&1 && ! command -v jscpd >/dev/null 2>&1; then
echo "Install script failed, falling back to npm install..."
if [ -n "$JSCPD_VERSION" ] && [ "$JSCPD_VERSION" != "latest" ]; then
npm install -g "jscpd@$JSCPD_VERSION" || npm install -g "cpd@$JSCPD_VERSION"
else
npm install -g jscpd || npm install -g cpd
fi
fi
- name: Install jscpd (Windows)
if: runner.os == 'Windows' && inputs.skip-install != 'true'
shell: pwsh
env:
JSCPD_VERSION: ${{ inputs.version }}
run: |
$version = $env:JSCPD_VERSION
$pkgs = if ($version -eq "latest" -or [string]::IsNullOrEmpty($version)) {
@("jscpd", "cpd")
} else {
@("jscpd@$version", "cpd@$version")
}
$installed = $false
foreach ($pkg in $pkgs) {
try {
npm install -g $pkg
$installed = $true
break
} catch {
Write-Host "npm install -g $pkg failed, trying next..."
Start-Sleep -Seconds 5
}
}
if (-not $installed) {
# Retry once
foreach ($pkg in $pkgs) {
try {
npm install -g $pkg
$installed = $true
break
} catch {
Write-Host "npm install -g $pkg retry failed"
}
}
}
if (-not $installed) {
Write-Error "Failed to install jscpd via npm"
exit 1
}
- name: Run jscpd
id: run-jscpd
shell: bash
env:
INPUT_PATH: ${{ inputs.path }}
INPUT_CONFIG: ${{ inputs.config }}
INPUT_MIN_TOKENS: ${{ inputs.min-tokens }}
INPUT_MIN_LINES: ${{ inputs.min-lines }}
INPUT_MAX_LINES: ${{ inputs.max-lines }}
INPUT_MODE: ${{ inputs.mode }}
INPUT_FORMAT: ${{ inputs.format }}
INPUT_IGNORE: ${{ inputs.ignore }}
INPUT_IGNORE_PATTERN: ${{ inputs.ignore-pattern }}
INPUT_REPORTERS: ${{ inputs.reporters }}
INPUT_OUTPUT: ${{ inputs.output }}
INPUT_THRESHOLD: ${{ inputs.threshold }}
INPUT_BASELINE: ${{ inputs.baseline }}
INPUT_UPDATE_BASELINE: ${{ inputs.update-baseline }}
INPUT_FAIL_ON_NEW_CLONES: ${{ inputs.fail-on-new-clones }}
INPUT_FAIL_ON_EMPTY: ${{ inputs.fail-on-empty }}
INPUT_BASELINE_FROM_REF: ${{ inputs.baseline-from-ref }}
INPUT_HISTORY: ${{ inputs.history }}
INPUT_BLAME: ${{ inputs.blame }}
INPUT_EXIT_CODE: ${{ inputs.exit-code }}
INPUT_PATTERN: ${{ inputs.pattern }}
INPUT_MAX_SIZE: ${{ inputs.max-size }}
INPUT_SKIP_LOCAL: ${{ inputs.skip-local }}
INPUT_IGNORE_CASE: ${{ inputs.ignore-case }}
INPUT_IGNORE_IDENTIFIERS: ${{ inputs.ignore-identifiers }}
INPUT_IGNORE_LITERALS: ${{ inputs.ignore-literals }}
INPUT_IGNORE_ANNOTATIONS: ${{ inputs.ignore-annotations }}
INPUT_MAX_GAP_LINES: ${{ inputs.max-gap-lines }}
INPUT_SIMILARITY: ${{ inputs.similarity }}
INPUT_FOLLOW_SYMLINKS: ${{ inputs.follow-symlinks }}
INPUT_NO_GITIGNORE: ${{ inputs.no-gitignore }}
INPUT_ABSOLUTE: ${{ inputs.absolute }}
INPUT_FORMATS_EXTS: ${{ inputs.formats-exts }}
INPUT_FORMATS_NAMES: ${{ inputs.formats-names }}
INPUT_EXTRA_ARGS: ${{ inputs.extra-args }}
run: |
# Ensure json reporter is always included for output parsing
REPORTERS="$INPUT_REPORTERS"
if echo "$REPORTERS" | grep -qv "json"; then
if [ -z "$REPORTERS" ]; then
REPORTERS="json"
else
REPORTERS="${REPORTERS},json"
fi
fi
# Build the command
ARGS=()
# Positional paths
if [ -n "$INPUT_PATH" ]; then
for p in $INPUT_PATH; do
ARGS+=("$p")
done
fi
# Config file
if [ -n "$INPUT_CONFIG" ]; then
ARGS+=("--config" "$INPUT_CONFIG")
fi
# Numeric options (only add if non-default)
if [ -n "$INPUT_MIN_TOKENS" ] && [ "$INPUT_MIN_TOKENS" != "50" ]; then
ARGS+=("--min-tokens" "$INPUT_MIN_TOKENS")
fi
if [ -n "$INPUT_MIN_LINES" ] && [ "$INPUT_MIN_LINES" != "5" ]; then
ARGS+=("--min-lines" "$INPUT_MIN_LINES")
fi
if [ -n "$INPUT_MAX_LINES" ]; then
ARGS+=("--max-lines" "$INPUT_MAX_LINES")
fi
# Mode
if [ -n "$INPUT_MODE" ] && [ "$INPUT_MODE" != "mild" ]; then
ARGS+=("--mode" "$INPUT_MODE")
fi
# Format and ignore
if [ -n "$INPUT_FORMAT" ]; then
ARGS+=("--format" "$INPUT_FORMAT")
fi
if [ -n "$INPUT_IGNORE" ]; then
ARGS+=("--ignore" "$INPUT_IGNORE")
fi
if [ -n "$INPUT_IGNORE_PATTERN" ]; then
ARGS+=("--ignore-pattern" "$INPUT_IGNORE_PATTERN")
fi
# Reporters and output
ARGS+=("--reporters" "$REPORTERS")
ARGS+=("--output" "$INPUT_OUTPUT")
# Threshold
if [ -n "$INPUT_THRESHOLD" ]; then
ARGS+=("--threshold" "$INPUT_THRESHOLD")
fi
# Baseline: gate on new duplication only (committed file or git ref)
if [ -n "$INPUT_BASELINE" ]; then
ARGS+=("--baseline" "$INPUT_BASELINE")
if [ "$INPUT_UPDATE_BASELINE" = "true" ]; then
ARGS+=("--update-baseline")
fi
elif [ -n "$INPUT_BASELINE_FROM_REF" ]; then
ARGS+=("--baseline-from-ref" "$INPUT_BASELINE_FROM_REF")
fi
if [ -n "$INPUT_HISTORY" ]; then
ARGS+=("--history" "$INPUT_HISTORY")
fi
# Fail on new clones: empty=omit, 'true'=bare flag (N=0), integer=value
if [ -n "$INPUT_FAIL_ON_NEW_CLONES" ]; then
if [ "$INPUT_FAIL_ON_NEW_CLONES" = "true" ]; then
ARGS+=("--fail-on-new-clones")
else
ARGS+=("--fail-on-new-clones" "$INPUT_FAIL_ON_NEW_CLONES")
fi
fi
# Boolean flags
if [ "$INPUT_BLAME" = "true" ]; then
ARGS+=("--blame")
fi
# Exit code: empty=omit, 'true'=bare flag, integer=value
if [ -n "$INPUT_EXIT_CODE" ]; then
if [ "$INPUT_EXIT_CODE" = "true" ]; then
ARGS+=("--exit-code")
else
ARGS+=("--exit-code" "$INPUT_EXIT_CODE")
fi
fi
# Pattern
if [ -n "$INPUT_PATTERN" ]; then
ARGS+=("--pattern" "$INPUT_PATTERN")
fi
# Max size (only if non-default)
if [ -n "$INPUT_MAX_SIZE" ] && [ "$INPUT_MAX_SIZE" != "1mb" ]; then
ARGS+=("--max-size" "$INPUT_MAX_SIZE")
fi
# Boolean flags
if [ "$INPUT_SKIP_LOCAL" = "true" ]; then
ARGS+=("--skip-local")
fi
if [ "$INPUT_FAIL_ON_EMPTY" = "true" ]; then
ARGS+=("--fail-on-empty")
fi
if [ "$INPUT_IGNORE_CASE" = "true" ]; then
ARGS+=("--ignore-case")
fi
if [ "$INPUT_IGNORE_IDENTIFIERS" = "true" ]; then
ARGS+=("--ignore-identifiers")
fi
if [ "$INPUT_IGNORE_LITERALS" = "true" ]; then
ARGS+=("--ignore-literals")
fi
if [ "$INPUT_IGNORE_ANNOTATIONS" = "true" ]; then
ARGS+=("--ignore-annotations")
fi
if [ -n "$INPUT_MAX_GAP_LINES" ] && [ "$INPUT_MAX_GAP_LINES" != "0" ]; then
ARGS+=("--max-gap-lines" "$INPUT_MAX_GAP_LINES")
fi
if [ -n "$INPUT_SIMILARITY" ] && [ "$INPUT_SIMILARITY" != "1" ]; then
ARGS+=("--similarity" "$INPUT_SIMILARITY")
fi
if [ "$INPUT_FOLLOW_SYMLINKS" = "true" ]; then
ARGS+=("--follow-symlinks")
fi
if [ "$INPUT_NO_GITIGNORE" = "true" ]; then
ARGS+=("--no-gitignore")
fi
if [ "$INPUT_ABSOLUTE" = "true" ]; then
ARGS+=("--absolute")
fi
# Format mappings
if [ -n "$INPUT_FORMATS_EXTS" ]; then
ARGS+=("--formats-exts" "$INPUT_FORMATS_EXTS")
fi
if [ -n "$INPUT_FORMATS_NAMES" ]; then
ARGS+=("--formats-names" "$INPUT_FORMATS_NAMES")
fi
# Extra args escape hatch
if [ -n "$INPUT_EXTRA_ARGS" ]; then
ARGS+=($INPUT_EXTRA_ARGS)
fi
# Run the detection, capture exit code for threshold/exit-code handling
# Post-steps use `if: always()` so they execute regardless of exit code
jscpd "${ARGS[@]}" || EXIT_CODE=$?
echo "exit-code=${EXIT_CODE:-0}" >> "$GITHUB_OUTPUT"
- name: Parse output
id: parse-output
if: always()
shell: bash
run: node "${{ github.action_path }}/.github/actions/jscpd/parse-output.js" "${{ inputs.output }}"
- name: Upload SARIF to GitHub Code Scanning
if: inputs.upload-sarif == 'true' && always() && steps.parse-output.outputs.sarif-path != ''
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: ${{ steps.parse-output.outputs.sarif-path }}
continue-on-error: true
- name: Upload report artifact
if: inputs.upload-report == 'true' && always()
uses: actions/upload-artifact@v7
with:
name: jscpd-report
path: ${{ inputs.output }}
continue-on-error: true