UNPKG

dry-ts

Version:

Find candidate duplicate TypeScript code by comparing normalized AST structure.

796 lines (651 loc) 41.7 kB
# dry-ts dry-ts finds candidate duplicate TypeScript code across files and directories. It reports fuzzy structural matches as clusters of related filename and line ranges so another mechanisma CI gate, an AI agent, a human reviewercan evaluate and reduce duplication. It catches Type-2/Type-3 clonessame shape, renamed identifiers, reordered or slightly varied statementsthat token and line matchers miss. That is exactly the class an LLM produces when it reimplements existing structure, so dry-ts is built with **agents and PR gates as the primary consumers**. See [How it works](#how-it-works) for the engine. ## Quickstart ```bash # 1. Scan a source tree for candidate duplicates bunx dry-ts src # 2. PR gate — fail only if THIS change adds duplication (the recommended CI workflow) bunx dry-ts --profile pr --changed-from origin/main src # 3. Agent loop — after edits, emit only NEW duplication as JSON, each finding # routed to its nearest existing match bunx dry-ts --profile agent --changed-from HEAD src # 4. SARIF for GitHub code scanning (inline PR annotations) bunx dry-ts --sarif --changed-from origin/main src > dry-ts.sarif ``` `--profile pr` and `--profile agent` are curated presets (see [Profiles](#profiles---profile-name)). Both **require** a changed-scope flag and fail loud without one, so they never gate against the wrong base. - **Config:** commit a [`.dry-ts.json`](#config-file-dry-tsjson) so the repo sets its scan policy once instead of retyping flags. CLI flags and `--profile` still override it. - **Suppress an intentional repetition:** annotate it with [`// dry-ignore`](#suppressing-a-single-occurrence--dry-ignore), or exclude a whole path with `--exclude`. - **Full flag reference:** [Usage](#usage). **Cutting noise:** [Curating results](#curating-results). **What it is:** a TypeScript-first *structural* duplicate-**candidate** detector, built for PR gates and AI/agent consumers. **What it is not:** a general-purpose, multi-language copy/paste detector. For broad Type-1 token cloning across many languages with mature CI reporters, reach for [jscpd](https://github.com/kucherenko/jscpd) or PMD CPD. dry-ts reports structural *similarity candidates*, not confirmed semantic duplication — keep that framing when you triage. The point is to catch accidental reimplementation before it lands, not to "dedupe everything." **Maturity:** young package — pin the version (`dry-ts@x.y.z`). It has no install hooks and two runtime dependencies (`ignore`, `typescript`); `git` is spawned only for `--changed-from`. ### Output stability dry-ts is stateless — no baseline file — so findings are a pure function of the input and the normalization rules. Those rules (`src/TypeScriptNormalizer.ts`, `src/NormalizedNode.ts`) and the scoring can evolve, which means an upgrade can shift findings. The policy: - **Any change that can move findings is at least a MINOR version bump**, called out in [CHANGELOG.md](CHANGELOG.md) under that release. - **Pin `dry-ts@x.y.z` in CI** so a gate stays reproducible across runs, and read the changelog before bumping the pin. CI tools live or die on trust; treating output stability as part of semver is how a pinned gate stays honest. ## Usage Run without installing after the package is published: ```bash bunx dry-ts [options] [file-or-directory ...] npx dry-ts [options] [file-or-directory ...] ``` Run from this repository: ```bash bun install bun run build bun ./dist/bin/dry-ts.js [options] [file-or-directory ...] ``` Options: ```text --profile NAME Start from a curated preset (pr, src, audit, tests, agent), then apply explicit flags on top. See "Profiles". --threshold N Minimum structural similarity score, default 0.82 --min-lines N Minimum source lines in a candidate declaration, default 4 --min-nodes N Minimum normalized syntax nodes, default 20; candidates below this threshold are excluded before pair matching, so raising this speeds scans --min-locations N Minimum locations in a reported cluster, default 2 --format F text, json, edn, or sarif, default text --edn Same as --format edn --json Same as --format json --text Same as --format text --sarif Same as --format sarif (SARIF 2.1.0 for GitHub code scanning) --changed-from REF Incremental gating: mark clusters that intersect code changed since merge-base(REF, HEAD) as status "new". The scope is your working tree (committed + staged + unstaged). Untracked scanned files count as fully changed. Requires a git repository. --changed FILE Incremental gating: mark clusters intersecting FILE (every line) as status "new". Repeatable; for agents/non-git callers. Cannot be combined with --changed-from. --explain-changed Dump the resolved changed-region map to stderr for debugging. --only-new Restrict reported clusters to status "new". Output filter only: the exit code is unchanged (still governed by --fail-on-duplicates). Requires --changed-from/--changed. Totals print to stderr, e.g. "showing 6 new (73 known hidden)". --fail-on-duplicates Exit 1 on findings. With --changed-from/--changed, only clusters with status "new" gate; otherwise any cluster does. --no-gitignore Include files and directories ignored by .gitignore --exclude GLOB Skip files/directories matching a .gitignore-style glob, e.g. --exclude '**/*.spec.*'. Repeatable. Applies during directory scans regardless of --no-gitignore; explicit file arguments are always scanned. --exclude-tests Skip test files during directory scans: a curated preset of **/*.test.*, **/*.spec.*, **/*.e2e-spec.*, **/__tests__/**, and **/__mocks__/**, merged into the --exclude glob list (composes with any --exclude globs; explicit file arguments still scanned). Opt-in, default off; output byte-for-byte unchanged when off. --exclude-kinds KIND[,KIND...] Drop candidate declarations of the given SyntaxKinds before matching. Comma-separated and repeatable. Opt-in only: with no flag, output is unchanged. Useful for suppressing boilerplate false positives such as dep-only DI constructors (--exclude-kinds Constructor) or port/interface member signatures (--exclude-kinds PropertySignature,MethodSignature). An unknown or non-candidate kind name is a hard error. --exclude-tagged-templates Drop candidate declarations whose value is a tagged template literal (const X = styled(Button)``, css``, gql``). Opt-in, default off. Suppresses CSS-in-JS / styled-components clusters, a dominant false-positive class on frontend codebases. --counterparts Add, per cluster location, its nearest matching counterpart ({index, file, startLine, endLine, shared, total, score}) and — under an active change scope (--changed-from/--changed) — a per-location "changed" boolean. The nearest is the absolute strongest AST-similar partner in the same cluster (intra-cluster reference, so --only-new never orphans it). Opt-in, default off; off-path output is byte-for-byte unchanged. Note: --only-new filters whole clusters, never individual locations or counterparts. ``` Valid `--exclude-kinds` names are the candidate root kinds — the TypeScript AST node types dry-ts treats as comparable units. The names are TypeScript `SyntaxKind`s; what each one is in plain terms: | Name | What it is | | --- | --- | | `ClassDeclaration` | a `class Foo {}` declaration | | `InterfaceDeclaration` | an `interface Foo {}` declaration | | `TypeAliasDeclaration` | a `type Foo = ...` alias | | `EnumDeclaration` | an `enum Foo {}` declaration | | `ModuleDeclaration` | a `namespace Foo {}` / `module Foo {}` block | | `FunctionDeclaration` | a `function foo() {}` declaration | | `MethodDeclaration` | a method body in a class or object literal: `foo() {}` | | `Constructor` | a class `constructor() {}` | | `GetAccessor` | a getter: `get foo() {}` | | `SetAccessor` | a setter: `set foo(v) {}` | | `PropertyDeclaration` | a class field: `foo = ...` / `foo: T` | | `PropertySignature` | a property in an interface/type: `foo: T` | | `MethodSignature` | a method signature in an interface/type: `foo(): T` | | `CallSignature` | a callable signature in a type: `(arg: T): U` | | `ConstructSignature` | a constructable signature in a type: `new (): T` | | `IndexSignature` | an index signature: `[key: string]: T` | | `VariableStatement` | a `const` / `let` / `var` statement (the whole declaration line) | | `EnumMember` | a single member inside an enum | | `ArrowFunction` | an arrow function used as a value: `() => {}` | | `FunctionExpression` | a `function () {}` used as a value | Excluding a kind never hides a longer child candidate — children are always visited regardless. ## Curating results Out of the box on a large frontend monorepo a scan can report thousands of clusters, much of it *expected* duplication — test scaffolding, CSS-in-JS, generated code. The raw output is a candidate list, not a ranked verdict. Two things cut it down to the findings that matter: **Ranking.** Clusters where the *same declaration name* recurs across two or more distinct files float to the top, tagged `same-name=<name>` in the text header. A `validateUser` copied into another module is the strongest "real, copy-pasted duplicate" signal there is — near-zero false positive — so it leads the report regardless of score. Everything below is ordered strongest score first, as before. (A name repeated only *within* one file — overloads, shadowed locals — does not count; cross-file is the signal.) **The curation footer.** On a large run (≥10 clusters) the text format prints a short footer to **stderr** that names the levers which would cut the noise and estimates the reduction: ```text 2574 clusters. Curation levers (see README "Curating results"): - 1250 disappear with --exclude-tests (clusters that fall below --min-locations once test files are dropped) → ≈1324 left - --exclude-tagged-templates drops CSS-in-JS / styled-components clusters - --min-nodes N raises the size floor (currently 20); --exclude '<glob>' drops paths ``` It is a teaching aid, not a finding: it goes to stderr (not stdout), so it never pollutes the findings stream — pipe or redirect stdout and the footer stays out of it. The `≈` is honest — the `--exclude-tests` count is estimated from the reported clusters, not a re-scan, so a removed location that bridged two halves of a cluster can split it rather than delete it. JSON/EDN output never prints the footer. The levers themselves, roughly in order of leverage on a frontend codebase: `--exclude-tests` (test scaffolding is typically about half the noise), `--exclude-tagged-templates` (CSS-in-JS), `--exclude '<glob>'` (whole paths: generated code, fixtures, stories), `--min-nodes N` (raise the size floor), and the kind filters below. ### Profiles: `--profile NAME` Rather than rediscover the right flag combination per run, start from a curated preset. `--profile NAME` seeds a bundle of defaults; any explicit flag you pass overrides it. Precedence is **explicit flag > profile > [`.dry-ts.json`](#config-file-dry-tsjson) > built-in default**, and list flags (`--exclude-kinds`) **union** the profile's entries with yours rather than replacing them — so `--profile pr --exclude-kinds Constructor` excludes `ArrowFunction`, `VariableStatement`, *and* `Constructor`. | Profile | Expands to | For | | --- | --- | --- | | `pr` | `--exclude-tests --min-nodes 50 --exclude-kinds ArrowFunction,VariableStatement --only-new --fail-on-duplicates` | PR gate, highest signal. **Requires** `--changed-from`/`--changed` (it sets `--only-new`, which errors without a scope — so it fails loud rather than gating against the wrong base). | | `agent` | `pr` **+** `--counterparts --format json` | After-edit agent loop. The PR gate plus per-location [counterpart routing](#nearest-counterpart-provenance---counterparts) and JSON output, so an agent reads each new finding's nearest existing match. Inherits `pr`'s scope requirement. See [AI Agents](#ai-agents). | | `src` | `--exclude-tests` | Source-only scan with test scaffolding dropped. | | `audit` | `--min-nodes 12` | Broad exploratory scan — lower the floor to surface near-misses the default filters out. | | `tests` | `--exclude-kinds ArrowFunction --min-nodes 40` | Test-*infrastructure* duplication (shared setup/fixtures/builders), explicitly **not** the anonymous arrow bodies that dominate a raw test scan. Point it at your test directories. | A profile only ever *turns things on* (there is no negation flag to switch one back off), so each stays at the high-signal defaults its name implies. ### Config file: `.dry-ts.json` Commit a `.dry-ts.json` to the repo root so its scan policy lives in one file instead of a flag list every run (and so a [CI action](#ci) or [agent loop](#ai-agents) stays one line). dry-ts reads it from the working directory; it is plain JSON: ```json { "minNodes": 40, "excludeTests": true, "excludeKinds": ["ArrowFunction"], "ignore": ["**/*.gen.ts", "src/generated/**"] } ``` Precedence is **explicit CLI flag > `--profile` > `.dry-ts.json` > built-in default**: the config sets the repo's baseline, and anything you type for a single run still wins. List options (`excludeKinds`, `exclude`/`ignore`) **union** across the layers rather than replacing. `ignore` is an alias for `exclude` — both add `.gitignore`-style globs applied on top of `.gitignore`. Every persistable option is accepted: `threshold`, `minLines`, `minNodes`, `minLocations`, `minDistinctKinds`, `format`, `failOnDuplicates`, `respectGitignore`, `excludeKinds`, `exclude`, `ignore`, `excludeTaggedTemplates`, `excludeTests`, `counterparts`, and `paths`. The run-scoped flags `--changed-from`, `--changed`, and `--only-new` are CLI-only by design — they describe one invocation, not a committed baseline. The config fails the run loud (exit 2, naming the file) on an unknown key, a wrong value type, a fractional value for an integer count (`minLines`/`minNodes`/`minLocations`/ `minDistinctKinds`), or a present-but-unreadable file (a directory or a permission error — only a genuinely absent config is the silent no-op). It never silently scans with the wrong policy. > **Gate sharp edge.** A committed config's `paths`, `exclude`/`ignore`, > `respectGitignore`, `excludeTests`, `excludeTaggedTemplates`, `excludeKinds`, or > the `threshold`/numeric floors change *what* `--fail-on-duplicates` sees — a > committed `exclude` or a higher `minNodes` can shrink the gate so a real > duplicate slips through and the gate exits 0. This is > the config doing its job (committed policy), but to keep it from being silent, > under `--fail-on-duplicates` dry-ts prints the config-derived gate inputs to > stderr (`.dry-ts.json shapes this --fail-on-duplicates run: …`), like the > `--only-new` totals line. Keep CI-gating config narrow, and read that note. > Per-path overrides (a looser `threshold`/`minNodes` for `tests/**` than > `src/**` in one run) are not yet supported — tracked on > [#23](https://github.com/Dominik-O22/dry4ts/issues/23). Run separate scans, or > use `--profile` per tree, until then. ### Dropping near-uniform candidates: `--min-distinct-kinds` `--min-nodes` filters by raw node count, but a large candidate can still be near-uniform boilerplate — a property-only interface, a flat config object — that clears the node bar yet reaches the similarity threshold against any similarly-shaped block. `--min-distinct-kinds N` drops a candidate whose subtree spans fewer than `N` distinct node kinds, while keeping candidates with varied control flow. It complements `--min-nodes` (size) with a structure-variety floor. Default **off** (`0`); markers do not count toward kind diversity, only node kinds do. The off path tracks nothing, so it costs nothing. ### Suppressing CSS-in-JS declarations: `--exclude-tagged-templates` Styled-components and other tagged-template idioms — `const X = styled(Button)\`…\``, `styled('span')\`…\``, `css\`…\``, `gql\`…\`` — normalize to a near-identical AST: a `VariableStatement` whose initializer is a `TaggedTemplateExpression`, with `${p => p.theme.x}` arrow interpolations that add just enough distinct kinds to clear the diversity floor. So they cluster across dozens of files despite sharing no logic, and the existing reducers do not catch them (excluding `VariableStatement` wholesale would also kill real const-bound function duplicates). On a large frontend codebase like the Sentry corpus this is one of the single largest false-positive classes. `--exclude-tagged-templates` drops any candidate declaration whose value is a tagged template literal. It matches by structure rather than by tag name, so it suppresses `styled`, `css`, `gql`, and any styled alias uniformly, with no tag allowlist to maintain. Default **off**; when off, output is byte-for-byte unchanged and the check costs nothing. Like the other reducers it never stops recursion into children — a genuine duplicate nested inside a tagged template is still reported. ### Skipping test files: `--exclude-tests` Test files are the single largest false-positive class in real scans — ~82% of clusters on a Node/TS backend (n8n `cli/src`), ~49% on a frontend (Sentry). Most of that is table-driven test cases: identical arrange/act/assert blocks differing only in data. That repetition is **correct** — test bodies should be DAMP (descriptive and meaningful) over DRY, so readability and failure-localization win, and table-driven cases are the sanctioned form. `--exclude-tests` exists to focus a run on `src` duplication, **not** because test duplication never matters: real test *infrastructure* dup (builders, factories, custom matchers, shared setup) is worth its own dedicated scan — just point the tool at the test tree without this flag. The flag merges a curated preset (`**/*.test.*`, `**/*.spec.*`, `**/*.e2e-spec.*`, `**/__tests__/**`, `**/__mocks__/**`) into the `--exclude` glob list, so it composes with any explicit `--exclude` globs and follows the same rules (directory scans only; explicitly-named file arguments are always scanned). Bare `test/` / `tests/` / `e2e/` directories are deliberately left out of the preset — too many projects use those names for non-test code; add them with `--exclude '**/test/**'` if your layout needs it. Default **off**; when off, output is byte-for-byte unchanged. ### Suppressing a single occurrence: `// dry-ignore` For an intentional, idiomatic repetition that you do not want to exclude wholesale by kind or file, annotate the specific declaration at the source: ```ts // dry-ignore export function knownDuplicate(): void { // ... } ``` A `// dry-ignore` (or `// dry-ignore-next-line`) comment in a declaration's **leading trivia** drops that declaration as a candidate. The block-comment form `/* dry-ignore */` works too. No flag required; reads the existing source, no second parse. Placement rule: suppression is scoped to the node whose leading comment carries the directive — put it on the exact declaration you mean. A directive on a wrapping `const` statement suppresses that `VariableStatement` candidate but does **not** reach a nested arrow function, which keeps its own (separate) leading trivia and remains a candidate. Suppressing a parent never hides unrelated child candidates inside it. **Limitation — `// dry-ignore` is all-or-nothing and global to that declaration.** It drops the node as a candidate entirely, including against any *future* accidental clone of it. There is deliberately no "this particular pair is intentional, but keep watching each member for *other* clones" acknowledgment: that would be a stored, fingerprint-keyed baseline, and dry-ts is stateless by design (no snapshot to drift or rot). The consequence is honest: for an intentional N-member family (say 18 sibling templates) you either annotate all N declarations, or — the intended blunt instrument — exclude the whole path with `--exclude '<glob>'` (or `.gitignore`), accepting that it also hides any real duplicate that later lands there. For file- or glob-level ignores, exclude the path via `--exclude`/`.gitignore` (or `--no-gitignore` to override). To stop retyping `--exclude-tests --min-nodes 40 …` every run, commit a [`.dry-ts.json`](#config-file-dry-tsjson) — it persists *flags* (config state), not a findings baseline. ## Incremental gating `--fail-on-duplicates` on its own is zero-tolerance: any cluster anywhere fails the build, which no real codebase survives. Pair it with a changed-scope flag to gate only on duplication a change introduces — "no change makes the codebase wetter" — while still reporting known debt. No baseline file, no state. Every cluster carries a `status`: - `new` — at least one location intersects the changed scope. This is the *finding*, even when the counterpart location is old code (you copied something). Only `new` clusters gate under `--fail-on-duplicates`. - `known` — pre-existing duplication, entirely in unchanged code. Reported, never gates. - `unscoped` — emitted for every cluster when no changed-scope flag is active (the tool cannot know what is "known" without a scope). In a CI gate the actionable `new` clusters are easily buried under pre-existing `known` ones. `--only-new` filters the *report* down to `new` clusters across all formats (text/json/edn/sarif); the exit code still reflects the full set, so the gate behaves identically while the log stays readable. It requires a changed-scope flag (there is no `new` status without one). ### What counts as "changed" `--changed-from REF` resolves `merge-base(REF, HEAD)` and diffs from there, so a branch behind its base does not see base-side changes pollute the result. Write `--changed-from origin/main` and get correct PR semantics directly. The diff is taken against your **working tree**, so the scope includes **committed, staged, and unstaged** edits — an uncommitted change already gates. That is what makes it right for an agent loop that runs *before* committing. On top of that, any scanned file that git does not track counts as **fully changed** (a brand-new file is all-new, so a freshly-added duplicate cannot slip past the gate). A file renamed into scope with no edits gates nothing — moving code is not duplicating it. `--changed FILE` scopes the *whole* file (file granularity), including any pre-existing duplication inside it, and is the path for non-git callers; use `--changed-from` for line-level precision. When no paths are provided, dry-ts scans `src`. Directory arguments recursively include `.js`, `.jsx`, `.ts`, `.tsx`, `.mts`, and `.cts` files, excluding TypeScript declaration files. Directory scans respect `.gitignore` from the working directory by default; pass `--no-gitignore` to include ignored paths. Explicit file arguments are always scanned even when they match a `.gitignore` pattern. `--exclude GLOB` drops files and directories matching a `.gitignore`-style glob, e.g. `--exclude '**/*.spec.*' --exclude '**/*.stories.*'`. It is repeatable and applies during directory scans regardless of `--no-gitignore` (it is an explicit instruction, not repo config); explicit file arguments are still always scanned. This is the highest-leverage way to cut whole categories of expected duplication — on a large frontend codebase, test and story files alone are typically about half of all reported clusters. ### Output formats Default text output: ```text CLUSTER 1 score=0.89 locations=2 status=unscoped src/invoice.ts:12-25 nodes=88 kind=FunctionDeclaration name=renderInvoice src/receipt.ts:30-44 nodes=91 kind=FunctionDeclaration name=renderReceipt ``` Under a changed-scope, findings are marked: `status=new (intersects your change)`. A cluster whose declaration name recurs across files carries a trailing `same-name=<name>` tag (up to three names, then `(+N)`) and is ranked to the top — see [Curating results](#curating-results). Each location carries two diagnostic facts so a reader can classify a finding without opening the file: `kind` (the candidate root SyntaxKind — `Constructor`, `InterfaceDeclaration`, `ArrowFunction`, …) and `name` (the declaration identifier). A `Constructor` is named `constructor`; an anonymous candidate (an arrow function, a callable signature) has no name — the text format drops the `name=` token, JSON/EDN report `null`/`nil`. EDN output: ```clojure {:clusters [{:score-min 0.8909090909090909 :score-max 0.8909090909090909 :status :unscoped :location-count 2 :locations [{:file "src/invoice.ts", :start-line 12, :end-line 25, :nodes 88, :kind "FunctionDeclaration", :name "renderInvoice"} {:file "src/receipt.ts", :start-line 30, :end-line 44, :nodes 91, :kind "FunctionDeclaration", :name "renderReceipt"}]}]} ``` JSON output: ```json { "clusters": [ { "score": { "min": 0.8909090909090909, "max": 0.8909090909090909 }, "status": "unscoped", "locationCount": 2, "locations": [ { "file": "src/invoice.ts", "startLine": 12, "endLine": 25, "nodes": 88, "kind": "FunctionDeclaration", "name": "renderInvoice" }, { "file": "src/receipt.ts", "startLine": 30, "endLine": 44, "nodes": 91, "kind": "FunctionDeclaration", "name": "renderReceipt" } ] } ] } ``` `--format sarif` emits SARIF 2.1.0 for GitHub code scanning — see [SARIF](#sarif--github-code-scanning). ## CI Gate a PR only when it introduces *new* duplication, tolerating known debt, with `--changed-from` against the PR's base branch. This is the one copy-paste action: ```yaml name: Duplicate Code on: [push, pull_request] jobs: dry-ts: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: # merge-base needs history; the default shallow checkout breaks it. fetch-depth: 0 - uses: oven-sh/setup-bun@v2 with: bun-version: 1.3.6 - run: bunx dry-ts@x.y.z --profile pr --changed-from origin/${{ github.base_ref || 'main' }} src ``` Pin `dry-ts@x.y.z` to a real version (see [Output stability](#output-stability)). To gate on *all* duplication (zero-tolerance) instead, drop `--changed-from` and the `pr` profile: `bunx dry-ts --fail-on-duplicates src`. For this repository, `bun run ci` builds, tests, and runs dry-ts against `src test`. ### SARIF / GitHub code scanning `--format sarif` (or `--sarif`) emits SARIF 2.1.0, the lingua franca for GitHub code scanning and most CI quality dashboards. One `result` per cluster under the rule `dry-ts/structural-duplicate`; each location maps to a `physicalLocation`, and `--counterparts` nearest data maps to `relatedLocations` joined back by a `relevant` relationship. A cluster's `level` follows its status: `new` (intersects the change) → `warning`, `known`/`unscoped``note`. Findings are framed as structural *candidates*, not confirmed duplicates. Upload the report so findings surface inline on the PR: ```yaml - run: bunx dry-ts --sarif --changed-from origin/${{ github.base_ref || 'main' }} src > dry-ts.sarif - uses: github/codeql-action/upload-sarif@v3 with: sarif_file: dry-ts.sarif ``` Pair with `--counterparts` to enrich each finding with its nearest counterpart. Run without `--fail-on-duplicates` if you want the annotations without failing the build. ## AI Agents If you use an AI agent, run `npx @tanstack/intent@latest install`. dry-ts is built for agents as a first-class consumer: run it after an edit, parse the JSON, and route each finding. The `agent` profile bundles exactly that configuration. ```bash # After an agent edits code: gate on duplication the edit introduced and hand # the agent routed JSON — each new cluster with its nearest existing match. bunx dry-ts --profile agent --changed-from HEAD src test ``` The loop: 1. **Run after edits** with `--profile agent` (= `--only-new --counterparts --fail-on-duplicates --format json` over the `pr` floors). 2. **Read the exit code.** `0` = clean. `1` = new duplication found, with the JSON below on stdout. `2` = infra/config failure — do **not** read it as findings. 3. **Per cluster, read `locations[].nearest`** — the nearest existing match — and its `changed` flag to route the fix: - counterpart `changed: false` ⇒ the new code duplicates **existing** code → reuse / extract toward the existing definition. - counterpart `changed: true` ⇒ the agent reimplemented **itself** within its own diff → refactor the new code (lowest-risk; nothing stable depends on it yet). 4. **Reuse existing code, or justify the duplication** (a `// dry-ignore` if it is intentional). Exit codes are stable for automation: ```text 0 success: no findings, or no --fail-on-duplicates 1 findings with --fail-on-duplicates (status "new" under a changed-scope; any cluster otherwise) 2 usage/configuration error, or any git/scanner failure (fail-closed) ``` The gate fails closed: a missing git binary, a bad ref, unparseable diff output, an unreadable source file, or zero files scanned under `--fail-on-duplicates` all exit 2 with a message — never a silent green or a 1 that reads as "findings". The JSON shape is intentionally small and stable: `{ "clusters": ClusterReport[] }`. Each cluster includes a `score` range, a `status` (`"new" | "known" | "unscoped"`), `locationCount`, and grouped `locations`. Each location includes `nodes` (the normalized syntax node count for that duplicated block), `kind` (the candidate root SyntaxKind name), and `name` (the declaration identifier, or `null` when anonymous). `kind` and `name` let an agent triage a finding — e.g. skip a `Constructor` in a `*.spec.ts` as dependency-injection boilerplate — without a second read of the source. ### Nearest-counterpart provenance: `--counterparts` A cluster's `score` range and member list do not say, for a given location, *which* member it actually matches and *how strongly* — in a transitive cluster (>2 members) the range hides the edge structure. `--counterparts` adds that missing payload: per location, its nearest matching counterpart (the absolute strongest AST-similar partner in the same cluster) as `{ index, file, startLine, endLine, shared, total, score }`, plus — under an active change scope — a per-location `changed` boolean. `index` is the counterpart's position in the same cluster's `locations` array (an O(1) deref); `file`/`startLine`/`endLine` are the self-contained reference; `shared`/`total` are the exact pairwise fingerprint-intersection and union counts; `score` is `shared / total`, the same similarity value the cluster reports, so you never recompute a float. ```json { "clusters": [ { "score": { "min": 0.8205128205128205, "max": 1 }, "status": "new", "locationCount": 3, "locations": [ { "file": "new_a.ts", "startLine": 1, "endLine": 4, "nodes": 41, "kind": "FunctionDeclaration", "name": "summarizeRows", "nearest": { "index": 2, "file": "old.ts", "startLine": 1, "endLine": 4, "shared": 34, "total": 34, "score": 1 }, "changed": true }, { "file": "new_b.ts", "startLine": 1, "endLine": 5, "nodes": 46, "kind": "FunctionDeclaration", "name": "reduceEntries", "nearest": { "index": 0, "file": "new_a.ts", "startLine": 1, "endLine": 4, "shared": 32, "total": 39, "score": 0.8205128205128205 }, "changed": true }, { "file": "old.ts", "startLine": 1, "endLine": 4, "nodes": 41, "kind": "FunctionDeclaration", "name": "normalizeRecord", "nearest": { "index": 0, "file": "new_a.ts", "startLine": 1, "endLine": 4, "shared": 34, "total": 34, "score": 1 }, "changed": false } ] } ] } ``` Reading this: `new_a` re-implements `old` exactly (`score 1`, a tight pair), while `new_b` chains in more loosely (`shared 32/39`, `score 0.82`). The `changed` flag on both sides tells an agent how to route the fix: - counterpart `changed: true` ⇒ **new/new** — the agent reimplemented itself within its own diff; refactor the new code (highest-confidence, lowest-risk fix, nothing stable depends on it yet). - counterpart `changed: false` ⇒ **new/old** — the new code duplicates existing code; extract toward the existing definition. The nearest is always a member of the *same* cluster, so the `index` always dereferences within that cluster's `locations`, and `--only-new` (which filters whole clusters, never individual locations or counterparts) never orphans it. The reported `score`/`shared`/`total` describe the edge between the two **rendered** locations. (One rare exception: when a location's only structural match is to a substructure *inside* a larger member — e.g. it matches another declaration's inner body but not the whole declaration — the counterpart resolves to that enclosing rendered member, and the score reflects the substructure edge.) The **full payload** (`index` + `score` included) lives in `json` and `edn`. The `text` format keeps one scannable line per location and appends an **abbreviated** counterpart — `→ nearest <file>:<start>-<end> (<shared>/<total>)` — without `index` or `score`; read `--format json`/`edn` when an agent needs those fields. Copy-paste recipes: ```bash # Self-catch: did the block I just wrote re-implement existing structure? dry-ts --counterparts --json --changed src/foo.ts src test # Line-precise self-catch against the last commit, gated (or just --profile agent). dry-ts --counterparts --only-new --fail-on-duplicates --changed-from HEAD src test # CI fixer: gate a PR and hand a reviewer/fixer agent the routed JSON. dry-ts --profile agent --changed-from origin/main src test ``` On a finding, exit `1` still emits the parseable JSON above on stdout — read it. Exit `2` is an infra/config failure (see the exit-code table) and must **not** be read as duplicate findings. ## Library API ```ts import { TypeScriptDuplicateFinder } from "dry-ts"; const clusters = new TypeScriptDuplicateFinder().findClusters({ paths: ["src"], threshold: 0.82, minLines: 4, minNodes: 20, minLocations: 2, respectGitignore: true, // default; set false to include .gitignore-d paths }); ``` `findClusters()` returns raw clusters with `status` unset. The changed-scope flags (`--changed-from`, `--changed`) and the `status` field (`"new" | "known" | "unscoped"`) are assigned by the CLI, not the library finder. ## How it works dry-ts parses TypeScript source with the TypeScript compiler API, selects TypeScript declarations and function-like nodes as comparison candidates, normalizes each candidate's AST, and compares sets of structural fingerprints with Jaccard similarity: ```text score = shared fingerprints / all fingerprints seen in either candidate ``` Names and literal values normalize away, while TypeScript syntax shape remains. Classes, interfaces, type aliases, enums, functions, methods, constructors, properties, variable statements, accessors, enum members, arrow functions, and function expressions can all become candidates. ### How dry-ts differs from token and line matchers Most duplicate-code tools match **tokens** or **lines**. dry-ts matches **normalized AST structure**. The difference is which kind of clone each can see, in the standard Type 14 clone taxonomy: - **Type 1** — identical code, modulo whitespace and comments. - **Type 2** — Type 1 with renamed identifiers and changed literals; same structure. - **Type 3** — near-miss: statements added, removed, or reordered. - **Type 4** — semantically equivalent but structurally different. | Tool | Method | Catches | | --- | --- | --- | | Simian | line hashing (ignores whitespace, braces, comments) | mostly Type 1 | | jscpd | contiguous token-sequence matching (Rabin–Karp over Prism tokens) | Type 1 | | PMD CPD | contiguous token-sequence matching (Rabin–Karp / suffix tree); can normalize identifiers and literals | Type 1, Type 2 | | **dry-ts** | **set similarity over normalized-AST fingerprints (Jaccard)** | **Type 2 and Type 3** | Two properties follow from comparing *sets* of structural fingerprints instead of contiguous token runs: 1. **Rename- and reorder-tolerant.** Names and literals normalize away before fingerprinting, and Jaccard scores *partial* overlap, so two blocks with the same shape but added, removed, or reordered statements still score high. Token- and line-sequence matchers need a contiguous run, so a single insertion splits the match. (PMD CPD's `ignore-identifiers` / `ignore-literals` reach Type 2, but still match contiguous token sequences — not fuzzy structural overlap.) 2. **Graded, not binary.** The output is a similarity score (default ≥ 0.82), not "≥ N identical tokens" — you tune by structural similarity, not run length. dry-ts does **not** target Type 4 (semantic) clones; it compares structure, not behavior. What it adds over token/line matchers is the Type-2/Type-3 middle: same shape, different names, slight variations. That is exactly the class an LLM produces when it reimplements existing structure — and dry-ts is built for **agents as the primary consumer** of its output (catching their own reimplementations, or gating others' in CI), not for humans reading copy-paste reports. ## Publishing Before publishing: ```bash bun install --frozen-lockfile bun run ci bun run pack:dry-run npm publish ``` ## Development ```bash bun run test bun run check bun run ci bun run bench -- /path/to/project/src /path/to/project/tests ``` ### Benchmarking Three corpus tiers, all scanned with `bun run bench -- <paths>`: 1. **Real mid-size project** — any ~30k LOC repository you have locally. Use it as a regression check: cluster output should stay identical across performance changes, and timing should not regress. 2. **Pinned large repositories** — `bun run bench:setup` fetches three pinned real-world corpora into `.bench/` (gitignored). Pass a name (`bun run bench:setup sentry`) to fetch just one. - `microsoft/TypeScript` at a statically pinned tag (`v5.9.3`, chosen by maintainers to match the current `typescript` dependency — bumped by hand, not resolved automatically). Scan `.bench/TypeScript/src/compiler` for a worst-case stress: very large files, deeply nested ASTs, and high structural self-similarity. Already pushed quite low (~1.5s), so it has little regression headroom. - `getsentry/sentry` (sparse blobless clone of `static/app` only). Scan `.bench/sentry/static/app` — a large, messy real-world TS/TSX frontend (~6.8k files, ~2.6k clusters). Wider and more varied than the compiler subtree, so it surfaces hot-path regressions the compiler scan would miss. - `n8n-io/n8n` (sparse blobless clone of two subtrees). A large real-world Node/TS backend that keeps the corpus from hyper-indexing on frontend code. Two complementary scan targets, both checked out by `bench:setup n8n`: - `.bench/n8n/packages/nodes-base/nodes` (~3.7k files, ~20 MB) — declarative integration nodes with real near-duplicate boilerplate; volume + recall and pair-comparison stress. This is the documented default baseline. - `.bench/n8n/packages/cli/src` (~1.9k files, ~14 MB) — the server itself (controllers, services, entities, queue, auth), representative imperative backend app logic. Scan it explicitly with `bun run bench -- <path>`. 3. **Synthetic regimes** — `bun run bench:corpus <regime>` generates a deterministic corpus into `.bench/corpus/<regime>`: - `identical` (default 800 functions): dense identical structures, stresses the pairwise comparison phase - `oneliners` (default 10000): trivial declarations below `--min-lines`, stresses entry filtering - `nested` (default depth 300): deeply nested expressions, stresses fingerprint construction Both `bench:corpus` and `bench` pass `--no-gitignore` so corpus paths under `.bench/` (which is gitignored) are scanned correctly. `bench:corpus` also accepts `--count N` to override the default size and `--out DIR` to write the corpus to a custom directory. Example: ```bash bun run bench:corpus identical -- --count 1200 bun run bench -- --runs 5 .bench/corpus/identical ``` Baselines (use as regression checks — cluster counts must stay fixed and timing must not regress across changes): - TypeScript v5.9.3 `src/compiler`: ~1.5s, 246 clusters (since v0.3.0). - Sentry 25.10.0 `static/app`: ~5.3s, 2574 clusters (since v0.5.0, `--exclude-kinds` hot-path change measured against it). - n8n 2.25.7 `packages/nodes-base/nodes`: ~3.3s, 1720 clusters (since v0.8.0). - n8n 2.25.7 `packages/cli/src`: ~2.9s, 1650 clusters (since v0.8.0).