---
name: scan-code-for-duplicate-candidates
description: >
  Run dry-ts locally or from code to find fuzzy structural duplicate clusters. Load when choosing paths, interpreting score, status, and line-range output, tuning --threshold, --min-lines, --min-nodes, or using TypeScriptDuplicateFinder.findClusters.
type: core
library: dry-ts
library_version: "0.14.0"
sources:
  - "dry-ts:README.md"
  - "dry-ts:src/TypeScriptDuplicateFinder.ts"
  - "dry-ts:src/TypeScriptNormalizer.ts"
  - "dry-ts:src/FileScanner.ts"
  - "dry-ts:src/Options.ts"
  - "dry-ts:src/Config.ts"
  - "dry-ts:src/types.ts"
---

# dry-ts - Scan Code for Duplicate Candidates

## Setup

```bash
bunx dry-ts src test
```

```ts
import { TypeScriptDuplicateFinder } from "dry-ts";

const clusters = new TypeScriptDuplicateFinder().findClusters({
  paths: ["src", "test"],
  threshold: 0.82,
  minLines: 4,
  minNodes: 20,
});

console.log(clusters);
```

## Core Patterns

### Start from a profile (`--profile`)

```bash
# PR gate — the recommended workflow. Requires --changed-from.
bunx dry-ts --profile pr --changed-from origin/main src
```

`--profile NAME` seeds a curated flag bundle; explicit flags override it (precedence: explicit flag > profile > `.dry-ts.json` > default; list flags like `--exclude-kinds` union across all layers). Profiles: `pr` (PR gate — `--exclude-tests --min-nodes 50 --exclude-kinds ArrowFunction,VariableStatement --only-new --fail-on-duplicates`; needs `--changed-from`), `src` (source-only, `--exclude-tests`), `audit` (broad, `--min-nodes 12`), `tests` (test-infra dup, `--exclude-kinds ArrowFunction --min-nodes 40`). Prefer a profile over hand-assembling flags. To make a repo's baseline permanent, commit a `.dry-ts.json` with the persistable options (`threshold`, `minNodes`, `excludeKinds`, `exclude`/`ignore` globs, …); it sits below `--profile` and any explicit flag, and a broken or unreadable config fails the run loud at exit `2`. See `wire-duplicate-checks-into-ci` for the full key list and the `--fail-on-duplicates` gate note.

### Scan changed source and tests

```bash
bunx dry-ts src test
```

Path arguments can be files or directories. Directories are scanned recursively for `.js`, `.jsx`, `.ts`, `.tsx`, `.mts`, and `.cts` files.

### Tune sensitivity for smaller candidates

```bash
bunx dry-ts src test --threshold 0.78 --min-lines 3 --min-nodes 12
```

Lower `--threshold`, `--min-lines`, and `--min-nodes` only when intentionally looking for smaller or fuzzier structural matches.

### Suppress boilerplate candidate kinds

```bash
bunx dry-ts src --exclude-kinds Constructor,PropertySignature,MethodSignature
```

`--exclude-kinds` drops candidate declarations of the named TypeScript
`SyntaxKind`s before matching, so structurally-identical boilerplate stops
clustering: dependency-injection constructors (`Constructor`) or port/interface
member signatures (`PropertySignature,MethodSignature`). It is opt-in — with no
flag, nothing is excluded — comma-separated and repeatable. An unknown or
non-candidate kind name is a hard error (exit `2`); valid names are the
candidate root kinds listed in the README. Excluding a kind never hides a
longer child candidate, since children are always visited.

### Drop near-uniform candidates (kind-diversity floor)

```bash
bunx dry-ts src --min-distinct-kinds 8
```

`--min-distinct-kinds N` drops a candidate whose subtree spans fewer than `N`
distinct node kinds. It complements `--min-nodes` (size) with a structure-variety
floor: a property-only interface or a flat config object can clear the node-count
bar yet reach the threshold against any similarly-shaped block. Opt-in, default
`0` (off); markers do not count, only node kinds. The off path tracks nothing, so
it costs nothing.

### Exclude whole files by path glob

```bash
bunx dry-ts src --exclude '**/*.spec.*' --exclude '**/*.stories.*'
```

`--exclude GLOB` skips files/directories matching a `.gitignore`-style glob during
directory scans. Repeatable; applies regardless of `--no-gitignore` (it is an
explicit instruction, not repo config); explicit file arguments are still always
scanned. This is the highest-leverage filter for expected duplication — on a large
frontend codebase, test and story files alone are typically about half of all
reported clusters.

### Suppress CSS-in-JS / styled-components declarations

```bash
bunx dry-ts src --exclude-tagged-templates
```

`--exclude-tagged-templates` drops candidate declarations whose value is a tagged
template literal — `const X = styled(Button)\`…\``, `styled('span')\`…\``, `css\`…\``,
`gql\`…\``. These normalize to a near-identical AST and cluster across dozens of
files despite sharing no logic; the kind/path filters cannot catch them without
dropping real const-bound function duplicates. The flag matches by structure, not
by tag name, so it covers `styled`/`css`/`gql` and any alias uniformly. Opt-in,
default off; on a large frontend codebase it is the single largest remaining
false-positive class after path and kind filters (~8% of clusters on the Sentry
corpus).

### Suppress one occurrence at the source (`// dry-ignore`)

```ts
// dry-ignore
export function knownDuplicate(): void {
  // ...
}
```

A `// dry-ignore` (or `// dry-ignore-next-line`, or `/* dry-ignore */`) comment in
a declaration's leading trivia drops that declaration as a candidate. No flag
required. Suppression is scoped to the node whose comment carries the directive —
a directive on a wrapping `const` statement does not reach a nested arrow function,
and suppressing a parent never hides unrelated child candidates inside it.

### Read cluster locations before refactoring

```text
CLUSTER 1 score=1.00 locations=2 status=unscoped same-name=validateUser
  src/auth.ts:12-25 nodes=88 kind=FunctionDeclaration name=validateUser
  src/admin/auth.ts:30-44 nodes=91 kind=FunctionDeclaration name=validateUser
```

The score is structural similarity, and the line ranges identify related duplicate regions for review. `nodes` is the normalized syntax node count for that duplicated block. `kind` is the candidate root SyntaxKind name (e.g. `FunctionDeclaration`, `Constructor`, `InterfaceDeclaration`, `ArrowFunction`) and `name` is the declaration identifier — so you can classify a finding without opening the file; `name=` is dropped for anonymous declarations (arrows, callable signatures), where JSON/EDN report `null`/`nil`. `status` is `unscoped` for a plain scan; under a changed-scope flag it becomes `new` (marked `status=new (intersects your change)`) or `known`.

Clusters are ranked: those whose same declaration name recurs across two or more files float to the top, tagged `same-name=<name>` (the strongest "real, copy-pasted duplicate" signal) — start your review there. On a large run (≥10 clusters) a curation footer prints to **stderr** naming the levers that cut noise (`--exclude-tests`, `--exclude-tagged-templates`, `--min-nodes`, `--exclude`) and estimating the reduction; stdout stays pure findings, and JSON/EDN never print it.

### Add per-location nearest-counterpart provenance (`--counterparts`)

```bash
bunx dry-ts src test --counterparts --json
```

`--counterparts` adds, per cluster location, its nearest matching counterpart —
the absolute strongest AST-similar partner in the same cluster — as `{ index,
file, startLine, endLine, shared, total, score }`, and (under an active change
scope) a per-location `changed` boolean. In a transitive cluster (>2 members) the
`score` range alone hides the edge structure; the counterpart resolves *which*
member a location actually matches and *how strongly* (`shared`/`total` are the
exact pairwise fingerprint-intersection/union counts, `score` is `shared/total`).
`index` dereferences within the same cluster's `locations`. In text it appends to
the location's own line: `… changed=true → nearest src/b.ts:5-33 (44/52)`. With
`changed` on both sides an agent routes the fix: counterpart `changed:true` ⇒
new/new (refactor the new code); `changed:false` ⇒ new/old (extract toward the
existing code). Opt-in, default off; off-path output is byte-for-byte unchanged.
`--only-new` filters whole clusters, never individual locations or counterparts.

### Diagnose a surprising changed-scope result

```bash
bunx dry-ts --explain-changed --changed-from HEAD src
```

`--explain-changed` dumps the resolved changed-region map (file → line ranges, with source: hunk / untracked / listed) to stderr, so a confusing `new`/`known` verdict — usually a path-normalization mismatch — is diagnosable in one rerun.

### Use the API from custom tooling

```ts
import { TypeScriptDuplicateFinder, type Cluster } from "dry-ts";

const finder = new TypeScriptDuplicateFinder();
const clusters = finder.findClusters({
  paths: ["src", "test"],
  threshold: 0.82,
  minLines: 4,
  minNodes: 20,
  excludeKinds: ["Constructor", "PropertySignature"],
  minDistinctKinds: 8,
  exclude: ["**/*.spec.*", "**/*.stories.*"],
  counterparts: true,
});

for (const cluster of clusters) {
  for (const location of cluster.locations) {
    const nearest = location.nearest;
    const ref = nearest ? ` → ${nearest.file}:${nearest.startLine} (${nearest.shared}/${nearest.total})` : "";
    console.log(`${cluster.score.max.toFixed(2)} ${location.file}:${location.startLine}${ref}`);
  }
}
```

With `counterparts: true`, each location carries a `nearest` field (`{ index,
file, startLine, endLine, shared, total, score }`); without it, `nearest` is
absent (the object shape is byte-identical to the default).

`findClusters` returns clusters with `status` unset — the `new`/`known`/`unscoped`
labels and the `--changed-from`/`--changed` scope are CLI behavior, not library
behavior.

## Common Mistakes

### HIGH Expect exact clone detection

Wrong:

```bash
bunx dry-ts src
```

Correct:

```bash
bunx dry-ts src
```

Treat the result as a fuzzy structural candidate list. dry-ts normalizes names and literal values away, so matches are not exact copy-paste proof.

Source: README.md:7

### MEDIUM Scan default src accidentally

Wrong:

```bash
bunx dry-ts
```

Correct:

```bash
bunx dry-ts src test
```

When no paths are passed, dry-ts scans only `src`, which can miss test duplication or package directories under review.

Source: README.md:46

### MEDIUM Use defaults for tiny candidates

Wrong:

```bash
bunx dry-ts src --threshold 0.82
```

Correct:

```bash
bunx dry-ts src --threshold 0.8 --min-lines 2 --min-nodes 8
```

The defaults require at least 4 source lines and 20 normalized nodes, so small repeated helpers or expressions are intentionally filtered out.

Source: README.md:35

### HIGH Tension: Signal versus noise

Lower thresholds and size filters catch more generated duplication but also increase candidate noise. Agents optimizing for zero findings tend to over-refactor harmless structural similarity.

See also: `adopt-dry-ts-in-agent-workflow/SKILL.md` - use cluster triage before extracting shared abstractions.

## References

- [Candidate selection and normalization](references/candidate-selection-and-normalization.md)

See also: `wire-duplicate-checks-into-ci/SKILL.md` - a local scan that becomes team policy needs `--fail-on-duplicates` and stable JSON semantics.
