mirror of
https://github.com/kucherenko/jscpd.git
synced 2026-09-19 08:11:03 +08:00
adbf5ecf3d
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
490 lines
17 KiB
YAML
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 |