pelicode
English | 日本語
pelicode extracts structural features of code at the function level (hashes based on the AST) and compares them with the code of published packages to find similar code that should be reviewed.
Just as dependabot checks dependency versions against external repositories, pelicode checks the functions in your commits and pull requests against code published on repositories such as PyPI, Maven Central and crates.io. It helps you notice code from elsewhere that may have slipped in without the developers knowing, for example through AI-generated code or pull requests from outside contributors.
[!IMPORTANT] pelicode does not decide whether code is plagiarized or violates a license. It shows "similar code candidates to review", together with their sources and licenses. Whether code was reused, and whether that raises a license issue, is for a person to check and decide.
This is a research prototype (v0.1). The usage scenarios describe who pelicode is for and what works today, and the specification describes the design and the implementation.
How it works
flowchart LR
R[("PyPI / Maven Central / crates.io<br/>Go proxy / RubyGems")] -->|pelicode crawl| I
P["Local projects"] -->|pelicode index| I
I["Extract, normalize and<br/>hash functions"] --> D[("SQLite DB")]
D -->|pelicode stats --refresh| D
T["Code to check<br/>(directory / git diff)"] -->|pelicode scan| S["Search for<br/>similar functions"]
D --> S
S --> O["Similar code candidates<br/>(text / JSON / CSV)"]
- Function extraction: source files are parsed with Tree-sitter, and functions, methods, lambdas and the like are extracted one by one.
- Normalization: superficial differences are removed step by step.
- N0: whitespace and comments removed
- N1: identifiers replaced with
$ID, literals with$STR,$NUMand so on - N2: type names also replaced with
$TYPE - N3: only the kinds of the AST nodes
- Hashing: pelicode computes a hash of the whole function (for exact matches) and fingerprints selected by winnowing from the k-grams of the token sequence (for partial matches). MinHash signatures and LSH buckets are built from the fingerprints.
- Search: exact matches are looked up first (N0, then N1, then N2). LSH then narrows down the candidates, which are compared by the Jaccard similarity of their fingerprint sets.
- Filtering common code: hashes found in many packages (getters,
equals, boilerplate) are no evidence of copying, so they can be ignored or down-weighted during the search.
The database never stores source code. It stores only hashes, locations (lines and byte ranges), sources (package, version, path, download URL) and licenses.
Supported languages and repositories
| Language | Extensions | Tree-sitter grammar |
|---|---|---|
| Go | .go |
tree-sitter-go |
| Java | .java |
tree-sitter-java |
| Kotlin | .kt, .kts |
tree-sitter-kotlin-ng (community-maintained) |
| Python | .py |
tree-sitter-python |
| Ruby | .rb |
tree-sitter-ruby |
| Rust | .rs |
tree-sitter-rust |
| Repository | What is fetched | Integrity check |
|---|---|---|
| PyPI | sdist (or a wheel when there is none) | SHA-256 |
| Maven Central | -sources.jar (Java and Kotlin) |
SHA-1 |
| Go | module zip | h1: hash from sum.golang.org |
| RubyGems | .gem |
SHA-256 |
| crates.io | .crate |
SHA-256 |
Installation
With Homebrew, Cargo, Docker, or from source (Rust 1.90 or later is required for Cargo and the source):
Archives for Linux, macOS and Windows (x64 and arm64) are attached to each release. To build from source:
This installs the pelicode command into ~/.cargo/bin. To try it without installing, use cargo run --release -- in place of pelicode. See the installation guide for details.
Quick start
# 1. Fetch published packages and index them into the database (pelicode.db)
# 2. Count how many packages contain each hash
# 3. Check your code
The result looks like this:
1 function(s) have similar code candidates to review.
hex.go:10-44 parseID (tokens=275)
similar(N1) 0.81 go:github.com/google/uuid@v1.6.0 uuid.go:68-117 [BSD-3-Clause]
lines 12-44 <-> 70-108,110-117 (containment 0.89, 80 shared fingerprints)
The first line is the location and name of the checked function. Each following line is a candidate: the kind of match (exact(N1) for an exact match after N1 normalization, similar(N1) for a similar one), the similarity, the source and the license. The lines line shows where the matching parts are in both pieces of code.
Commands
| Command | Description |
|---|---|
pelicode crawl <ECOSYSTEM> [PACKAGES]... |
Fetch packages from a public repository and index them |
pelicode index <PATHS>... |
Index local directories as one package |
pelicode stats |
Show database statistics and the hashes found in the most packages |
pelicode clean |
Remove data no longer referenced, and shrink the database |
pelicode snapshot -o <FILE> |
Write a copy of the database for checking only, to distribute |
pelicode pull <oci://...> |
Fetch a snapshot from a registry into the cache (scan --db oci://... does it too) |
pelicode scan [PATHS]... |
Search for similar code candidates |
pelicode extract <PATHS>... |
Show extracted functions and their features (no database) |
pelicode export |
List the functions stored in the database |
pelicode languages |
Show the supported languages and extensions |
Every command that uses a database takes --db <FILE> (default: pelicode.db); scan takes several, such as --db pypi.db --db crates.db. Run pelicode <COMMAND> --help for all options.
crawl: fetching published packages
- Without a version, the latest release is fetched.
- Versions already in the database are skipped. Use
--forceto fetch them again. - Archives are read in memory and discarded; nothing is unpacked to disk.
- Requests are sent one at a time, by default at least one second apart (
--delay-ms). When crawling many packages, put a contact URL or e-mail address in the User-Agent with--contact. - Failures are recorded in the database. If any package fails, the exit status is 1.
- For PyPI, the download ranking used by
--topis not an official PyPI service but hugovk/top-pypi-packages. For crates.io, it is the all-time download count of crates.io. Maven Central, the Go module proxy and RubyGems publish no ranking of their own, so--topasks ecosyste.ms for them: RubyGems by downloads, Maven Central and Go, which publish no download counts, by the number of repositories that depend on a package. The aim is well-known packages, not one ranking across repositories. - The crates.io data access policy allows at most one API request per second and strongly suggests a way to contact you; give
--contactand keep--delay-msat 1000 or more when crawling crates.io.
index: registering a local project
The ecosystem is local by default. When several directories are given, file paths are prefixed with the directory names.
stats: counting common hashes
The counts are not updated automatically. After crawl or index, run pelicode stats --refresh, or pass --refresh-stats to crawl or index. scan warns when the counts are out of date. After the first count, a refresh recounts only the hashes of what was indexed since, with the same result as recounting everything (--refresh --full).
scan: searching for similar code
Main options:
| Option | Default | Meaning |
|---|---|---|
--threshold |
0.7 | Minimum similarity to report |
--min-tokens |
50 | Shorter functions are not checked (they match by chance too easily) |
--max-candidates |
5 | Maximum number of candidates per function |
--stop-packages N or P% |
100 | Ignore hashes found in at least N packages (or P% of all packages); 0 disables |
--min-shared |
5 | Number of shared fingerprints required for a similar match |
--idf |
off | Weight fingerprints by ln(1 + N / df) |
The default --stop-packages 100 assumes a large database. With only a few dozen packages it has no effect, so give a percentage such as --stop-packages 40%.
--languages python,rust checks only some languages, and --exclude-path 'vendor/**' leaves out files by path. Candidates found only in the dependencies of the checked code (read from Cargo.lock, go.mod, requirements*.txt, poetry.lock, uv.lock, Pipfile.lock, Gemfile.lock, pom.xml and gradle.lockfile) are left out; --no-exclude-dependencies reports them too.
Settings file
Options used every time can be written in .pelicode.yaml (or .pelicode.yml, .pelicode.json5, .pelicode.json) at the root of the repository:
# .pelicode.yaml
version: 1
database: data/pelicode.db # relative to this file
scan:
languages:
threshold: 0.6
stop_packages: 10%
fail_on_match: true
exclude:
packages:
paths:
crawl:
contact: https://example.org/about
pelicode looks for it in the current directory and above, up to the root of the git repository. Options on the command line win over it; --config FILE names another file and --no-config ignores it. See Usage for every key.
GitHub Action
pelicode-action is a GitHub Action that checks pull requests and shows the candidates through code scanning:
# .github/workflows/pelicode.yaml
name: pelicode
on: pull_request
permissions:
contents: read
security-events: write # to upload the results to code scanning
jobs:
pelicode:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0 # pelicode compares the pull request with its base
- uses: tamada/pelicode-action@v0.1.0
with:
database-url: https://example.org/pelicode/pelicode.db
See pelicode-action for its inputs.
extract / export: looking inside
Feature parameters
These can be given to crawl, index and extract.
| Option | Default | Meaning |
|---|---|---|
--kgram |
10 | Length of the k-grams turned into fingerprints |
--window |
4 | Window size of winnowing |
--minhash-size |
128 | Length of MinHash signatures |
--bands |
32 | Number of LSH bands (must divide --minhash-size) |
--levels |
N1 | Normalization levels with fingerprints (several allowed, such as N1,N2) |
--ident-mode |
uniform | Replace every identifier with $ID (uniform), or number them by first appearance as $ID1, $ID2, ... (indexed) |
--min-tokens |
50 | Shorter functions get exact hashes only |
The database records the parameters it was built with, and adding to it with different parameters is an error. scan uses the parameters recorded in the database automatically.
Layout
| Directory | Crate | Contents |
|---|---|---|
| core | pelicode-core | Parsing, function extraction, normalization, hashes, fingerprints, MinHash |
| db | pelicode-db | SQLite storage, lookups and hash counts |
| detect | pelicode-detect | Search for similar code candidates |
| crawl | pelicode-crawl | Fetching from PyPI, Maven Central, Go, RubyGems and crates.io |
| cli | pelicode-cli | The pelicode command |
See CONTRIBUTING.md for the development workflow, adding a language or a package repository, and when to bump the versions of the hashes and the database.
Not implemented yet
- Using published snapshots from the GitHub Action, and a command for evaluation experiments (
pelicode eval) - Fetching the most downloaded packages of Go, Maven Central and RubyGems