Boundaries
Some code belongs in one place. In a product that handles money, the backend calculates every amount it charges, so a fee, a discount or a loan payment that the frontend calculates too is a second version of a backend function. When the formula changes on the backend, the copy keeps the old one, and the user sees one price and pays another. A boundary rule names the code to protect and the places its copies must stay out of. Every clone run checks the rules and exits with code 1 when it finds a copy, whether someone copied the code, edited a copy, or wrote the same logic again in another language.
Import linters such as dependency-cruiser, eslint-plugin-boundaries, ArchUnit or import-linter check which module imports which. A copied function imports nothing, so they don't see it.
Quick start
The jscpd repository has a ready-made example in fixtures/boundaries-demo, a small shop that also gives loans. backend/pricing/ holds the discount and the sales tax in TypeScript, backend/loans/Interest.kt holds the monthly payment of a loan in Kotlin, and the demo's .jscpd.json says that only the backend calculates money:
{
"boundaries": [
{
"name": "money",
"code": ["backend/pricing", "backend/loans/Interest.kt#monthlyPayment"],
"notIn": ["web/**"],
"allow": ["web/e2e/**"],
"thresholds": { "minTokens": 30, "similarity": 0.7 }
}
]
}
code protects the whole backend/pricing folder and one method of Interest.kt, notIn keeps copies out of web/, and allow lets the end-to-end tests keep theirs. The search for logic written again in another language needs the embedding model of --semantic, downloaded once:
jscpd --semantic-download # once: 548 MB into the jscpd cache
cd fixtures/boundaries-demo
jscpd .
The clone report comes first, as usual. After it, the console lists what the rule found:
Boundaries:
money: 3 copies outside the boundary
web/src/checkout/discount.ts:8-17 applyDiscount
copy of backend/pricing/discount.ts:6-15 applyDiscount (exact, 10 lines)
web/src/checkout/tax.ts:6-15 estimateTax
looks like backend/pricing/tax.ts:5-13 salesTax (similar (ast) ~0.84)
web/src/loan/payment.ts:4-14 monthlyPayment
looks like backend/loans/Interest.kt:8-15 monthlyPayment (semantic ~0.82)
The run ends with this line on stderr and exit code 1:
ERROR: jscpd found 3 copies of protected code outside their boundaries (money: 3)
Each copy comes first, with the function it lies in, and the protected code it copies sits under it. copy of marks copy-paste that token matching found, with its number of lines. looks like marks a function of the same structure, similar (ast) with its score, or a function that does the same job, semantic with its score. In the demo the discount was copied as it is, the tax was rewritten with other names and a debug line, and the monthly payment was written again in TypeScript.
The demo has three more copies that the rule leaves alone: the discount in web/e2e/, which allow takes; the discount in backend/orders/, which is outside notIn; and formatRate of Interest.kt written again in TypeScript, a method the rule doesn't protect.
Write a rule
boundaries in the config file is a list of rules. Each rule has a name for the reports, unique in the file, and a code list. The other keys are optional.
What to protect
code takes paths and globs. A folder stands for everything in it, so backend/billing and backend/billing/** protect the same files, and a glob such as backend/loans/**/*Payment*.kt takes files by name. After a #, a name takes one method of the matching files, and a pattern in parentheses takes every method whose name matches it, with *, ? and {a,b}:
{
"name": "money",
"code": [
"backend/billing",
"backend/loans/**/*Payment*.kt",
"backend/loans/src/main/kotlin/Interest.kt#monthlyPayment",
"backend/loans/src/main/kotlin/Interest.kt#(*Payments)",
"backend/**/*.kt#(calculate*)"
],
"notIn": ["web/**", "mobile/**"]
}
Interest.kt#(*Payments) takes every method of the file whose name ends with Payments, such as latePayments, and backend/**/*.kt#(calculate*) takes the methods whose names start with calculate in every Kotlin file of the backend. A plain function counts as a method. jscpd finds methods in the languages of --semantic: JavaScript, TypeScript and their Vue, Svelte and Astro components, Python, Rust, Go, Java, Kotlin, C#, C, C++, PHP, Ruby, Scala and Swift. In other formats, code takes whole files.
A pattern needs its parentheses. Interest.kt#monthly* stops the run with an error that shows both forms, and Interest.kt#(monthly*) takes monthlyPayment.
Paths start in the folder of the config file named with --config, and in the working directory for a config file jscpd finds there. In a glob, * stays in one folder and ** crosses folders. An absolute path matches as it is. Square brackets match as written too, so web/app/[orderId] is the folder of a Next.js or SvelteKit route as well as a set of characters.
A copy breaks a method rule when it holds the whole method or a line inside it. A token clone that only starts on the method's last line, or ends on its first, copies the code around the method and doesn't count.
Where copies must not show up
notIn takes globs of the places the rule keeps copies out of. Without notIn, every file outside code counts, which suits a package whose code belongs nowhere else:
{ "name": "auth", "code": ["packages/auth"] }
The files of a rule's own code never break it: two copies inside one module are plain duplication, which the clone report covers. So a rule can keep the code of one backend module out of the other backend modules:
{ "name": "billing", "code": ["backend/billing"], "notIn": ["backend/**"] }
Where a copy is fine
allow takes globs of the places where copies may stay although notIn covers them, such as end-to-end tests that repeat a calculation to know the result they expect, or a generated API client. Test files count unless allow takes them, although --similarity leaves them out of the clone report: for a rule, a copy in a test is a copy.
How close a copy has to be
thresholds sets the bars of one rule. Each key replaces the project's setting for that rule only, and a key you leave out keeps the project's:
| Key | Replaces | Values |
|---|---|---|
minTokens | --min-tokens | 1 and up |
minLines | --min-lines | 1 and up |
minNodes | --min-nodes | 1 and up |
similarity | --similarity, or 0.8 when the run sets none | above 0, up to 1 |
semanticThreshold | --semantic-threshold | above 0, up to 1 |
semanticSameThreshold | --semantic-same-threshold | above 0, up to 1 |
For a rule like money a missed copy costs more than a false alarm, so its bars can sit below the ones the clone report uses: the demo looks for copies from 30 tokens and for functions of the same structure from a score of 0.7. A value out of range stops the run with an error.
Kinds to leave out
disable turns off kinds of copy by the names of --kind: exact, renamed, gap, ast, similar for both gap and ast, and semantic. A rule whose two sides use one language can do without the model, and then needs no download and prints no warning about it:
{ "name": "auth", "code": ["packages/auth"], "disable": ["semantic"] }
When a rule turns off the surer search for a copy, the next one reports it: with exact off, a copied function shows up as one of the same structure. A rule that turns off every kind stops the run with an error.
How jscpd looks for copies
After the scan of the clone report, jscpd runs three searches for every rule, with the rule's bars:
- Token matching finds copy-paste within one language:
exactcopies always,renamedones with--ignore-identifiers,--ignore-literalsor--ignore-annotations, and copies merged across a gap with--max-gap-lines. - The structure search compares functions the way
--similaritydoes and finds a copy someone edited. It compares each protected function with the functions outside the boundary and no other pair. - The semantic search pairs functions the way
--comparepairs two folders, with the model of--semantic, and finds the logic written again, often in another language.
The searches run whatever the clone report asks for. --similarity and --semantic don't have to be on, and --kind, --skip-local and --skip-isolated narrow the clone report but not the rules. The clone list, the statistics and --threshold stay as they are. A copy that two searches find is reported once, by the surer one: exact, then renamed, then merged across a gap, then the same structure, then the same job.
The rules see the files of the scan only. jscpd web/ reads no protected code and warns about it, so scan a path that holds both sides, usually the repository root.
The model
The semantic search uses the model of --semantic, with the settings of the semantic section of the config file and the --semantic-* flags, --semantic-scope aside. jscpd embeds the functions of every rule in one call and caches the vectors where --semantic does, so a later run embeds only the functions that changed. Semantic clones covers the models, the cache folder and an embeddings API in place of the local model.
Without the model, jscpd prints a warning and checks the rules without the semantic search:
Warning: boundaries: the model nomic-ai/CodeRankEmbed is not downloaded, so the rules look for copies without it and miss logic written again in another language. Run `jscpd --semantic-download` once (548 MB), or use an embeddings API with --semantic-url
Copy-paste and edited copies still fail the run. The demo then reports 2 copies of 3, and the monthly payment written again in TypeScript goes unseen. jscpd does the same when there is no cache folder to keep the model in, and JSCPD_CACHE_DIR names one. Any other problem with the model, such as an embeddings API that doesn't answer, stops the run, as it does for --semantic.
The semantic search is the least sure of the three. It can report related code, such as a client function and the endpoint it calls, so read a semantic violation before you act on it. When the pair is not a copy, raise the rule's semanticThreshold or add the file to allow.
Read the report
console and console-full print the rules after the clone table, as in the quick start, and a rule without copies says auth: no copies outside the boundary. With a copy, jscpd writes the ERROR: line to stderr and exits with code 1 whichever reporters run, --silent included.
The ai reporter prints one line per copy, the copy first, and a count:
jscpd . -r ai
Boundaries:
money: web/src/checkout/discount.ts:8-17 ~ backend/pricing/discount.ts:6-15
money: web/src/checkout/tax.ts:6-15 ~ backend/pricing/tax.ts:5-13 [~0.84 ast]
money: web/src/loan/payment.ts:4-14 ~ backend/loans/Interest.kt:8-15 [~0.82 semantic]
3 copies outside boundaries
The json reporter adds a boundaries key to jscpd-report.json, with one entry per rule that holds its name and its violations. The semantic copy of the demo, with startLoc and endLoc left out of both sides:
{
"kind": "semantic",
"similarity": 0.82,
"lines": 11,
"tokens": 80,
"copy": { "name": "web/src/loan/payment.ts", "start": 4, "end": 14, "function": "monthlyPayment" },
"protected": { "name": "backend/loans/Interest.kt", "start": 8, "end": 15, "function": "monthlyPayment" }
}
The JSON reporter page lists every field. One line per copy from a script:
jscpd . -r json --output report
jq -r '.boundaries[] | .name as $rule | .violations[] | "\($rule): \(.copy.name):\(.copy.start) copies \(.protected.name):\(.protected.start) (\(.kind))"' report/jscpd-report.json
money: web/src/checkout/discount.ts:8 copies backend/pricing/discount.ts:6 (exact)
money: web/src/checkout/tax.ts:6 copies backend/pricing/tax.ts:5 (similar)
money: web/src/loan/payment.ts:4 copies backend/loans/Interest.kt:8 (semantic)
The other reporters, SARIF, CodeClimate and HTML among them, don't list the copies, and the exit code fails the job all the same.
In CI
A rule fails the job through the exit code, so a step that runs jscpd on the repository root is the whole setup. Keep the jscpd cache between runs, or every run downloads the model again:
- uses: actions/cache@v4
with:
path: ~/.cache/jscpd
key: jscpd-${{ runner.os }}-${{ github.run_id }}
restore-keys: jscpd-${{ runner.os }}-
- run: npx jscpd --semantic-download
- run: npx jscpd .
--semantic-download does nothing when the model is in the cache already. When every rule has "disable": ["semantic"], the job needs neither the download nor the cache. Violations don't go through a baseline, so a new rule fails on the copies that exist when you add it. Move them behind the boundary, or put them in allow until you do.
When a rule checks nothing
A rule that doesn't check what you meant must not look as if it passed. A mistake in a rule stops the run with exit code 1 and an error that names the rule: an unknown key, an empty code or notIn, a glob that doesn't compile, a # with no method after it, a pattern of method names without parentheses, a threshold out of range, two rules with one name, an unknown kind in disable, or a disable that turns off every kind.
Error: boundaries: rule 'money': unknown field `notin`, expected one of `name`, `code`, `notIn`, `allow`, `thresholds`, `disable`
A valid rule that checks nothing in a run gets a warning, --silent or not, and the run goes on:
Warning: boundaries: rule 'money' protects no code of this scan; check the paths and method names of its code, and the scan paths
Warning: boundaries: rule 'money' keeps copies out of no file of this scan; check the paths of its notIn and allow, and the scan paths
Warning: boundaries: rule 'money' looks for no kind of copy this run can find: renamed needs --ignore-identifiers, --ignore-literals or --ignore-annotations, gap needs --max-gap-lines
The first comes from a typo in the paths or the method names of code, or from a scan that leaves the protected code out. The second comes from a typo in notIn, or from an allow that takes every file notIn names. The third comes from a disable that leaves only kinds the run doesn't look for. The warnings cover a rule that checks nothing at all: a typo in one of several code entries leaves the others working and prints nothing. To test a new rule, copy a protected function into a place the rule keeps it out of and check that the run fails.
Limits
- Violations don't go through
--baseline, and--changeddoesn't narrow them. console,console-full,aiandjsonshow the violations. The other reporters, the language server and the MCP server don't.- Only a clone run checks the rules.
--compare,--dead-code,--complexity,--dashboard,--health,--mcpand--lspdon't, and neither do the commits of--history. - The rules read their files once more after the clone scan, and each rule runs its own search.
- jscpd finds only code that looks like the protected code. Money math in a frontend with no twin in the backend is a job for a linter.
See also
- The config file: the keys of a rule and their types.
- Semantic clones: the model, the cache and the thresholds of the semantic search.
- Similar functions: how the structure search scores two functions.
- Use jscpd in CI: the rest of a CI setup.
- Exit codes: every gate that fails a run.