<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Bob Taylor]]></title><description><![CDATA[Bob Taylor]]></description><link>https://bobtaylor.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Bob Taylor</title><link>https://bobtaylor.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Thu, 17 Sep 2026 06:22:37 GMT</lastBuildDate><atom:link href="https://bobtaylor.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How to Add Python Duplicate-Code Detection to GitHub Actions]]></title><description><![CDATA[Duplicate code is easiest to deal with before it becomes part of the codebase. That makes duplicate-code detection a natural CI check: run the same rules on every pull request, give developers a way t]]></description><link>https://bobtaylor.hashnode.dev/how-to-add-python-duplicate-code-detection-to-github-actions</link><guid isPermaLink="true">https://bobtaylor.hashnode.dev/how-to-add-python-duplicate-code-detection-to-github-actions</guid><category><![CDATA[Python]]></category><category><![CDATA[github-actions]]></category><category><![CDATA[Code Quality]]></category><category><![CDATA[Continuous Integration]]></category><category><![CDATA[Developer Tools]]></category><dc:creator><![CDATA[spongeb0b]]></dc:creator><pubDate>Wed, 09 Sep 2026 03:50:59 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a81e13cc6b5d0b7614be7da/422f27d9-c946-404b-825a-f110eef6bde1.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Duplicate code is easiest to deal with before it becomes part of the codebase. That makes duplicate-code detection a natural CI check: run the same rules on every pull request, give developers a way to reproduce the result locally, and catch new duplication before it quietly becomes another piece of technical debt.</p>
<p>The GitHub Actions part is straightforward. If a duplicate-code checker has a CLI, meaningful exit codes, and predictable output, GitHub Actions can run it. The more important decisions are what you scan, what constitutes a failure, and what to do when an existing repository already contains duplication.</p>
<p>Those decisions determine whether the check becomes a useful engineering constraint or just another red CI job that everyone learns to ignore.</p>
<h2>CI Should Enforce a Policy, Not Just Run a Command</h2>
<p>The simplest duplicate-code policy is also the strictest: if any duplicate code is found, fail the build. That works well for a new project or a repository that is already clean, but it can be a bad adoption strategy for a mature codebase.</p>
<p>Suppose you add duplicate detection to a five-year-old project and the first run finds 150 existing duplicate groups. The detector has done exactly what you asked, but CI can no longer pass until someone fixes five years of accumulated debt. Teams faced with that situation usually end up choosing between stopping feature work, accepting a permanently failing check, or disabling the check again. None of those outcomes are especially useful.</p>
<p>Before adding the workflow, decide what you actually want CI to enforce. You may want zero duplication, you may initially want visibility without enforcement, or you may want to accept existing duplication while preventing new duplication from being introduced. Those are three different policies even though they can all use the same detector.</p>
<h2>A Minimal GitHub Actions Workflow</h2>
<p>GitHub Actions does not need any special understanding of duplicate-code detection. At its simplest, the job checks out the repository, runs the detector over the Python source, and interprets the result.</p>
<p>For the examples here I am using <a href="https://github.com/sponge-b0b/arid">Arid</a>, the Python duplicate-code checker I built to provide focused, Python-aware duplicate detection without requiring a general-purpose linter. Arid has an official GitHub Action, so a minimal workflow looks like this:</p>
<pre><code class="language-yaml">name: Duplicate code

on:
  pull_request:
  push:
    branches: [main]

jobs:
  duplicate-code:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Check for duplicate Python code
        uses: sponge-b0b/arid@v2.2.3
        with:
          paths: .
</code></pre>
<p>That is enough for a basic enforcing check. The Action handles the Python setup and installation needed to run Arid, scans the repository, and fails when active duplicate findings are present.</p>
<p>For a new or already-clean project, this may be all you need.</p>
<h2>Keep Detection Policy in the Project</h2>
<p>One of the easiest ways to make CI irritating is to hide important tool configuration inside the workflow file. Developers then run one thing locally while CI quietly runs something else.</p>
<p>A better separation of responsibilities is to let GitHub Actions decide <strong>when</strong> the tool runs and let the project configuration decide <strong>how</strong> it runs.</p>
<p>For example:</p>
<pre><code class="language-toml">[tool.arid]
min-lines = 6
exclude = [
    "generated/**",
]
</code></pre>
<p>The workflow itself can remain unchanged:</p>
<pre><code class="language-yaml">- name: Check for duplicate Python code
  uses: sponge-b0b/arid@v2.2.3
  with:
    paths: .
</code></pre>
<p>A developer can reproduce the same policy locally with:</p>
<pre><code class="language-bash">arid .
</code></pre>
<p>That property matters more than it may appear. CI failures are much easier to work with when the developer can reproduce the exact same check before pushing another commit. The workflow should automate the repository's quality policy, not define a second, hidden version of it.</p>
<h2>Decide Whether Findings Should Fail CI</h2>
<p>Enforcement does not have to begin on day one. In an existing codebase, it can be useful to run duplicate detection for a while before making findings blocking. That gives you a chance to understand what the detector reports, adjust exclusions or thresholds, and decide what level of duplication the team actually wants to enforce.</p>
<p>With the Arid Action, that can be done by disabling failure on findings:</p>
<pre><code class="language-yaml">- name: Report duplicate Python code
  uses: sponge-b0b/arid@v2.2.3
  with:
    paths: .
    fail-on-findings: "false"
</code></pre>
<p>The important distinction is that this changes the policy for <strong>duplicate findings</strong>, not the meaning of every possible failure. A completed scan that finds duplication is different from a scan that cannot complete because of invalid configuration, unreadable source, or another operational problem.</p>
<p>That distinction is worth preserving in any CI tool. "We found something and chose not to block on it" is a policy decision. "The analysis did not complete" is a different state entirely.</p>
<p>For many mature repositories, a reasonable adoption path is to begin in reporting mode, tune the check, and only then turn it into a required gate. There is little value in introducing a quality check so aggressively that the first lesson developers learn is how to bypass it.</p>
<h2>Brownfield Repositories Need a Different Strategy</h2>
<p>Reporting-only mode is useful during evaluation, but eventually you may want to enforce duplicate-code policy without first eliminating every duplicate that already exists.</p>
<p>That is where baselining becomes useful.</p>
<p>A baseline records the findings that already exist and treats them as acknowledged debt. Future scans can then distinguish between duplication the repository already had and duplication introduced later. The CI policy becomes "do not add new duplicate code" rather than "repair the entire history of the repository before the next pull request can merge."</p>
<p>That is often a much more practical constraint for a brownfield project. Existing debt remains visible and can be reduced over time, but it does not prevent the team from enforcing a better rule going forward.</p>
<p>There are enough subtleties around baseline creation, maintenance, stale findings, and debt reduction that baselines deserve a separate article. The important point here is that CI enforcement does not have to be binary. You do not have to choose between fixing everything immediately and ignoring duplication forever.</p>
<h2>Duplicate Detection Is a Corpus-Level Problem</h2>
<p>There is another CI mistake that is less obvious: scanning only the files changed by the pull request.</p>
<p>That optimization makes sense for many tools. A formatter can format one file. A linter can inspect one file. A type checker may be able to limit part of its work to a dependency boundary.</p>
<p>Duplicate detection is different because a duplicate is a relationship between two or more regions of code.</p>
<p>Suppose a pull request adds this:</p>
<pre><code class="language-python">def normalize_items(items):
    result = []

    for item in items:
        if item is None:
            continue

        result.append(str(item).strip())

    return result
</code></pre>
<p>The duplicate may already exist in <code>src/legacy/importer.py</code>. If CI analyzes only the newly changed file, there is nothing to compare it with. The file appears unique because the rest of the corpus has been removed from the analysis.</p>
<p>This is one of those cases where a performance optimization can quietly change the question being asked. "Does this changed file contain something that duplicates code elsewhere in the repository?" cannot be answered by looking only at the changed file.</p>
<p>Arid separates those concerns with its <code>focus</code> option. The full corpus can still be analyzed while reporting is restricted to findings that touch the area you care about:</p>
<pre><code class="language-yaml">- name: Check package for new duplicate code
  uses: sponge-b0b/arid@v2.2.3
  with:
    paths: .
    focus: src/package
</code></pre>
<p>The distinction is important: <strong>focus the report, not the detector</strong>. You can reduce noise without throwing away the context required to detect duplication correctly.</p>
<h2>CI Results Should Be Useful to Both Humans and Automation</h2>
<p>A command returning success or failure is enough to build a gate, but CI usually benefits from richer output.</p>
<p>The Arid Action writes a Markdown report to the GitHub job summary by default, which means developers do not have to dig through raw workflow logs just to see what was found. It also exposes structured outputs such as duplicate-group counts, duplicated lines, duplication percentage, affected files, occurrence counts, scan completeness, and the underlying scan exit code.</p>
<p>For example:</p>
<pre><code class="language-yaml">- name: Check for duplicate Python code
  id: arid
  uses: sponge-b0b/arid@v2.2.3
  with:
    paths: .

- name: Show duplicate metrics
  run: |
    echo "Duplicate groups: ${{ steps.arid.outputs.duplicate-groups }}"
    echo "Duplicate lines: ${{ steps.arid.outputs.duplicate-lines }}"
</code></pre>
<p>You may never need those outputs, and that is fine. Their value is that another piece of automation does not have to parse terminal text intended for a human.</p>
<p>Human-readable output and machine-readable output serve different purposes. Trying to make one format do both usually produces output that is unpleasant for humans and fragile for machines.</p>
<h2>Add SARIF Only If It Improves the Workflow</h2>
<p>GitHub code scanning understands SARIF, so Arid can also publish duplicate findings through that interface. If your team already uses GitHub code scanning for static-analysis results, putting duplicate findings there can make sense.</p>
<p>A workflow enabling SARIF looks like this:</p>
<pre><code class="language-yaml">name: Duplicate code

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read
  security-events: write

jobs:
  duplicate-code:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Check for duplicate Python code
        uses: sponge-b0b/arid@v2.2.3
        with:
          paths: .
          sarif: "true"
</code></pre>
<p>I would not enable SARIF merely because it exists. If developers already get everything they need from the job summary, adding another reporting surface may not improve anything.</p>
<p>The useful architectural property is that reporting remains separate from analysis. Arid performs the scan once and can derive the job summary, Action outputs, and optional SARIF result from that same analysis. You should not need to run the detector three times because three consumers want the result in three different formats.</p>
<h2>A Reasonable Adoption Sequence</h2>
<p>For a new or clean repository, I would start with the simple enforcing workflow:</p>
<pre><code class="language-yaml">name: Duplicate code

on:
  pull_request:
  push:
    branches: [main]

jobs:
  duplicate-code:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Check for duplicate Python code
        uses: sponge-b0b/arid@v2.2.3
        with:
          paths: .
</code></pre>
<p>For an established repository where you do not yet know what the check will uncover, I would begin with the same workflow but set <code>fail-on-findings</code> to <code>false</code>. Once the configuration is producing useful results, decide whether the repository should enforce zero duplication or whether existing findings should be baselined so that only new duplication fails CI.</p>
<p>That progression is intentionally boring. Good CI policy usually is.</p>
<p>The important part is that the detector does not dictate the adoption model. A greenfield project, a project evaluating the tool, and a brownfield project with years of historical duplication can all use the same underlying analysis while enforcing different policies.</p>
<h2>Is Arid the Right Tool for This?</h2>
<p>Not necessarily.</p>
<p>If Pylint <code>R0801</code> already performs well enough for your repository and fits the rest of your tooling, there may be no reason to replace it. If you have a multi-language monorepo and want one clone detector for everything, a Python-specific tool is probably the wrong abstraction. If your real requirement is semantic clone detection—finding code that behaves similarly even though it is written differently—you need a detector designed for that problem.</p>
<p>Arid deliberately has a narrower scope. It detects exact duplicated Python source after configurable Python-aware normalization, and it is designed to run alongside tools such as Ruff rather than become another general-purpose linter.</p>
<p>That narrowness is why I built it. I had already moved most of my Python linting workflow to Ruff but still wanted corpus-level duplicate detection. Carrying a general-purpose linter primarily for one expensive check felt like the wrong dependency boundary, so I built a tool whose responsibility begins and ends with duplicate-code detection.</p>
<p>The local workflow is intentionally unremarkable:</p>
<pre><code class="language-bash">ruff check .
arid .
</code></pre>
<p>The GitHub Action simply makes the second command part of the repository's normal engineering process.</p>
<h2>The YAML Is the Easy Part</h2>
<p>Adding duplicate-code detection to GitHub Actions takes only a few lines. Deciding what those lines should mean is the more interesting part.</p>
<p>A good CI check answers a clear policy question. Are duplicate findings always forbidden? Are you still evaluating the signal? Are existing findings accepted while new ones are blocked? Are you analyzing the whole repository even when you only want to report findings in a particular area?</p>
<p>Those choices determine whether duplicate detection becomes useful or merely becomes present.</p>
<p>The goal is not another green checkmark in a pull request. The goal is a constraint developers understand, can reproduce locally, and can keep green without working around it. Once the policy is clear, GitHub Actions is just the mechanism that makes it consistent.</p>
<hr />
<p><strong>Arid</strong> is an open-source Python duplicate-code checker written in Rust, designed as a focused replacement for Pylint <code>R0801</code> and to complement Ruff.</p>
<p>GitHub: <a href="https://github.com/sponge-b0b/arid">https://github.com/sponge-b0b/arid</a></p>
<p>GitHub Marketplace: <a href="https://github.com/marketplace/actions/arid-duplicate-code-check">https://github.com/marketplace/actions/arid-duplicate-code-check</a></p>
<h3>About the Author</h3>
<p>Bob Taylor is a software engineer and architect who builds developer tools and AI systems. He is currently developing <a href="https://github.com/sponge-b0b/arid">Arid</a> and <a href="https://github.com/sponge-b0b/Polaris">Polaris</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Duplicate-Code Detection for Ruff Users]]></title><description><![CDATA[Moving a Python project to Ruff can be strangely satisfying.
A collection of linting tools and plugins becomes one fast executable. Configuration gets simpler. Checks that used to take long enough to ]]></description><link>https://bobtaylor.hashnode.dev/duplicate-code-detection-for-ruff-users</link><guid isPermaLink="true">https://bobtaylor.hashnode.dev/duplicate-code-detection-for-ruff-users</guid><category><![CDATA[Python]]></category><category><![CDATA[ruff]]></category><category><![CDATA[Software Engineering]]></category><category><![CDATA[Code Quality]]></category><category><![CDATA[Developer Tools]]></category><dc:creator><![CDATA[spongeb0b]]></dc:creator><pubDate>Wed, 26 Aug 2026 04:19:55 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a81e13cc6b5d0b7614be7da/b29eefaf-f07d-4fbb-a68a-b6f630c3a0fd.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Moving a Python project to Ruff can be strangely satisfying.</p>
<p>A collection of linting tools and plugins becomes one fast executable. Configuration gets simpler. Checks that used to take long enough to interrupt the development loop become cheap enough to run constantly. Ruff now provides more than 900 lint rules, along with formatting, import sorting, automatic fixes, caching, <code>pyproject.toml</code> configuration, and editor integrations.</p>
<p>For many projects, that's enough to eliminate a significant portion of the traditional Python tooling stack.</p>
<p>Then you go looking for duplicate-code detection.</p>
<p>Ruff has rules that detect specific forms of duplication—duplicate exception handlers, duplicate entries in <code>__all__</code>, duplicate set values, and similar local mistakes—but it does not provide a general cross-file duplicate-code detector comparable to Pylint's <code>R0801</code>.</p>
<p>That leaves Ruff users with an interesting choice. You can add a broader analysis tool back into the stack to recover one capability, ignore duplication entirely, or pair Ruff with a tool focused specifically on the problem.</p>
<p>I ended up choosing the third option.</p>
<h2>Ruff Doesn't Need to Do Everything</h2>
<p>It's tempting to think of adopting a tool as choosing a winner.</p>
<p>If Ruff replaces Flake8, isort, Black, and a collection of plugins, the natural question becomes: why can't it replace everything else too?</p>
<p>I don't think that's necessarily the right goal.</p>
<p>Ruff is already extraordinarily broad. Its rules are inspired by or compatible with a long list of existing Python tools, but Ruff reimplements those rules in Rust as first-party functionality rather than invoking the original tools underneath.</p>
<p>That architecture is a big part of what makes Ruff useful, but there's still a difference between adding another lint rule and adding another kind of analysis.</p>
<p>Consider unused imports:</p>
<pre><code class="language-python">import pathlib
</code></pre>
<p>A linter can inspect that module and determine whether <code>pathlib</code> is used.</p>
<p>Now consider this:</p>
<pre><code class="language-python">def normalize_customer(customer):
    name = customer.name.strip()
    email = customer.email.lower().strip()
    return name, email
</code></pre>
<p>There may be absolutely nothing wrong with that function when considered by itself.</p>
<p>The problem appears when essentially the same implementation exists somewhere else in the repository.</p>
<p>Duplicate detection therefore asks a different question:</p>
<blockquote>
<p>Where else in this corpus does this code occur?</p>
</blockquote>
<p>That's inherently a broader problem than many ordinary lint rules. The unit of reasoning isn't necessarily one statement, function, or file. It's the source corpus.</p>
<p>That doesn't mean Ruff could never perform this kind of analysis. It means I wouldn't judge Ruff by whether every conceivable form of Python analysis eventually becomes a Ruff rule.</p>
<p>Sometimes composition is a perfectly good architecture.</p>
<h2>What Are the Options?</h2>
<p>If you're using Ruff and want duplicate-code detection, there are several reasonable approaches.</p>
<p>The first is to do nothing.</p>
<p>That isn't sarcasm. Not every project needs an automated duplication check. Small projects may make duplication obvious during review, and some teams simply don't consider it important enough to justify another tool.</p>
<p>The second option is Pylint.</p>
<p>Pylint's <code>R0801</code>, or <code>duplicate-code</code>, is the established Python solution I used before building Arid. You don't have to restore an entire Pylint configuration either. If all you want is duplicate detection, you can run that checker specifically:</p>
<pre><code class="language-bash">pylint --disable=all --enable=duplicate-code .
</code></pre>
<p>If that's fast enough for your project, it's a reasonable solution.</p>
<p>I covered that path, and why I eventually moved away from it, in <a href="https://medium.com/@bobltaylorjr/a-fast-alternative-to-pylint-r0801-061a10c2b444"><strong>A Fast Alternative to Pylint R0801</strong></a>.</p>
<p>The third option is a dedicated duplicate-code tool.</p>
<p>That's the approach I eventually wanted for my own projects: let Ruff handle the broad linting job and use something else for the analysis Ruff doesn't provide.</p>
<h2>The Ruff + Arid Model</h2>
<p>That second part is why I built <a href="https://github.com/sponge-b0b/arid">Arid</a>.</p>
<p>Arid is an open-source Python duplicate-code checker written in Rust. It isn't a Ruff replacement, and I deliberately don't want it to become one.</p>
<p>My normal workflow is:</p>
<pre><code class="language-bash">ruff check .
arid .
</code></pre>
<p>That's basically the entire idea.</p>
<p>Ruff owns linting. Arid owns duplicate-code detection.</p>
<p>There's some appeal in how boring that is.</p>
<p>Instead of building another Python quality platform with formatting, imports, complexity analysis, security rules, type checking, duplication, and whatever else I can squeeze into it, Arid can remain narrowly concerned with one question.</p>
<p>That lets Ruff continue doing the things Ruff is exceptionally good at while Arid specializes in something Ruff currently doesn't attempt.</p>
<h2>What Counts as Duplicate Code?</h2>
<p>This deserves some precision because "duplicate code" can describe very different kinds of analysis.</p>
<p>Consider:</p>
<pre><code class="language-python">def calculate_total(items):
    total = 0

    for item in items:
        total += item.price

    return total
</code></pre>
<p>and:</p>
<pre><code class="language-python">def calculate_order_value(products):
    amount = 0

    for product in products:
        amount += product.price

    return amount
</code></pre>
<p>A sophisticated semantic clone detector might decide those functions represent the same computation.</p>
<p>Arid won't.</p>
<p>Arid detects exact duplicated Python source after configurable Python-aware normalization. It can exclude things such as comments, docstrings, imports, and function signatures from duplicate identity, but it isn't attempting to prove that differently written programs mean the same thing.</p>
<p>That boundary is deliberate.</p>
<p>Semantic similarity is interesting, but it's also a much harder problem with different tradeoffs around false positives and explainability. I wanted something closer to the practical duplicate-code check I had been getting from Pylint.</p>
<p>When Arid reports duplication, I want the reason to be unsurprising.</p>
<h2>Fast Tools Change When You Use Them</h2>
<p>Performance matters here for a reason that has less to do with benchmark bragging rights than it might appear.</p>
<p>A quality check that takes 30 seconds isn't necessarily useless.</p>
<p>But you probably don't run it after every small change.</p>
<p>A check that takes a fraction of a second can become part of the normal development loop:</p>
<pre><code class="language-bash">ruff check .
arid .
pytest
</code></pre>
<p>That difference changes behavior.</p>
<p>Fast checks move earlier.</p>
<p>Instead of discovering duplication when CI runs—or during a larger quality pass before merging—you can discover it while you're still working on the code that introduced it.</p>
<p>This was one of the things I liked about Ruff in the first place. Speed isn't merely about spending fewer seconds waiting for software. It changes which checks are cheap enough to run routinely.</p>
<p>I wanted duplicate detection to have the same property.</p>
<h2>How Fast Is Arid?</h2>
<p>For Arid 2.0, I benchmarked against Pylint 4.0.6 using pinned corpora and Hyperfine. The Pylint runs isolate its duplicate-code functionality rather than comparing Arid against a complete Pylint analysis.</p>
<p>Running serially, Arid measured:</p>
<p><strong>Requests — 191.19x faster</strong></p>
<p><strong>Pydantic — 219.06x faster</strong></p>
<p><strong>Polaris — 249.68x faster</strong></p>
<p>There's an important limitation on what those numbers mean.</p>
<p>They do <strong>not</strong> mean "Arid is 200x faster than Pylint" in some general sense. Pylint performs many analyses that Arid doesn't perform at all.</p>
<p>The benchmark asks a much narrower question: when both tools are being used for the duplicate-code job Arid was built to replace, how much does that analysis cost?</p>
<p>That's the comparison I care about.</p>
<p>And although Arid supports worker-based parallelism, I use serial execution for the canonical comparison. I'd rather have a boring benchmark whose assumptions are easy to understand than manufacture the largest number possible.</p>
<h2>One <code>pyproject.toml</code>, Two Responsibilities</h2>
<p>Using focused tools doesn't have to mean returning to configuration-file sprawl.</p>
<p>Ruff supports <code>pyproject.toml</code>, <code>ruff.toml</code>, and <code>.ruff.toml</code>. Arid supports configuration through <code>[tool.arid]</code> in <code>pyproject.toml</code>.</p>
<p>So a project can keep both tools in the same configuration file:</p>
<pre><code class="language-toml">[tool.ruff]
line-length = 100

[tool.ruff.lint]
select = ["E", "F", "B", "UP"]

[tool.arid]
min-lines = 6
</code></pre>
<p>They're independent tools, but they don't have to feel like unrelated infrastructure scattered around the repository.</p>
<p>That matters to me because "use focused tools" can become bad advice if every capability introduces another config format, another runtime, another reporting convention, and another collection of strange CI semantics.</p>
<p>Composition works best when the boundaries are narrow and predictable.</p>
<h2>The Existing-Code Problem</h2>
<p>There's another issue that becomes especially visible when adding a new quality check to an established Ruff workflow.</p>
<p>Imagine you've spent months cleaning up your Ruff configuration. CI is green. Developers have fast feedback.</p>
<p>Then you add duplicate detection and discover 200 existing duplicate groups.</p>
<p>Congratulations: your new quality gate is now permanently red.</p>
<p>The obvious responses aren't great.</p>
<p>You can fix all 200 findings before enabling the check, which may turn a tooling improvement into a refactoring project nobody has time for.</p>
<p>You can suppress the check broadly, which defeats the point.</p>
<p>Or you can establish a baseline.</p>
<p>Arid can record the duplicate findings that already exist and treat them as accepted debt while continuing to reject newly introduced duplication.</p>
<p>That creates a migration path:</p>
<pre><code class="language-text">existing duplicate debt
        ↓
record baseline
        ↓
reject new duplication
        ↓
refactor old duplication over time
        ↓
prune resolved baseline entries
</code></pre>
<p>I think this is a much more general lesson about static-analysis adoption.</p>
<p>A tool doesn't become useful merely because it can identify everything that's wrong. On an existing codebase, it also needs a practical answer to the question: <strong>what are we supposed to do with all the problems that existed yesterday?</strong></p>
<p>Ruff itself recognizes a version of this adoption problem. Its documentation describes <code>--add-noqa</code> and <code>--add-ignore</code> workflows for introducing new rules while suppressing existing violations.</p>
<p>Arid's baseline mechanism solves the analogous problem for findings whose identity can span multiple files and locations.</p>
<p>I'll cover that workflow in more depth separately, because it's useful well beyond the Ruff + Arid combination.</p>
<h2>The CI Version Is Still Boring</h2>
<p>Locally:</p>
<pre><code class="language-bash">ruff check .
arid .
</code></pre>
<p>In CI, the same separation works.</p>
<p>Arid 2.0 provides an official GitHub Action:</p>
<pre><code class="language-yaml">- uses: sponge-b0b/arid@v2.0.0
  with:
    paths: .
</code></pre>
<p>You can also invoke the CLI directly if that's preferable.</p>
<p>Arid can emit human-readable text as well as JSON, Markdown, and SARIF, and it can produce multiple report formats from a single analysis. That lets a CI run serve different consumers without rescanning the project for each representation.</p>
<p>For example, a developer may want console output while GitHub code scanning consumes SARIF and some downstream automation consumes JSON.</p>
<p>The detector runs once.</p>
<p>Again, this is less interesting as a feature checklist than as a property of composition. If two tools are going to live beside each other in CI, both need to behave predictably as components of a larger system.</p>
<h2>Focused Tools Still Need Good Contracts</h2>
<p>This became more important to me while building Arid 2.0.</p>
<p>A small CLI can get away with being designed entirely around humans:</p>
<pre><code class="language-text">scan some files
print some text
return an exit code
</code></pre>
<p>But developer tools increasingly have other software consuming their results: CI systems, editors, reporting services, scripts, and coding agents.</p>
<p>Once that happens, "small tool" doesn't mean "informal interface."</p>
<p>Arid 2.0 has versioned report schemas, stable finding fingerprints, structured operational errors, deterministic capability discovery, and explicit completion state for partial analysis.</p>
<p>Those aren't duplicate-detection algorithms.</p>
<p>They're contracts.</p>
<p>And I think they're part of what makes the focused-tool model viable. If I'm going to assemble a development workflow from specialized components, those components need to be easy for both humans and software to reason about.</p>
<p>A Unix pipe is only elegant because both sides agree on what goes through it.</p>
<h2>Could Ruff Eventually Add Duplicate Detection?</h2>
<p>Of course.</p>
<p>Ruff has grown enormously, and its current documentation lists more than 900 lint rules. There is no technical law saying duplicate-code analysis must forever live outside Ruff.</p>
<p>If Ruff eventually implements the kind of duplicate detection I need, I'll evaluate it like any other option.</p>
<p>That doesn't make building a companion tool pointless.</p>
<p>Software ecosystems don't have to converge toward one executable that does everything. Sometimes a focused tool is useful permanently. Sometimes it demonstrates demand for a capability that eventually moves elsewhere. Sometimes competing implementations discover different tradeoffs.</p>
<p>I'm comfortable with all three outcomes.</p>
<p>Arid doesn't need Ruff to remain incomplete in order to justify existing. It needs to solve a real problem well enough to be useful today.</p>
<h2>Should a Ruff User Add Arid?</h2>
<p>Only if you care about duplicate-code detection.</p>
<p>If you don't, Ruff doesn't have a problem that needs fixing.</p>
<p>If you do, I'd think about the decision this way:</p>
<p><strong>Use Pylint R0801</strong> if you're already happy with Pylint or its dedicated duplicate-code pass performs well enough for your project.</p>
<p><strong>Use a more sophisticated clone-analysis tool</strong> if you're trying to detect semantic or structurally similar implementations rather than exact normalized duplication.</p>
<p><strong>Consider Arid</strong> if you want a focused, fast duplicate-code check that fits naturally beside Ruff and can be used locally and in CI without turning duplication into another general-purpose linting stack.</p>
<p>For me, that last option produced a development loop I like:</p>
<pre><code class="language-bash">ruff check .
arid .
</code></pre>
<p>Two tools. Two responsibilities.</p>
<p>I don't think good tooling architecture is measured by how few executable names appear in a CI file. It's measured by whether each piece solves a useful problem, composes cleanly with the others, and stays out of the developer's way.</p>
<p>Ruff already does that extraordinarily well.</p>
<p>Duplicate-code detection doesn't need to be inside Ruff to work the same way.</p>
<hr />
<p><a href="https://github.com/sponge-b0b/arid"><strong>Arid</strong></a> is an open-source Python duplicate-code checker written in Rust and designed to complement Ruff.</p>
<h3>About the Author</h3>
<p><strong>Bob Taylor</strong> is a software engineer and architect who builds developer tools and AI systems. He is currently developing <a href="https://github.com/sponge-b0b/arid">Arid</a> and <a href="https://github.com/sponge-b0b/Polaris">Polaris</a>.</p>
<p><a href="https://github.com/sponge-b0b">GitHub</a></p>
]]></content:encoded></item><item><title><![CDATA[A Fast Alternative to Pylint R0801]]></title><description><![CDATA[Python tooling has changed quite a bit over the last few years.
Ruff can replace a large part of the traditional Python linting stack, and it does that work extremely quickly. For a lot of projects, a]]></description><link>https://bobtaylor.hashnode.dev/a-fast-alternative-to-pylint-r0801</link><guid isPermaLink="true">https://bobtaylor.hashnode.dev/a-fast-alternative-to-pylint-r0801</guid><category><![CDATA[Python]]></category><category><![CDATA[Software Engineering]]></category><category><![CDATA[Code Quality]]></category><category><![CDATA[Developer Tools]]></category><category><![CDATA[static analysis]]></category><dc:creator><![CDATA[spongeb0b]]></dc:creator><pubDate>Tue, 25 Aug 2026 04:59:41 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a81e13cc6b5d0b7614be7da/170b0d08-e4bd-4c1d-a73e-83f5c6eebdb2.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Python tooling has changed quite a bit over the last few years.</p>
<p>Ruff can replace a large part of the traditional Python linting stack, and it does that work extremely quickly. For a lot of projects, adopting Ruff means there is much less reason to run several different linters over the same source tree.</p>
<p>There is one check I wasn't able to move out of Pylint, though: duplicate-code detection.</p>
<p>Pylint reports duplicated code as <code>R0801</code>, or <code>duplicate-code</code>. It comes from Pylint's similarities checker and looks for similar lines across Python source.</p>
<p>It's a useful check. The problem for me wasn't what R0801 did. It was having to run Pylint to get it.</p>
<h2>Why I Still Wanted R0801</h2>
<p>Duplicate code is easy to dismiss as a style problem until you've maintained enough of it.</p>
<p>Two copies of the same implementation mean two places that may need to change when the behavior changes. Three copies mean three. Eventually one gets fixed while another doesn't, and code that looked identical gradually stops behaving identically.</p>
<p>Pylint's own documentation makes essentially the same point: duplicated logic increases the number of places that have to be found, changed, and tested, and can make code harder to understand during review.</p>
<p>So turning off R0801 wasn't the solution I wanted.</p>
<p>I wanted to keep the check without paying for a much broader analysis pass just to get that one result.</p>
<h2>Running Only Pylint's Duplicate-Code Check</h2>
<p>If you already have Pylint installed, the first thing to try is simply running less Pylint.</p>
<p>You can enable only duplicate-code detection:</p>
<pre><code class="language-bash">pylint --disable=all --enable=duplicate-code .
</code></pre>
<p>That is a perfectly reasonable solution, particularly if Pylint is already part of your project and its performance is acceptable.</p>
<p>There are also useful controls around what contributes to similarity. Pylint supports ignoring comments, docstrings, imports, and function signatures when constructing the source representation used for duplicate detection.</p>
<p>So before replacing anything, I'd start there.</p>
<p>If a dedicated R0801 pass is fast enough for your repository, you may already have the solution you need.</p>
<p>For me, it wasn't.</p>
<h2>Why Duplicate Detection Can Become Noticeable</h2>
<p>Duplicate-code detection is different from many ordinary lint rules.</p>
<p>A rule such as "this import is unused" can largely reason about a particular file or syntax tree. Duplicate detection has to compare source across a corpus because the code in one file may duplicate code somewhere else entirely.</p>
<p>That distinction becomes more important as a repository grows.</p>
<p>It also complicates the obvious answer to performance problems: just split the work across files.</p>
<p>Cross-file analysis needs a global view of the source being compared. There are real-world projects that have ended up separating Pylint's duplicate-code checker into its own single-process CI job because partitioning files among workers can change the detected clusters.</p>
<p>That doesn't mean Pylint is a bad tool. Pylint does far more than duplicate detection.</p>
<p>But it does raise a reasonable question if R0801 is the reason you're still running it:</p>
<p><strong>Do you need a general-purpose linter for this particular job?</strong></p>
<h2>A Focused Alternative</h2>
<p>That question is why I built <a href="https://github.com/sponge-b0b/arid">Arid</a>.</p>
<p>Arid is a Python duplicate-code checker written in Rust. It isn't intended to replace Pylint as a whole, and it isn't trying to replace Ruff.</p>
<p>It replaces one job:</p>
<pre><code class="language-text">Pylint R0801
</code></pre>
<p>with a tool dedicated to duplicate-code detection.</p>
<p>The workflow I use is simply:</p>
<pre><code class="language-bash">ruff check .
arid .
</code></pre>
<p>Ruff handles the broad linting work. Arid handles duplication.</p>
<p>That separation is useful beyond performance. A focused tool can make its configuration, reporting, CI behavior, and machine interfaces specifically about the problem it's solving rather than accommodating an entire linting framework.</p>
<h2>How Much Faster?</h2>
<p>I don't think "written in Rust" is a benchmark, so I maintain a pinned performance campaign for Arid.</p>
<p>For Arid 2.0, I compared it with Pylint 4.0.6 while isolating Pylint's duplicate-code functionality rather than comparing Arid with an entire Pylint lint run.</p>
<p>Running serially, the results were:</p>
<p><strong>Requests — 191.19x faster</strong></p>
<p><strong>Pydantic — 219.06x faster</strong></p>
<p><strong>Polaris — 249.68x faster</strong></p>
<p>Those numbers aren't meant to establish that Pylint itself is "200x slower." That would be an unfair comparison because Pylint performs many checks Arid doesn't even attempt.</p>
<p>They answer a much narrower question:</p>
<p><strong>If the job is duplicate-code detection, what does the focused implementation cost compared with Pylint's implementation of that job?</strong></p>
<p>That's the comparison that mattered to me.</p>
<h2>What Arid Actually Detects</h2>
<p>There is another important qualification.</p>
<p>"Duplicate code" can mean several different things.</p>
<p>At one extreme is exact textual duplication. At the other are sophisticated clone detectors trying to recognize code that is structurally or semantically similar despite substantial differences in its source.</p>
<p>Arid isn't trying to solve the entire code-clone research problem.</p>
<p>It detects exact duplicated Python source after configurable Python-aware normalization. Depending on the configuration, comments, docstrings, imports, and function signatures can be excluded from duplicate identity.</p>
<p>That makes it much closer in purpose to the R0801 workflow I wanted to replace.</p>
<p>If you're looking for semantic clone detection—two differently written implementations that happen to do the same thing—Arid isn't the tool for that.</p>
<p>I think being explicit about that boundary is important. A focused tool is only useful if its focus matches the problem you actually have.</p>
<h2>What About an Existing Codebase Full of Duplication?</h2>
<p>This is where replacing a checker and actually adopting one become different problems.</p>
<p>Suppose you run duplicate detection on a mature project for the first time and discover 300 existing findings.</p>
<p>Technically, the tool worked.</p>
<p>Practically, you've just created 300 reasons for your team not to enable it in CI.</p>
<p>You could fix everything before enforcing the check, but that's often unrealistic. You could ignore duplicate detection entirely, but then new duplication continues accumulating.</p>
<p>The more useful approach is a baseline.</p>
<p>With Arid, existing findings can be recorded as accepted debt. CI can then reject new duplication without requiring you to eliminate everything that existed before the check was introduced.</p>
<p>As existing duplication gets refactored, stale baseline entries can be identified and pruned.</p>
<p>That changes adoption from:</p>
<pre><code class="language-text">fix all existing duplication
        ↓
enable the check
</code></pre>
<p>into:</p>
<pre><code class="language-text">record existing duplication
        ↓
prevent new duplication
        ↓
improve old duplication over time
</code></pre>
<p>For mature codebases, I think the second model is considerably more realistic.</p>
<p>I'll cover that workflow separately because it turns out to be a more general engineering problem than just configuring a duplicate-code checker.</p>
<h2>Using It in CI</h2>
<p>For a basic local check, Arid doesn't require much:</p>
<pre><code class="language-bash">uv tool install arid
arid .
</code></pre>
<p>Arid 2.0 also has an official GitHub Action for CI:</p>
<pre><code class="language-yaml">- uses: sponge-b0b/arid@v2.0.0
  with:
    paths: .
</code></pre>
<p>V2 can produce text, JSON, Markdown, and SARIF reports, including multiple representations from a single analysis.</p>
<p>That matters if, for example, you want readable output for developers, structured JSON for another tool, and SARIF for code scanning. The source doesn't need to be analyzed independently for every consumer.</p>
<p>Again, none of that makes duplicate detection inherently better. It makes the detector easier to incorporate into the systems surrounding it.</p>
<h2>Should You Replace Pylint R0801?</h2>
<p>Not necessarily.</p>
<p>If you're already using Pylint extensively, R0801 performs well enough for your repository, and you like its behavior, replacing it solely because another implementation is faster may accomplish very little.</p>
<p>I'd consider a dedicated alternative when the situation looks more like this:</p>
<ul>
<li><p>you've moved most linting to Ruff;</p>
</li>
<li><p>duplicate-code detection is one of the remaining reasons you're running Pylint;</p>
</li>
<li><p>R0801 has become noticeable on your repository;</p>
</li>
<li><p>you want duplicate detection in a fast local feedback loop;</p>
</li>
<li><p>you need baseline-based adoption for an existing codebase; or</p>
</li>
<li><p>you want structured duplicate-code output for CI or other tooling.</p>
</li>
</ul>
<p>That's the situation Arid was built for.</p>
<p>I didn't start the project because I thought Python needed another general-purpose linter. Quite the opposite. I started it because I wanted one useful capability without carrying a general-purpose linter along just to get it.</p>
<p>If that's your problem too, Arid may be useful.</p>
<p>If it isn't, keep using R0801.</p>
<p>The goal isn't to replace a tool that already works for you. It's to avoid running more tooling than the problem actually requires.</p>
<hr />
<p><a href="https://github.com/sponge-b0b/arid"><strong>Arid</strong></a> is an open-source Python duplicate-code checker written in Rust and designed to complement Ruff.</p>
<h3>About the Author</h3>
<p><strong>Bob Taylor</strong> is a software engineer and architect who builds developer tools and AI systems. He is currently developing <a href="https://github.com/sponge-b0b/arid">Arid</a> and <a href="https://github.com/sponge-b0b/Polaris">Polaris</a>.</p>
<p><a href="https://github.com/sponge-b0b">GitHub</a></p>
]]></content:encoded></item><item><title><![CDATA[Arid 2.0: From Fast Python Duplicate Detection to CI-Ready Tooling]]></title><description><![CDATA[A few months ago, I had a fairly simple problem: Pylint was too slow.
I use Pylint's R0801 duplicate-code detection on Polaris, a fairly large Python project I've been building. I also use Ruff, which]]></description><link>https://bobtaylor.hashnode.dev/arid-2-0-from-fast-python-duplicate-detection-to-ci-ready-tooling</link><guid isPermaLink="true">https://bobtaylor.hashnode.dev/arid-2-0-from-fast-python-duplicate-detection-to-ci-ready-tooling</guid><category><![CDATA[Python]]></category><category><![CDATA[Software Engineering]]></category><category><![CDATA[open source]]></category><category><![CDATA[Developer Tools]]></category><category><![CDATA[Continuous Integration]]></category><dc:creator><![CDATA[spongeb0b]]></dc:creator><pubDate>Sun, 23 Aug 2026 08:41:31 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a81e13cc6b5d0b7614be7da/5cf6de4c-d06f-4d9d-b3ab-1a05562d4262.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A few months ago, I had a fairly simple problem: Pylint was too slow.</p>
<p>I use Pylint's <code>R0801</code> duplicate-code detection on <a href="https://github.com/sponge-b0b/Polaris">Polaris</a>, a fairly large Python project I've been building. I also use Ruff, which handles most of the Python linting I care about and handles it very quickly.</p>
<p>Unfortunately, Ruff doesn't detect duplicate code. So every time I wanted that one check, I was back to waiting for Pylint.</p>
<p>Eventually I got tired of waiting and built <a href="https://github.com/sponge-b0b/arid">Arid</a>, a focused Python duplicate-code checker written in Rust. The idea was deliberately simple: do the job I was using Pylint <code>R0801</code> for, do it accurately, and do it fast enough that I wouldn't mind running it all the time.</p>
<p>The first versions of Arid proved that idea worked. Arid 2.0 is about something different: what does a fast detector need around it before it becomes a tool you can comfortably build into real development workflows? That question ended up defining the release.</p>
<h2>The Detector Didn't Need Reinventing</h2>
<p>Major versions have a way of encouraging major rewrites: new architecture, new algorithm, new semantics. Everything is better because everything is new.</p>
<p>I didn't do that.</p>
<p>Arid 2.0 uses the same basic detection model as 1.2. It still detects exact duplicated Python source after configurable Python-aware normalization. It still reports <code>DUP001</code>. Comments, docstrings, imports, and function signatures can be excluded from duplicate identity. The detector isn't suddenly trying to find semantically equivalent code or fuzzy AST clones.</p>
<p>That's intentional. The detector was already solving the problem I wanted it to solve, so instead of redesigning the part that worked, I concentrated v2 on the things surrounding it: stable machine contracts, CI integration, baseline management, focused workflows, incomplete-analysis handling, project control, and better support for external tooling.</p>
<p>Arid 2.0 isn't really a new detector. It's the same detector with a much more useful development workflow around it.</p>
<h2>Fast Still Matters</h2>
<p>None of that would matter much if Arid stopped being fast.</p>
<p>The v2 performance campaign used the same pinned benchmark corpora and Hyperfine methodology used to qualify Arid 1.2. Against Pylint 4.0.6, running serially, Arid 2.0 measured:</p>
<table>
<thead>
<tr>
<th>Project</th>
<th>Arid v2 vs. Pylint</th>
</tr>
</thead>
<tbody><tr>
<td>Requests</td>
<td><strong>191.19x faster</strong></td>
</tr>
<tr>
<td>Pydantic</td>
<td><strong>219.06x faster</strong></td>
</tr>
<tr>
<td>Polaris</td>
<td><strong>249.68x faster</strong></td>
</tr>
</tbody></table>
<p>Those aren't comparisons against an entire Pylint run. The benchmark isolates Pylint's duplicate-code functionality so the comparison is actually about the job Arid replaces.</p>
<p>I also compared v2 directly against the qualified Arid 1.2 implementation. The additional v2 functionality introduced only low-single-digit overhead across the canonical corpora.</p>
<p>Could I have spent more time trying to recover that few percent? Sure. Would anybody using Arid notice the difference between roughly 219x faster than Pylint and slightly more than 219x faster than Pylint? Probably not. At some point optimization becomes an excellent way to avoid working on things users actually need.</p>
<h2>Stable Identity for a Finding</h2>
<p>One of those things is identity.</p>
<p>Suppose Arid finds the same duplicated code today and tomorrow, but somebody inserts 20 lines near the top of the file. The physical line numbers changed.</p>
<p>The duplicate didn't.</p>
<p>Or perhaps a file moves to another directory. Maybe the order of occurrences changes, or the same duplicate appears in another file. If external tooling identifies findings using locations, those findings become surprisingly unstable.</p>
<p>Arid 2.0 gives every finding a versioned fingerprint:</p>
<pre><code class="language-text">arid-finding-v1:sha256:...
</code></pre>
<p>That fingerprint identifies the normalized duplicate content independently of path, physical line number, occurrence ordering and multiplicity, structural metadata, output format, and worker mode. The same identity is exposed in SARIF through a versioned partial fingerprint.</p>
<p>This isn't particularly exciting when you're looking at a CLI report. It becomes considerably more useful when a CI system, reporting service, coding agent, or other tool needs to reason about the same finding across multiple runs.</p>
<h2>Focus the Report, Not the Analysis</h2>
<p>Large projects create another problem. Sometimes I don't care about every duplicate in the repository. I'm working on one package, directory, or file and want to know what's relevant to the thing I'm changing.</p>
<p>The obvious implementation is to scan only that path, but that's wrong for duplicate detection.</p>
<p>Suppose I'm working in:</p>
<pre><code class="language-text">src/payments/
</code></pre>
<p>and some code there duplicates code in:</p>
<pre><code class="language-text">src/customers/
</code></pre>
<p>If I analyze only <code>src/payments/</code>, I've removed half of the evidence.</p>
<p>Arid 2.0 therefore separates what gets analyzed from what gets reported:</p>
<pre><code class="language-bash">arid . --focus src/payments
</code></pre>
<p>Arid still performs whole-corpus duplicate detection. Baseline enforcement still happens against the complete result, and only afterward does focus filtering determine which groups are reported. If a focused finding also occurs outside the focused path, those occurrences remain part of the finding.</p>
<p>In other words, focus changes what you ask Arid to show you without changing the corpus Arid uses to determine whether the code is duplicated. That's an important distinction for CI jobs and coding agents operating on a specific part of a larger repository.</p>
<h2>Existing Duplicate Debt Is a Lifecycle</h2>
<p>Baselines were already part of Arid before v2. The idea is straightforward: perhaps you're introducing duplicate-code enforcement into a mature project that already has 300 duplicate groups.</p>
<p>You could fix all 300 before adopting the tool.</p>
<p>Or don't adopt the tool.</p>
<p>Neither is especially compelling.</p>
<p>A baseline gives you a third option: accept the existing debt temporarily while preventing new duplicate debt from being introduced. Arid 2.0 extends that into an actual lifecycle.</p>
<pre><code class="language-bash">arid . --baseline-status arid-baseline.json
</code></pre>
<p>can distinguish accepted duplicate debt, active/new findings, and stale baseline entries. Then:</p>
<pre><code class="language-bash">arid . --prune-baseline arid-baseline.json
</code></pre>
<p>removes stale acceptance when the corresponding duplication no longer exists. It never silently accepts new debt.</p>
<p>That gives a team a useful progression:</p>
<pre><code class="language-text">existing duplication
        ↓
baseline it
        ↓
prevent new duplication
        ↓
refactor existing duplication over time
        ↓
prune stale baseline entries
        ↓
smaller baseline
</code></pre>
<p>You don't have to make an old codebase perfect before you're allowed to stop making it worse. I suspect that principle applies to considerably more than duplicate code.</p>
<h2>Failure Doesn't Have to Mean "Tell Me Nothing"</h2>
<p>Source analysis has another annoying edge case. Imagine scanning 3,000 Python files and one cannot be read, parsed, or normalized. Should the entire analysis disappear?</p>
<p>Sometimes yes. If you're enforcing a complete quality gate, an incomplete analysis cannot be treated as success. But that doesn't mean the useful results from the other 2,999 files need to vanish.</p>
<p>Arid 2.0 adds:</p>
<pre><code class="language-bash">arid . --keep-going --json
</code></pre>
<p>Independent source failures are collected while valid files continue through detection. The important part is that Arid doesn't pretend partial analysis is complete analysis.</p>
<p>A report-v4 result explicitly says:</p>
<pre><code class="language-json">{
  "complete": false
}
</code></pre>
<p>and includes structured source errors. The process still exits with operational status <code>2</code>, and incomplete reports cannot be emitted as SARIF.</p>
<p>The intent is simple: produce as much useful information as you safely can, but be explicit about how complete that information is. A human can inspect the partial result, and an automated consumer can make its own decision. Neither has to guess whether the analysis silently skipped something.</p>
<h2>One Scan, Several Consumers</h2>
<p>A CI pipeline often wants more than one representation of the same result. Maybe developers want readable console output, the build system wants JSON, GitHub code scanning wants SARIF, and the job summary wants Markdown.</p>
<p>The inefficient answer is to run the analyzer four times.</p>
<p>Arid 2.0 can instead produce multiple outputs from one in-memory report:</p>
<pre><code class="language-bash">arid . \
  --format text \
  --report json=artifacts/arid.json \
  --report markdown=artifacts/arid.md \
  --report sarif=artifacts/arid.sarif
</code></pre>
<p>The source isn't reparsed four times and duplicate detection isn't repeated four times. The analysis happens once, and the result is rendered for the consumers that need it.</p>
<p>Obvious in retrospect? Probably. Still worth doing.</p>
<h2>Machine-Readable Means Having a Contract</h2>
<p>Once other software starts consuming CLI output, "it happens to be JSON" isn't enough.</p>
<p>Arid 2.0 introduces report schema v4 with explicit fields for things like the schema version, tool version, analysis metadata, completion state, structured errors, and finding fingerprints. The schema itself is published:</p>
<pre><code class="language-text">schemas/report-v4.schema.json
</code></pre>
<p>Arid also publishes contracts for capabilities and fatal JSON-mode operational errors. And:</p>
<pre><code class="language-bash">arid --capabilities
</code></pre>
<p>allows tooling to discover deterministic build capabilities without first discovering or analyzing a project.</p>
<p>This is partly about ordinary CI integration, but there's another consumer I care about more now than I would have a few years ago: coding agents.</p>
<p>I use AI heavily in my own development workflow. An agent interacting with a tool shouldn't have to scrape human-readable console output and hope a sentence doesn't change in the next release. If we're increasingly going to have software using software on our behalf, the interfaces between those tools need to become more explicit, not less.</p>
<p>JSON gives us a machine-readable format. Publishing the schema tells the consumer what that format actually promises.</p>
<h2>Arid Now Has an Official GitHub Action</h2>
<p>Of course, the easiest integration is the one you don't have to assemble yourself.</p>
<p>Arid 2.0 ships an official composite GitHub Action:</p>
<pre><code class="language-yaml">- uses: sponge-b0b/arid@v2.0.0
  with:
    paths: .
</code></pre>
<p>The Action installs the exact Arid release associated with its tag and performs one scan. It can expose core metrics as outputs, write a job summary, and produce SARIF when configured.</p>
<p>Before v2, Arid could certainly be used in CI. Now there's a supported integration that makes doing it considerably simpler.</p>
<h2>Sometimes You Want to Analyze Code That Isn't on Disk</h2>
<p>Arid normally discovers and analyzes Python files in a project, but editors, coding agents, and other tools frequently have source that doesn't exist on disk yet—or source that differs from what's currently there.</p>
<p>V2 adds virtual Python source through standard input:</p>
<pre><code class="language-bash">cat src/example.py | arid . --stdin-path src/example.py
</code></pre>
<p>The virtual source goes through the same Python parser and normalizer as disk-backed source. If an equivalent disk path exists, the virtual version replaces it for that scan. Otherwise it can be added to the corpus when the resolved project context permits it. Arid never writes that source to disk.</p>
<p>That means another tool can effectively ask, "If this were the contents of <code>src/example.py</code>, what duplicates would exist?" without first modifying the working tree. That's useful for editors and automation, and yes, it's particularly useful for coding agents.</p>
<h2>Explicit When You Need It</h2>
<p>Convention is great until automation needs certainty.</p>
<p>Arid still supports its existing nearest-config behavior, but v2 adds explicit control over project and configuration context:</p>
<pre><code class="language-bash">arid . --config path/to/pyproject.toml
arid . --no-config
arid . --project-root path/to/project
arid . --show-config
arid . --list-files
</code></pre>
<p>For somebody running Arid manually in a normal repository, most of this can stay invisible. For CI, monorepos, editor integrations, and agents, being able to ask exactly which project and configuration are being used becomes considerably more important.</p>
<p>The common case can still rely on convenient defaults. The less common cases now have a way to be explicit.</p>
<h2>Not Everything Became Public</h2>
<p>There was one place where v2 deliberately became less extensible.</p>
<p>Arid is primarily a CLI application, but its Rust crate naturally exposes Rust code too. In v2, I narrowed the semver-supported Rust surface to a small crate-root application API. Implementation modules, detector internals, and reporting internals are no longer promises to downstream Rust consumers.</p>
<p>That decision fits something I've been thinking about a lot lately: every public interface creates an obligation. Once implementation details become supported API, changing your own internals becomes somebody else's breaking change.</p>
<p>If Arid were intended to be a general duplicate-detection framework, that would be a different conversation. It isn't, and not everything another developer <em>could</em> call needs to become something they <em>should</em> depend on.</p>
<h2>What Didn't Change May Matter More</h2>
<p>For a major release, the compatibility list is almost as important as the feature list.</p>
<p>Arid 2.0 preserves the things ordinary users depend on:</p>
<ul>
<li><p>exact normalized duplicate semantics;</p>
</li>
<li><p><code>DUP001</code>;</p>
</li>
<li><p>normal CLI invocation;</p>
</li>
<li><p><code>[tool.arid]</code> configuration;</p>
</li>
<li><p>normalization behavior;</p>
</li>
<li><p>source suppression;</p>
</li>
<li><p>existing baseline-v1 files;</p>
</li>
<li><p>serial execution by default;</p>
</li>
<li><p>worker controls;</p>
</li>
<li><p>the <code>0</code> / <code>1</code> / <code>2</code> exit meanings;</p>
</li>
<li><p>pre-commit integration;</p>
</li>
<li><p>supported release platforms.</p>
</li>
</ul>
<p>For CLI-only users who don't consume Arid's machine contracts or Rust internals, upgrading from 1.2 may require no migration work at all.</p>
<p>The intentional breaking changes are concentrated where a major version gives us room to make contracts cleaner: report JSON, SARIF finding identity, and the supported Rust API. That's the kind of major version I prefer: break what you have a good reason to break and leave everything else alone.</p>
<h2>I Tried It on Real Projects</h2>
<p>I don't want Arid's correctness story to be based entirely on unit tests and a repository containing six carefully selected Python files.</p>
<p>The v2 validation campaign exercised Arid against Black, Django, mypy, Rich, Unicode and space-containing paths, and combinations of the new workflow features.</p>
<p>For equivalent settings, canonical duplicate groups from Arid 2.0 were compared directly with qualified Arid 1.2 results across Black, Django, mypy, and Rich. No detector-semantic regression was found.</p>
<p>Validation also covered focus behavior, baseline-before-focus ordering, virtual-source replacement without disk mutation, controlled malformed source with <code>--keep-going</code>, multi-output on Django, worker determinism, and the published GitHub Action.</p>
<p>Performance matters, but so does knowing that the fast answer is still the right answer.</p>
<h2>Where Arid Fits</h2>
<p>Arid isn't trying to replace Ruff. Quite the opposite.</p>
<p>My normal mental model is:</p>
<pre><code class="language-bash">ruff check .
arid .
</code></pre>
<p>Ruff handles the broad Python linting problem extraordinarily well. Arid handles one problem Ruff currently doesn't: duplicate code.</p>
<p>Because Arid is deliberately focused on that problem, I can make decisions around its detection model, reporting, baselines, and automation without turning it into another general-purpose linter. That's still the philosophy behind v2:</p>
<p><strong>Cleaner contracts. Better automation. Same focused detector.</strong></p>
<h2>Try It</h2>
<p>With <code>uv</code>:</p>
<pre><code class="language-bash">uv tool install "arid==2.0.0"
arid .
</code></pre>
<p>Or with <code>pip</code>:</p>
<pre><code class="language-bash">python -m pip install "arid==2.0.0"
arid .
</code></pre>
<p>If you're already using Ruff and want duplicate-code detection, try Arid on a real project. If you're still running Pylint primarily because you need <code>R0801</code>, I'm particularly interested in what you think.</p>
<p>I'm also interested in people integrating static-analysis tools into CI, editors, or coding-agent workflows. A large part of Arid 2.0 exists because once a command-line tool starts participating in larger development systems, speed isn't the only thing that matters anymore.</p>
<p>Arid started because I didn't want to wait for Pylint.</p>
<p>Version 2.0 is what happened after the detector became fast enough that speed stopped being the most interesting problem.</p>
<hr />
<p><a href="https://github.com/sponge-b0b/arid"><strong>Arid</strong></a> is an open-source Python duplicate-code checker written in Rust. Version 2.0 is available now.</p>
<p><strong>Documentation:</strong> <a href="https://github.com/sponge-b0b/arid/blob/main/docs/releases/v2.0.0.md">Arid 2.0 release notes</a></p>
<h3>About the Author</h3>
<p><strong>Bob Taylor</strong> is a software engineer and architect who builds developer tools and AI systems. He is currently developing <a href="https://github.com/sponge-b0b/arid">Arid</a> and <a href="https://github.com/sponge-b0b/Polaris">Polaris</a>.</p>
<p><a href="https://github.com/sponge-b0b">GitHub</a></p>
]]></content:encoded></item><item><title><![CDATA[Every Abstraction Is a Bet on the Future]]></title><description><![CDATA[Software developers love abstractions.
I know. I'm one of them.
Give us a small enough problem and eventually somebody will propose an interface, a factory, a plugin architecture, dependency injection]]></description><link>https://bobtaylor.hashnode.dev/every-abstraction-is-a-bet-on-the-future</link><guid isPermaLink="true">https://bobtaylor.hashnode.dev/every-abstraction-is-a-bet-on-the-future</guid><dc:creator><![CDATA[spongeb0b]]></dc:creator><pubDate>Wed, 19 Aug 2026 06:28:23 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a81e13cc6b5d0b7614be7da/ce859be5-23a0-4459-8ef4-3f98b8452b03.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Software developers love abstractions.</p>
<p>I know. I'm one of them.</p>
<p>Give us a small enough problem and eventually somebody will propose an interface, a factory, a plugin architecture, dependency injection, and perhaps a message bus just in case the three functions need to communicate asynchronously someday.</p>
<p>You know. For flexibility.</p>
<p>I've been thinking about this while building <a href="https://github.com/sponge-b0b/arid">Arid</a>, a Python duplicate-code checker written in Rust.</p>
<p>Arid has a deliberately narrow job:</p>
<blockquote>
<p>Detect duplicated Python source code quickly and accurately.</p>
</blockquote>
<p>That's it.</p>
<p>It doesn't format Python.</p>
<p>It doesn't sort imports.</p>
<p>It doesn't type-check anything.</p>
<p>It doesn't detect dead code.</p>
<p>It doesn't scan JavaScript.</p>
<p>And unless something changes dramatically, it isn't going to make coffee either.</p>
<p>That narrow scope created an interesting architectural question:</p>
<p><strong>How much architecture does a small tool actually need?</strong></p>
<p>My answer has increasingly become:</p>
<p><strong>Enough to make the current problem clean. No more.</strong></p>
<p>That sounds like YAGNI.</p>
<p>It is.</p>
<p>But I think there's a more useful way to look at it.</p>
<h2>Every Abstraction Is a Prediction</h2>
<p>Suppose Arid had started with this:</p>
<pre><code class="language-text">Language
    ├── Python
    ├── JavaScript
    ├── TypeScript
    └── ...
</code></pre>
<p>Looks reasonable.</p>
<p>Maybe even responsible.</p>
<p>After all, duplicate-code detection isn't inherently a Python problem. Why couple the architecture to Python?</p>
<p>So we introduce something like:</p>
<pre><code class="language-text">LanguageFrontend
    parse()
    normalize()
    classify()
</code></pre>
<p>Python implements it today.</p>
<p>JavaScript can implement it tomorrow.</p>
<p>Look at us. Future-proof already.</p>
<p>Except there is currently no JavaScript version of Arid.</p>
<p>There is no TypeScript version.</p>
<p>There is no requirement for either one.</p>
<p>There isn't even a roadmap item for another language.</p>
<p>What exactly did the abstraction buy us?</p>
<p>It bought us an interface.</p>
<p>It bought us indirection.</p>
<p>It bought us a contract that future implementations now have to fit.</p>
<p>It bought us tests for a generic concept that currently has exactly one implementation.</p>
<p>And perhaps most importantly, it quietly made a prediction:</p>
<blockquote>
<p>Arid will need multiple language frontends.</p>
</blockquote>
<p>Maybe that's true someday.</p>
<p>Maybe it isn't.</p>
<p>Either way, we've paid for part of that future before we know whether it's coming.</p>
<p>That's the thing about abstractions.</p>
<p><strong>They're not free flexibility. They're bets about where the software is going.</strong></p>
<h2>Architecture Has Carrying Costs</h2>
<p>The cost of an abstraction isn't just the code required to create it.</p>
<p>Code is usually the cheap part.</p>
<p>The real cost is that somebody has to understand it.</p>
<p>Consider a hypothetical detector architecture:</p>
<pre><code class="language-text">DuplicateDetector
    ├── ExactDetector
    ├── StructuralDetector
    ├── SemanticDetector
    └── FuzzyDetector
</code></pre>
<p>Nice.</p>
<p>Except Arid has one detector.</p>
<p>It performs exact duplicate detection after configurable Python-aware normalization.</p>
<p>There is no structural detector.</p>
<p>There is no semantic detector.</p>
<p>There is no fuzzy detector.</p>
<p>In fact, those are explicitly outside Arid's current scope.</p>
<p>So if I introduce <code>DuplicateDetector</code>, what have I modeled?</p>
<p>Not the software that exists.</p>
<p>I've modeled software I can imagine.</p>
<p>That's a subtle but important difference.</p>
<p>Now every developer reading the code has additional questions:</p>
<p>Why is this an interface?</p>
<p>Are there other implementations?</p>
<p>Can the implementation change at runtime?</p>
<p>Am I expected to add new detectors this way?</p>
<p>What guarantees does the abstraction make?</p>
<p>Which behavior belongs to the interface and which belongs to the implementation?</p>
<p>The abstraction has increased the number of concepts required to understand the system without increasing what the system can do.</p>
<p>That's architectural debt too.</p>
<p>We just don't usually call it that because the code looks clean.</p>
<h2>But Don't Just Put Everything in <code>main()</code></h2>
<p>There is an obvious bad interpretation of this argument:</p>
<blockquote>
<p>Small software doesn't need architecture.</p>
</blockquote>
<p>I don't believe that.</p>
<p>Arid 1.1.0 has an architecture.</p>
<p>The main application pipeline is roughly:</p>
<pre><code class="language-text">discover
   ↓
read
   ↓
normalize
   ↓
build corpus
   ↓
detect duplicates
   ↓
apply baseline
   ↓
build report
   ↓
render output
</code></pre>
<p>Those are real boundaries because they represent different responsibilities in the problem.</p>
<p>File discovery shouldn't know how suffix arrays work.</p>
<p>Duplicate detection shouldn't know how Python comments are parsed.</p>
<p>Normalization shouldn't know how Markdown reports are rendered.</p>
<p>Reporting shouldn't decide what counts as a duplicate.</p>
<p>Those separations aren't predictions about hypothetical future products.</p>
<p>They're properties of the problem Arid solves today.</p>
<p>That's the distinction I care about.</p>
<p><strong>Good architecture separates things that are actually different. Overarchitecture separates things because they might become different someday.</strong></p>
<p>Those are not the same thing.</p>
<h2>Concrete Is Not a Dirty Word</h2>
<p>At some point, "concrete" became suspicious in software design.</p>
<p>If one module directly calls another module, perhaps we're too tightly coupled.</p>
<p>Better introduce an interface.</p>
<p>But coupling isn't automatically bad.</p>
<p><strong>Incorrect coupling is bad.</strong></p>
<p>Arid's duplicate detector depends on Arid's corpus representation.</p>
<p>Of course it does.</p>
<p>That's the data it detects duplicates in.</p>
<p>The normalization layer produces Arid's normalized representation.</p>
<p>Again: yes.</p>
<p>That's its job.</p>
<p>Those relationships aren't architectural mistakes waiting to be abstracted away.</p>
<p>They're the architecture.</p>
<p>The question shouldn't be:</p>
<blockquote>
<p>How do I eliminate coupling?</p>
</blockquote>
<p>It should be:</p>
<blockquote>
<p>Are these things coupled for a reason that belongs to the domain?</p>
</blockquote>
<p>If the answer is yes, hiding that relationship behind another interface doesn't necessarily improve anything.</p>
<p>Sometimes it just makes the coupling harder to see.</p>
<h2>One Implementation Is a Clue</h2>
<p>I don't subscribe to a hard rule that an interface must always have multiple implementations.</p>
<p>There are legitimate reasons to put a boundary in front of a single implementation.</p>
<p>External systems are an obvious example.</p>
<p>Testing can be another.</p>
<p>A meaningful architectural seam can exist before the second implementation arrives.</p>
<p>But one implementation should at least make you ask a question:</p>
<blockquote>
<p><strong>What variation am I modeling?</strong></p>
</blockquote>
<p>If the answer is:</p>
<blockquote>
<p>Well, someday we might...</p>
</blockquote>
<p>I become suspicious.</p>
<p>Someday is responsible for a lot of software.</p>
<p>Someday we'll support another database.</p>
<p>Someday we'll have multiple cloud providers.</p>
<p>Someday this will become a distributed system.</p>
<p>Someday users will write plugins.</p>
<p>Someday we'll support seventeen programming languages.</p>
<p>Maybe.</p>
<p>When someday becomes a requirement, we can design for someday with considerably more information than we have now.</p>
<p>And if the current design makes that future literally impossible without rewriting the entire system, that's worth considering.</p>
<p>But there is an enormous amount of territory between:</p>
<blockquote>
<p>Don't make the future impossible.</p>
</blockquote>
<p>and:</p>
<blockquote>
<p>Implement the future now.</p>
</blockquote>
<p>We seem to confuse those surprisingly often.</p>
<h2>The Plugin System Nobody Asked For</h2>
<p>Plugin systems are one of my favorite examples.</p>
<p>Imagine adding plugins to Arid.</p>
<p>What can a plugin do?</p>
<p>Add a language?</p>
<p>Change normalization?</p>
<p>Replace duplicate detection?</p>
<p>Add output formats?</p>
<p>Filter findings?</p>
<p>Modify configuration?</p>
<p>Now we need a plugin API.</p>
<p>Then we need to decide which internal concepts are public.</p>
<p>Then those concepts need stability guarantees.</p>
<p>Then plugins need version compatibility.</p>
<p>Then failures need isolation.</p>
<p>Then documentation.</p>
<p>Then testing.</p>
<p>Then somebody writes a plugin that depends on behavior we thought was an implementation detail.</p>
<p>Congratulations.</p>
<p>Our tiny duplicate-code checker now has an ecosystem to govern.</p>
<p>For what requirement?</p>
<p>There isn't one.</p>
<p>A plugin architecture would not make Arid more flexible today.</p>
<p>It would create an obligation to remain flexible tomorrow.</p>
<p>That's different.</p>
<h2>Generalization Can Make the Current Problem Worse</h2>
<p>There's another cost to premature abstraction that bothers me more than the extra code.</p>
<p>It can make the abstraction <strong>less correct</strong>.</p>
<p>Arid's frontend is Python-aware for a reason.</p>
<p>A comment isn't simply "text following a comment delimiter."</p>
<p>A docstring isn't simply "a string."</p>
<p>A function signature has Python-specific syntax.</p>
<p>Structural context depends on Python syntax.</p>
<p>If I had started by demanding a language-neutral abstraction, I would have needed to decide what all programming languages have in common before I had completely solved the Python problem.</p>
<p>What is a <code>Function</code> in the generic model?</p>
<p>What is a <code>Comment</code>?</p>
<p>What is a <code>Docstring</code> in a language that doesn't have docstrings?</p>
<p>What is <code>StructuralScope</code> across Python, Rust, JavaScript, SQL, and whatever somebody asks for next?</p>
<p>Now we're not merely implementing duplicate detection.</p>
<p>We're designing a theory of programming languages.</p>
<p>All because somebody might want TypeScript someday.</p>
<p>No thanks.</p>
<p>Arid can understand Python correctly.</p>
<p>If another language becomes a real requirement later, <em>then</em> we can compare two concrete implementations and discover which concepts are genuinely shared.</p>
<p>That's usually a much better time to generalize.</p>
<p>The second implementation teaches you things the first one can't.</p>
<h2>Duplication Isn't Always Worse Than the Wrong Abstraction</h2>
<p>This is where DRY can get us into trouble.</p>
<p>We're trained to see duplication and eliminate it.</p>
<p>I'm literally building a tool that finds duplicated code, so I'm probably supposed to be careful here.</p>
<p>But eliminating duplication by creating the wrong abstraction can be worse than the duplication itself.</p>
<p>Two pieces of code can look similar today and evolve for completely different reasons tomorrow.</p>
<p>Combine them too early and you've coupled their futures.</p>
<p>The same thing happens architecturally.</p>
<p>We see two concepts that <em>might</em> eventually share behavior, so we manufacture a common parent before we understand either one.</p>
<p>Then reality arrives.</p>
<p>One implementation needs a special case.</p>
<p>Then another.</p>
<p>The abstraction starts accumulating flags.</p>
<p>Then optional methods.</p>
<p>Then configuration.</p>
<p>Eventually the "generic" abstraction is mostly a complicated description of the differences it was supposed to hide.</p>
<p>Sometimes duplication is information.</p>
<p>It tells you:</p>
<blockquote>
<p>These things look similar.</p>
</blockquote>
<p>It does <strong>not</strong> necessarily tell you:</p>
<blockquote>
<p>These things are the same concept.</p>
</blockquote>
<p>That's a decision we still have to make.</p>
<h2>Extensibility Is a Feature</h2>
<p>We often talk about extensibility as though every system should have as much of it as possible.</p>
<p>I don't think that's true.</p>
<p>Extensibility is a product capability.</p>
<p>Like any other capability, it has users, requirements, costs, and tradeoffs.</p>
<p>If third-party developers need to extend your system without modifying it, extensibility may be essential.</p>
<p>If your organization has five implementations behind a stable contract, abstraction may be essential.</p>
<p>If you're publishing a framework whose entire purpose is to support unknown use cases, flexibility may be the product.</p>
<p>But Arid is a CLI that finds duplicate Python code.</p>
<p>Its value isn't proportional to the number of ways it can be extended.</p>
<p>Its value comes from doing its one job correctly, quickly, and predictably.</p>
<p>That changes the architecture I want.</p>
<h2>Small Doesn't Mean Crude</h2>
<p>Architectural restraint doesn't mean throwing everything into one file until it becomes unbearable.</p>
<p>Look at Arid's internal model.</p>
<p>A prepared file owns its original source, normalized source, normalized lines, and segments.</p>
<p>A normalized line carries things the detector and reporting pipeline genuinely need: its range in normalized text, original source line, whether it's effective, and its structural context and scope.</p>
<p>A duplicate occurrence identifies a file and a normalized range.</p>
<p>A duplicate group contains its effective size and occurrences.</p>
<p>These are small types.</p>
<p>They exist because the domain has those concepts.</p>
<p>That's very different from introducing types whose primary purpose is to make the architecture look sophisticated.</p>
<p>The test I increasingly like is:</p>
<blockquote>
<p><strong>Can I explain why this abstraction exists without talking about a hypothetical future?</strong></p>
</blockquote>
<p>If I can say:</p>
<blockquote>
<p>We need this boundary because parsing Python and detecting repeated normalized sequences are different responsibilities.</p>
</blockquote>
<p>Good.</p>
<p>If I say:</p>
<blockquote>
<p>We need this because eventually we may support arbitrary parser backends selected dynamically from third-party plugins...</p>
</blockquote>
<p>I'm going to need considerably more evidence.</p>
<h2>What Happens When Requirements Change?</h2>
<p>The obvious objection is:</p>
<blockquote>
<p>Isn't this shortsighted? What happens when Arid needs another language?</p>
</blockquote>
<p>Then I change the architecture.</p>
<p>Seriously.</p>
<p>Architecture is not a one-time ceremony performed before implementation begins.</p>
<p>It's the structure of a living system.</p>
<p>If Arid someday has a legitimate requirement to support Rust source, I'll have something incredibly valuable that I don't have today:</p>
<p><strong>a second real language implementation.</strong></p>
<p>Then I can look at Python and Rust and ask:</p>
<p>What is actually common?</p>
<p>What must vary?</p>
<p>Where does the boundary belong?</p>
<p>Which assumptions in the Python implementation were language-specific?</p>
<p>Which concepts really are universal?</p>
<p>The abstraction designed from those answers is likely to be better than the one I invent today while staring at a single Python implementation.</p>
<p>Will refactoring cost something?</p>
<p>Of course.</p>
<p>So does maintaining an unnecessary abstraction for three years waiting for a requirement that never arrives.</p>
<p>Architecture is tradeoffs.</p>
<p>There is no option where we pay nothing.</p>
<h2>You Can Always Add Code Later</h2>
<p>This sounds ridiculously obvious, but software development sometimes behaves as if there's a code shortage.</p>
<p>There isn't.</p>
<p>If a requirement appears later, we're allowed to write more code.</p>
<p>If a second implementation appears, we're allowed to extract an interface.</p>
<p>If users need plugins, we're allowed to design a plugin model.</p>
<p>If the pipeline needs asynchronous execution, we're allowed to introduce it.</p>
<p>If Arid needs another language, we're allowed to refactor the frontend.</p>
<p>We don't receive bonus points for having predicted every requirement five years early.</p>
<p>In fact, predictions made too early can make the real requirement harder to implement because now it has to fit the imaginary one.</p>
<p>The goal isn't to avoid changing the architecture.</p>
<p>The goal is to make the architecture <strong>easy to change when reality gives us a reason to change it</strong>.</p>
<p>Those are very different objectives.</p>
<h2>Build the Architecture You Can Defend</h2>
<p>Arid 1.1.0 isn't architecturally small because I don't care about architecture.</p>
<p>It's small <strong>because I do</strong>.</p>
<p>Its pipeline has boundaries.</p>
<p>Its Python-specific parsing is isolated from duplicate detection.</p>
<p>Its internal representation carries the information downstream stages actually need.</p>
<p>Detection doesn't decide presentation.</p>
<p>Structural metadata describes findings without changing duplicate identity.</p>
<p>Those are architectural decisions.</p>
<p>But there is no generic language framework.</p>
<p>There is no detector hierarchy.</p>
<p>There is no plugin system.</p>
<p>There is no dependency-injection framework.</p>
<p>There is no async runtime.</p>
<p>Not because those things are bad.</p>
<p>Because Arid doesn't currently have problems that they solve.</p>
<p>That's the standard I want to apply more often:</p>
<blockquote>
<p><strong>Don't ask whether an abstraction could be useful. Ask what requirement makes it necessary.</strong></p>
</blockquote>
<p>If you can't name one, maybe don't build it yet.</p>
<p>Your small tool doesn't need a framework.</p>
<p>It needs an architecture that makes the problem it actually solves obvious.</p>
<p>Build that.</p>
<p>When the problem changes, change the architecture.</p>
<hr />
<p><a href="https://github.com/sponge-b0b/arid"><strong>Arid</strong></a> is an open-source Python duplicate-code checker written in Rust. This article discusses the architecture of <strong>Arid 1.1.0</strong>. Its intentionally small application pipeline can be seen in <a href="https://github.com/sponge-b0b/arid/blob/v1.1.0/src/lib.rs"><code>lib.rs</code></a>, and its core domain representation is in <a href="https://github.com/sponge-b0b/arid/blob/v1.1.0/src/model.rs"><code>model.rs</code></a>.</p>
<h3>About the Author</h3>
<p><strong>Bob Taylor</strong> is a software engineer and architect who builds developer tools and AI systems. He is currently developing <a href="https://github.com/sponge-b0b/arid">Arid</a> and <a href="https://github.com/sponge-b0b/Polaris">Polaris</a>.</p>
<p><a href="https://github.com/sponge-b0b">GitHub</a></p>
]]></content:encoded></item><item><title><![CDATA[Detecting Duplicate Python Code Is Harder Than Comparing Text]]></title><description><![CDATA[Duplicate-code detection sounds easy.
You have some source files. Find sequences of lines that occur more than once.
How hard could it be?
I asked essentially that question when I started building Ari]]></description><link>https://bobtaylor.hashnode.dev/detecting-duplicate-python-code-is-harder-than-comparing-text</link><guid isPermaLink="true">https://bobtaylor.hashnode.dev/detecting-duplicate-python-code-is-harder-than-comparing-text</guid><dc:creator><![CDATA[spongeb0b]]></dc:creator><pubDate>Mon, 17 Aug 2026 17:10:05 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a81e13cc6b5d0b7614be7da/d9990f58-1128-4d53-add0-e0110e029f8e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Duplicate-code detection sounds easy.</p>
<p>You have some source files. Find sequences of lines that occur more than once.</p>
<p>How hard could it be?</p>
<p>I asked essentially that question when I started building <a href="https://github.com/sponge-b0b/arid">Arid</a>, a duplicate-code checker for Python.</p>
<p>The answer, as it often is in software, was: it depends on what you mean.</p>
<p>What exactly is a duplicate?</p>
<p>Consider these two functions:</p>
<pre><code class="language-python">def save_customer(value):
    # Persist the customer
    result = serialize(value)
    database.save(result)
</code></pre>
<pre><code class="language-python">def save_account(value):
    # Store the account
    result = serialize(value)
    database.save(result)
</code></pre>
<p>As text, they're different.</p>
<p>As executable logic, they're the same.</p>
<p>If I change the comment again, is it suddenly different code?</p>
<p>What about the function name?</p>
<p>What about a docstring?</p>
<p>Imports?</p>
<p>Blank lines?</p>
<p>Decorators?</p>
<p>Formatting?</p>
<p>At some point duplicate-code detection stops being a string-comparison problem and becomes a language problem.</p>
<p>That was one of the first lessons I learned building Arid.</p>
<h2>First, Define "Duplicate"</h2>
<p>There are a lot of ways two pieces of code can be similar.</p>
<p>These are obviously identical:</p>
<pre><code class="language-python">value = calculate()
save(value)
</code></pre>
<pre><code class="language-python">value = calculate()
save(value)
</code></pre>
<p>Now change the variable name:</p>
<pre><code class="language-python">value = calculate()
save(value)
</code></pre>
<pre><code class="language-python">result = calculate()
save(result)
</code></pre>
<p>Are those duplicates?</p>
<p>A human can look at them and reasonably say yes.</p>
<p>Arid says no.</p>
<p>That's deliberate.</p>
<p>Arid 1.1.0 detects <strong>exact duplicate source after configured normalization</strong>. It is not trying to determine whether two pieces of code are semantically equivalent, structurally similar, or suspiciously alike.</p>
<p>Changing <code>value</code> to <code>result</code> changes the code being compared.</p>
<p>Changing a literal from <code>10</code> to <code>20</code> changes it.</p>
<p>Changing an expression changes it.</p>
<p>That's an important boundary because "find code that means roughly the same thing" is a very different problem from "find source that has actually been duplicated."</p>
<p>The first problem starts taking you toward AST similarity, clone classification, semantic analysis, and eventually a fairly interesting discussion about what "equivalent" even means.</p>
<p>I wasn't trying to solve that problem.</p>
<p>I wanted a fast replacement for the duplicate-code functionality I was using in Pylint.</p>
<p>Pylint's similarity checker already has useful concepts for this. By default, it can exclude comments, docstrings, imports, and function signatures from similarity calculation. <a href="https://pylint.readthedocs.io/en/stable/user_guide/configuration/all-options.html#similarities-checker">Those options are part of Pylint's similarity checker</a>.</p>
<p>Arid keeps that general idea.</p>
<p>The interesting part is figuring out how to do it correctly.</p>
<h2>A <code>#</code> Is Not Necessarily a Comment</h2>
<p>Let's start with comments.</p>
<p>This looks easy:</p>
<pre><code class="language-python">value = 42  # this is a comment
</code></pre>
<p>Remove everything after <code>#</code>.</p>
<p>Done.</p>
<p>Until this shows up:</p>
<pre><code class="language-python">value = "# this is not a comment"
</code></pre>
<p>Now our sophisticated duplicate-code checker has helpfully converted valid Python source into:</p>
<pre><code class="language-python">value = "
</code></pre>
<p>Excellent.</p>
<p>You can keep adding increasingly clever text-processing rules, or you can ask Python what the thing actually is.</p>
<p>Arid takes the second approach.</p>
<p>Its Python frontend tokenizes the source and identifies tokens whose kind is actually <code>Comment</code>. The comment's source range can then be removed from the representation used for duplicate matching.</p>
<p>So:</p>
<pre><code class="language-python">value = "# this is not a comment"
other = 42  # actual comment
</code></pre>
<p>normalizes to:</p>
<pre><code class="language-python">value = "# this is not a comment"
other = 42
</code></pre>
<p>when comments are ignored.</p>
<p>The distinction seems obvious when you see the example.</p>
<p>But that's the point.</p>
<p>The distinction is obvious because <strong>you understand Python syntax</strong>.</p>
<p>A text processor doesn't.</p>
<h2>A String Is Not Necessarily a Docstring</h2>
<p>Docstrings get more interesting.</p>
<p>Consider:</p>
<pre><code class="language-python">def calculate():
    """Calculate the current value."""
    value = 42
    return value
</code></pre>
<p>If docstrings are configured to be ignored, we don't want that string to participate in duplicate identity.</p>
<p>Now consider:</p>
<pre><code class="language-python">def calculate():
    value = 42
    """This is an ordinary string expression."""
    return value
</code></pre>
<p>Should that string disappear too?</p>
<p>No.</p>
<p>They're both string literals.</p>
<p>Only one is a docstring.</p>
<p>The difference isn't the quotes. The difference is <strong>where that expression exists in the Python program</strong>.</p>
<p>Arid identifies structural docstrings as string-expression statements in the docstring position of a module, class, or function body.</p>
<p>So this:</p>
<pre><code class="language-python">"""module documentation"""

class Customer:
    """class documentation"""

    def save(self):
        """method documentation"""
        persist()
</code></pre>
<p>can have all three docstrings excluded from matching.</p>
<p>But this:</p>
<pre><code class="language-python">def save():
    persist()
    """ordinary string expression"""
    finish()
</code></pre>
<p>keeps the string expression.</p>
<p>You can't make that distinction reliably by looking for triple quotes.</p>
<p>You have to understand the syntax tree.</p>
<p>And now our simple "compare some lines" project has a parser.</p>
<p>That escalated quickly.</p>
<h2>Function Signatures Are Worse Than They Look</h2>
<p>Ignoring function signatures sounds simple too.</p>
<p>Remove the <code>def</code> line:</p>
<pre><code class="language-python">def calculate(value):
</code></pre>
<p>Except Python doesn't require a function declaration to fit on one line.</p>
<p>It can look like this:</p>
<pre><code class="language-python">def calculate(
    value: dict[str, tuple[int, int]],
    multiplier: int = 2,
) -&gt; list[int]:
    return transform(value, multiplier)
</code></pre>
<p>Or:</p>
<pre><code class="language-python">async def calculate(
    value: dict[str, tuple[int, int]],
) -&gt; list[int]:
    return await transform(value)
</code></pre>
<p>There can be nested brackets, type annotations, default values, and plenty of colons inside those expressions before you reach the colon that actually terminates the function signature.</p>
<p>So "remove everything through the first colon" isn't going to last very long.</p>
<p>Arid walks the parser tokens beginning at <code>def</code> or <code>async</code>, tracks bracket nesting, and finds the colon that terminates the declaration at nesting level zero.</p>
<p>Then it masks that source range.</p>
<p>The body remains.</p>
<p>There's another detail I like here.</p>
<p>Decorators remain significant.</p>
<p>Given:</p>
<pre><code class="language-python">@transactional
def save(value):
    persist(value)
</code></pre>
<p>and:</p>
<pre><code class="language-python">@cached
def save(value):
    persist(value)
</code></pre>
<p>ignoring function signatures doesn't silently erase the decorators.</p>
<p>Arid removes the function declaration.</p>
<p>It doesn't remove everything vaguely associated with the function.</p>
<p>That distinction is easier to maintain when syntax tells you where the boundaries actually are.</p>
<h2>Then There Are Imports</h2>
<p>Imports have their own collection of small traps.</p>
<p>This:</p>
<pre><code class="language-python">import os
</code></pre>
<p>is easy.</p>
<p>This:</p>
<pre><code class="language-python">from package import (
    first,
    second,
    third,
)
</code></pre>
<p>takes several physical lines.</p>
<p>Imports can also occur inside control flow or functions:</p>
<pre><code class="language-python">if enabled:
    import optional_backend
    run()
</code></pre>
<p>If imports are ignored, Arid removes the import statement while retaining:</p>
<pre><code class="language-python">if enabled:
    run()
</code></pre>
<p>Then somebody writes:</p>
<pre><code class="language-python">import os; run()
</code></pre>
<p>If you remove only the AST range belonging to <code>import os</code>, you're left with:</p>
<pre><code class="language-python">; run()
</code></pre>
<p>Wonderful.</p>
<p>Or:</p>
<pre><code class="language-python">run(); import os
</code></pre>
<p>becomes:</p>
<pre><code class="language-python">run();
</code></pre>
<p>So Arid's frontend deliberately consumes the appropriate adjacent semicolon when an ignored statement shares a logical line with retained code.</p>
<p>The result in both cases is:</p>
<pre><code class="language-python">run()
</code></pre>
<p>It's a tiny implementation detail.</p>
<p>It's also exactly the kind of tiny implementation detail that separates "works on my example" from "works on Python source."</p>
<h2>Normalization Is Not Parsing</h2>
<p>At this point it would be easy to let the parser take over the entire design.</p>
<p>I didn't want that either.</p>
<p>Arid uses Python syntax to answer questions that require knowledge of Python:</p>
<ul>
<li><p>Is this actually a comment?</p>
</li>
<li><p>Is this string expression actually a docstring?</p>
</li>
<li><p>What source range belongs to this import?</p>
</li>
<li><p>Where does this function signature end?</p>
</li>
<li><p>Is this code associated with a module, class, or function?</p>
</li>
</ul>
<p>Then that knowledge crosses a boundary.</p>
<p>The Python frontend converts parser-specific information into ordinary source ranges and structural regions owned by Arid.</p>
<p>The normalization layer doesn't operate on AST nodes.</p>
<p>The duplicate detector certainly doesn't.</p>
<p>The basic relationship is:</p>
<pre><code class="language-text">Python source
    ↓
parser + tokenizer
    ↓
source masks + structural regions
    ↓
normalized lines
    ↓
duplicate detector
</code></pre>
<p>I think that distinction matters.</p>
<p><strong>Python syntax determines what participates in comparison. It does not perform the comparison.</strong></p>
<p>The parser is there because text alone can't reliably tell me which text should be ignored.</p>
<p>Once that question has been answered, the rest of Arid doesn't need to know which parser answered it.</p>
<h2>The Source You Compare Is Not the Source You Report</h2>
<p>Normalization creates another problem.</p>
<p>Suppose the original code is:</p>
<pre><code class="language-python">def calculate(
    first: int,
    second: int,
) -&gt; int:
    # Add the values
    result = first + second

    return result
</code></pre>
<p>With comments and signatures ignored, the meaningful normalized representation might effectively be:</p>
<pre><code class="language-python">result = first + second
return result
</code></pre>
<p>That's useful for matching.</p>
<p>It's terrible for reporting if you lose the relationship to the original file.</p>
<p>A developer doesn't want a diagnostic that says:</p>
<blockquote>
<p>Duplicate found at normalized line 2.</p>
</blockquote>
<p>Normalized line 2 does not exist in the file they're editing.</p>
<p>Arid therefore keeps the original physical source-line location on every normalized line.</p>
<p>The detector works with the normalized representation.</p>
<p>The report maps the result back to the original Python source.</p>
<p>That means ignored comments, docstrings, signatures, imports, and blank lines can disappear from duplicate identity without making the diagnostic point somewhere imaginary.</p>
<p>This sounds like bookkeeping.</p>
<p>It is bookkeeping.</p>
<p>Bookkeeping is architecture when getting it wrong makes the product useless.</p>
<h2>Four Lines Isn't Always Four Lines</h2>
<p>There's another deceptively small question:</p>
<p>What does <code>--min-lines 4</code> mean?</p>
<p>Four physical lines?</p>
<p>Four normalized lines?</p>
<p>Four lines containing actual code?</p>
<p>Consider:</p>
<pre><code class="language-python">values = [
    first,
    second,
]
</code></pre>
<p>The closing bracket is a normalized physical line.</p>
<p>But should <code>]</code> count as one of the four meaningful lines required to declare a duplicate?</p>
<p>Arid distinguishes a <strong>normalized line</strong> from an <strong>effective line</strong>.</p>
<p>A line is effective when it contains at least one alphanumeric character or underscore.</p>
<p>So punctuation-only lines can remain part of a repeated source sequence without artificially helping that sequence satisfy the configured minimum duplicate size.</p>
<p>Blank lines don't count either.</p>
<p>That means a reported four-line duplicate means four effective normalized lines satisfied the threshold, even if the corresponding physical source range spans more lines.</p>
<p>Again, this isn't difficult because the algorithm is exotic.</p>
<p>It's difficult because apparently simple words like <strong>line</strong> turn out to require definitions.</p>
<h2>Python-Aware Does Not Mean Semantic</h2>
<p>This is probably the most important boundary in Arid's normalization model.</p>
<p>Using a Python parser does <strong>not</strong> mean Arid performs semantic duplicate detection.</p>
<p>These two blocks:</p>
<pre><code class="language-python">value = calculate()
save(value)
</code></pre>
<pre><code class="language-python">result = calculate()
save(result)
</code></pre>
<p>are not duplicates to Arid.</p>
<p>Nor are:</p>
<pre><code class="language-python">if ready:
    execute()
</code></pre>
<p>and:</p>
<pre><code class="language-python">if is_ready:
    execute()
</code></pre>
<p>They may represent the same pattern.</p>
<p>They may deserve refactoring.</p>
<p>They may even have been created by copy and paste followed by renaming a variable.</p>
<p>Arid still says they're different.</p>
<p>Why?</p>
<p>Because the parser is being used to interpret Python syntax accurately, not to erase meaningful Python source until everything vaguely similar starts matching everything else.</p>
<p>There's a line somewhere between useful normalization and inventing equivalence.</p>
<p>As of Arid 1.1.0, that line sits in a fairly conservative place.</p>
<p>Comments, structural docstrings, imports, and function signatures can be configured out.</p>
<p>What remains must match exactly.</p>
<p>Could Arid eventually detect renamed-variable clones, structural clones, or fuzzy AST similarity?</p>
<p>Sure.</p>
<p>It could also become an IDE, package manager, database, and small accounting system.</p>
<p>The question isn't whether those things can be built.</p>
<p>The question is whether they're the problem Arid is supposed to solve.</p>
<p>For now, they're not.</p>
<h2>Description Is Not Identity</h2>
<p>Arid does use the Python structure for one other purpose: describing what it found.</p>
<p>A duplicate can be reported as declarative or executable and associated with module, class, or function scope.</p>
<p>For example, repeated class-level assignments might be described differently from repeated executable logic inside functions.</p>
<p>But that metadata doesn't change duplicate identity.</p>
<p>Two blocks don't become equal because they're both executable.</p>
<p>They don't stop being equal because one occurs in a different structural context.</p>
<p>First Arid answers:</p>
<blockquote>
<p>Is this source duplicated?</p>
</blockquote>
<p>Then it can help answer:</p>
<blockquote>
<p>What kind of source did I find?</p>
</blockquote>
<p>I deliberately keep those questions separate.</p>
<p>The moment classification starts deciding whether something "really counts" as duplication, the tool starts making domain judgments it doesn't have enough information to make.</p>
<p>Repeated declarative code might be framework boilerplate.</p>
<p>It might be accidental duplication.</p>
<p>It might be exactly what the project wants.</p>
<p>Arid doesn't know.</p>
<p>Neither does the AST.</p>
<h2>The Algorithm Wasn't the First Hard Part</h2>
<p>Arid ultimately uses a generalized suffix array and longest-common-prefix analysis to find repeated normalized sequences.</p>
<p>That's the algorithmically interesting part of the project, and I'll get into it separately.</p>
<p>But something surprised me while building the tool.</p>
<p>Before you can efficiently find repeated sequences, you have to decide <strong>what the sequence is</strong>.</p>
<p>That decision contains a surprising amount of the product's behavior.</p>
<p>Do comments matter?</p>
<p>Which strings are docstrings?</p>
<p>Do imports matter?</p>
<p>What is a function signature?</p>
<p>What happens to decorators?</p>
<p>What does a line mean?</p>
<p>How do normalized lines map back to physical source?</p>
<p>Does structural context affect equality?</p>
<p>How much difference is allowed before code stops being "the same"?</p>
<p>Those aren't suffix-array questions.</p>
<p>They're product-definition questions.</p>
<p>And they're language questions.</p>
<p>The duplicate detector can only be as correct as the representation you give it.</p>
<p>Feed it bad normalization quickly and all you've built is a very fast way to produce bad answers.</p>
<h2>The Parser Isn't the Point</h2>
<p>I started Arid because Pylint's duplicate-code checker was too slow for my workflow.</p>
<p>I expected performance to be the interesting problem.</p>
<p>And performance certainly mattered.</p>
<p>But building the tool reinforced something I keep running into in software architecture:</p>
<p><strong>Before optimizing the solution, define the problem precisely.</strong></p>
<p>"Find duplicate code" isn't precise enough.</p>
<p>"Find exact repeated Python source after removing a configurable set of syntactically understood constructs" is considerably closer.</p>
<p>Once that definition existed, a lot of architectural decisions became easier.</p>
<p>Use Python syntax where Python syntax matters.</p>
<p>Turn that knowledge into a small internal representation.</p>
<p>Keep parser details out of the detector.</p>
<p>Preserve the mapping back to the source developers actually edit.</p>
<p>And don't quietly turn exact duplicate detection into semantic similarity because the parser happens to make that possible.</p>
<p>It turns out duplicate-code detection is harder than comparing text.</p>
<p>Not because comparing text is hard.</p>
<p>Because deciding <strong>which text means what</strong> is.</p>
<hr />
<p><a href="https://github.com/sponge-b0b/arid"><strong>Arid</strong></a> is an open-source Python duplicate-code checker written in Rust. This article describes the normalization model in <strong>Arid 1.1.0</strong>. The implementation is available in <a href="https://github.com/sponge-b0b/arid/blob/v1.1.0/src/normalize.rs"><code>normalize.rs</code></a>, and the Python syntax frontend is in <a href="https://github.com/sponge-b0b/arid/blob/v1.1.0/src/python.rs"><code>python.rs</code></a>.</p>
<h3>About the Author</h3>
<p><strong>Bob Taylor</strong> is a software engineer and architect who builds developer tools and AI systems. He is currently developing <a href="https://github.com/sponge-b0b/arid">Arid</a> and <a href="https://github.com/sponge-b0b/Polaris">Polaris</a>.</p>
<p><a href="https://github.com/sponge-b0b">GitHub</a></p>
]]></content:encoded></item><item><title><![CDATA[Pylint Was Too Slow, So I Built Arid]]></title><description><![CDATA[I didn't set out to write a duplicate-code checker.
I was working on Polaris, a fairly large Python project, and doing what I normally do: running code-quality tools against it. Ruff handles most of w]]></description><link>https://bobtaylor.hashnode.dev/pylint-was-too-slow-so-i-built-arid</link><guid isPermaLink="true">https://bobtaylor.hashnode.dev/pylint-was-too-slow-so-i-built-arid</guid><category><![CDATA[Python]]></category><category><![CDATA[Rust]]></category><category><![CDATA[Software Engineering]]></category><category><![CDATA[Open Source]]></category><category><![CDATA[Development Tools]]></category><dc:creator><![CDATA[spongeb0b]]></dc:creator><pubDate>Sun, 16 Aug 2026 16:43:59 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a81e13cc6b5d0b7614be7da/8c36c3ae-cc44-4290-a68a-2e786d4fe763.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I didn't set out to write a duplicate-code checker.</p>
<p>I was working on <a href="https://github.com/sponge-b0b/Polaris">Polaris</a>, a fairly large Python project, and doing what I normally do: running code-quality tools against it. Ruff handles most of what I want from a Python linter these days, and it handles it very quickly.</p>
<p>There was one problem.</p>
<p>Ruff doesn't detect duplicated blocks of code.</p>
<p>That isn't an oversight in my configuration. <a href="https://github.com/astral-sh/ruff/issues/18432">Ruff simply doesn't support project-wide duplicate-code detection today</a>.</p>
<p>Pylint does.</p>
<p>Its <code>R0801</code> checker has been finding similar code for years, and Pylint also exposes the same capability through its standalone <a href="https://pylint.readthedocs.io/en/latest/additional_tools/symilar/index.html"><code>symilar</code></a> tool. It can ignore comments, docstrings, imports, and function signatures while looking for repeated blocks.</p>
<p>There was just one small problem.</p>
<p>It was slow.</p>
<p>On Polaris, it was <em>really</em> slow.</p>
<p>Eventually I got tired of waiting for it.</p>
<p>So I built <a href="https://github.com/sponge-b0b/arid">Arid</a>.</p>
<h2>How Hard Could It Be?</h2>
<p>Those may be some of the most dangerous words in software development.</p>
<p>At first glance, duplicate-code detection doesn't sound particularly complicated. Read some files, compare some lines, find the parts that repeat, print them out.</p>
<p>Done.</p>
<p>Except we aren't comparing text files. We're analyzing Python source code.</p>
<p>Suppose these two functions contain the same executable logic:</p>
<pre><code class="language-python">def save_customer(value):
    # Persist the current customer
    result = serialize(value)
    database.save(result)
</code></pre>
<pre><code class="language-python">def save_account(value):
    # Store the account
    result = serialize(value)
    database.save(result)
</code></pre>
<p>Are they duplicates?</p>
<p>If comments are ignored and function signatures are ignored, they probably should be.</p>
<p>Now add docstrings. Imports. Blank lines. Decorators. Multiline function signatures. Parenthesized expressions. Different physical source ranges. Same-file duplicates that overlap one another.</p>
<p>Suddenly "compare some lines" needs a little more definition.</p>
<p>Pylint already has an answer to many of these questions, and I wasn't trying to invent a completely different meaning for duplicate code. Arid started with the intent of Pylint's <code>R0801</code>: find repeated Python source while allowing things such as comments, docstrings, imports, and signatures to be excluded from the comparison.</p>
<p>But I didn't need to reproduce Pylint's implementation.</p>
<p>That distinction turned out to matter.</p>
<h2>One Job</h2>
<p>Once I decided to build the tool, I had one significant advantage over Pylint.</p>
<p>Arid only needed to do one thing.</p>
<p>Pylint is a general-purpose static-analysis system. Duplicate-code detection is one capability among many.</p>
<p>Arid has exactly one production responsibility:</p>
<blockquote>
<p>Find duplicated Python source code.</p>
</blockquote>
<p>That's it.</p>
<p>No formatting. No import sorting. No type checking. No complexity analysis. No security scanner. No dead-code detector.</p>
<p>Ruff already does a lot of those things exceptionally well. I have no interest in building a slower, less capable Ruff just so Arid can have a longer feature list.</p>
<p>In fact, one of the rules I settled on for Arid is fairly simple:</p>
<blockquote>
<p>If a feature naturally belongs in Ruff, it probably doesn't belong in Arid.</p>
</blockquote>
<p>That decision had architectural consequences.</p>
<p>Arid supports one programming language, so it doesn't have a generic language abstraction.</p>
<p>It has one detector, so it doesn't have a detector hierarchy.</p>
<p>It doesn't have a plugin system.</p>
<p>It doesn't have a reporter registry.</p>
<p>It doesn't have a dependency-injection framework.</p>
<p>It doesn't have an async runtime.</p>
<p>And I did not create a parser abstraction just in case I wake up one morning three years from now and decide to replace the parser.</p>
<p>I realize this may be shocking.</p>
<p>The duplicate-code checker somehow manages to function anyway.</p>
<p>Those choices are explicit in <a href="https://github.com/sponge-b0b/arid/blob/main/docs/arid-v1-technical-architecture-and-design.md">Arid's technical architecture</a>. Parser-specific knowledge stays in the Python frontend, while everything downstream operates on Arid-owned data structures rather than parser AST or token types.</p>
<p>That isn't an argument against abstraction.</p>
<p>It's an argument for paying for abstraction when you actually have a requirement for it.</p>
<p>Every abstraction has a cost. More types. More indirection. More concepts somebody has to understand. More extension points that have to remain stable. More opportunities to design for a future that never arrives.</p>
<p>If Arid eventually develops requirements that justify one of those abstractions, then I'll have a reason to build it.</p>
<p>Until then, I have a duplicate-code checker to write.</p>
<h2>Small Scope Does Not Mean a Trivial Problem</h2>
<p>Keeping the product small didn't make the underlying problem simple.</p>
<p>Arid still has to understand enough Python syntax to distinguish source that participates in duplicate identity from source that may be ignored.</p>
<p>The basic pipeline became:</p>
<pre><code class="language-text">Python source
    ↓
file discovery
    ↓
Python parse + tokenize
    ↓
Python-aware filtering + structural classification
    ↓
normalized source lines
    ↓
global exact-duplicate index
    ↓
maximal repeated blocks
    ↓
duplicate findings + metrics
</code></pre>
<p>The Python frontend uses syntax to identify comments, docstrings, imports, function declarations, structural scope, and their source ranges. It converts that parser-specific information into Arid's internal representation.</p>
<p>After that boundary, the duplicate-detection engine doesn't know what a Python AST node is.</p>
<p>That was deliberate.</p>
<p>The parser is a dependency.</p>
<p>Python is the domain.</p>
<p>Those aren't the same thing.</p>
<p>I want Arid to understand Python, but I don't want the entire application architecture to understand the implementation details of whichever parser happens to provide that information.</p>
<p>There's another important distinction in the design: structural information describes a duplicate, but it does not determine whether two blocks are duplicates.</p>
<p>Arid can tell you that repeated code is executable logic inside a function or declarative code associated with a class. That can be useful when deciding what deserves attention.</p>
<p>But "this looks like framework boilerplate" is a judgment.</p>
<p>So is "this duplicate is harmless."</p>
<p>So is "you need to refactor this."</p>
<p>Arid doesn't pretend to know your application's intent. Its job is to detect the duplication accurately and give you enough objective information to make your own decision.</p>
<h2>Exact Means Exact</h2>
<p>I also didn't want duplicate findings based solely on the assumption that two hashes being equal means two pieces of source code are equal.</p>
<p>Hashes can be useful internally.</p>
<p>They are not proof.</p>
<p>Arid v1 defines duplication as exact equality after the configured Python-aware normalization. Hashing can be an implementation technique, but equality eventually has to resolve to actual equality.</p>
<p>The global detection engine uses a generalized suffix array with longest-common-prefix analysis to identify repeated normalized source sequences. Arid then turns those candidates into maximal duplicate groups while handling things like overlapping occurrences and deterministic canonicalization.</p>
<p>That probably deserves an article of its own.</p>
<p>The important point here is that once duplicate detection became the entire problem instead of one feature inside a larger tool, I could choose an architecture specifically for that problem.</p>
<h2>Fast Was Still the Point</h2>
<p>All of this architectural discussion can make the project sound much more philosophical than it actually was.</p>
<p>Let's not rewrite history.</p>
<p>I built Arid because I was tired of waiting for Pylint.</p>
<p>So eventually I had to answer the obvious question:</p>
<p><strong>Is it actually faster?</strong></p>
<p>For Arid 1.0, I built a <a href="https://github.com/sponge-b0b/arid/blob/main/benchmarks/README.md">reproducible benchmark suite</a> around three real Python repositories at fixed revisions: Requests, Pydantic, and Polaris.</p>
<p>The benchmark isolates Pylint's duplicate-code checker, pins tool versions and repository revisions, records environment metadata, uses repeated Hyperfine measurements, and distinguishes comparisons with approximately equivalent semantics from comparisons against tools whose clone-detection semantics differ.</p>
<p>Against Pylint 4.0.6, the published Arid 1.0 measurements were:</p>
<table>
<thead>
<tr>
<th>Project</th>
<th>Python files</th>
<th>Arid vs. Pylint</th>
</tr>
</thead>
<tbody><tr>
<td>Requests</td>
<td>37</td>
<td><strong>196.79× faster</strong></td>
</tr>
<tr>
<td>Pydantic</td>
<td>404</td>
<td><strong>192.88× faster</strong></td>
</tr>
<tr>
<td>Polaris</td>
<td>1,452</td>
<td><strong>264.45× faster</strong></td>
</tr>
</tbody></table>
<p>On the pinned Polaris benchmark, Arid completed the duplicate scan in about 442 milliseconds.</p>
<p>Pylint took about 117 seconds.</p>
<p>That's the difference between a check I don't mind running and one that interrupts my workflow.</p>
<p>But benchmark numbers need context.</p>
<p>Pylint is not standing still.</p>
<p>Its upcoming <a href="https://pylint.readthedocs.io/en/latest/whatsnew/4/4.1/index.html">4.1 release</a> includes significant work on the duplicate-code checker: reuse of an already-parsed AST when running inside Pylint, rolling hashes with caching, and changes intended to avoid quadratic behavior in problematic inputs. The Pylint project reports improvements ranging from roughly 1.5× on small projects to 20× on large ones, along with lower memory usage.</p>
<p>Good.</p>
<p>I hope it gets even faster.</p>
<p>Arid doesn't need Pylint to be bad in order for Arid to be useful.</p>
<p>And those benchmark numbers are measurements of particular versions of two pieces of software on particular corpora.</p>
<p>They are not a law of physics.</p>
<p>If Arid's entire identity were "Pylint 4.0.6 is slow," the project would have a fairly short shelf life.</p>
<p>The original <em>motivation</em> was performance.</p>
<p>The resulting tool has a broader reason to exist: focused, Python-aware duplicate-code detection that can sit next to Ruff without requiring a general-purpose linter for that one remaining job.</p>
<p>Performance is still the reason I started.</p>
<p>It just isn't the only engineering property I care about now.</p>
<h2>Yes, I Built It With AI</h2>
<p>There's another part of the story worth being explicit about.</p>
<p>I built Arid with ChatGPT as an AI coding partner.</p>
<p>The same is true of Polaris.</p>
<p>I use AI extensively in my development workflow. It helps generate code, examine designs, write tests, review changes, reason about problems, and accelerate implementation.</p>
<p>I'm not particularly interested in pretending otherwise.</p>
<p>What I have found, though, is that generating code and engineering software are not the same activity.</p>
<p>The difficult parts don't disappear because an LLM can produce Rust.</p>
<p>You still have to decide what the product is supposed to do.</p>
<p>You have to define the invariants.</p>
<p>You have to recognize a bad abstraction when one appears.</p>
<p>You have to decide whether a result is actually correct.</p>
<p>You have to build validation that doesn't merely prove the implementation agrees with itself.</p>
<p>You have to benchmark honestly.</p>
<p>You have to reject unnecessary code.</p>
<p>And sooner or later, you have to decide that the thing is stable enough to put <code>1.0.0</code> on it.</p>
<p>AI changes how I write software.</p>
<p>It does not remove the need to engineer it.</p>
<p>That experience probably deserves an article too.</p>
<h2>The Tool I Wanted to Use</h2>
<p>Arid began with a very unremarkable engineering problem.</p>
<p>I had a useful tool.</p>
<p>One part of it was too slow for the way I wanted to work.</p>
<p>The tool I preferred to use for the rest of my Python linting didn't provide that capability.</p>
<p>So I wrote the missing piece.</p>
<p>I didn't need another Python linter.</p>
<p>I needed duplicate-code detection that was fast enough that I wouldn't think twice about running it.</p>
<p>That constraint eventually led to a Rust implementation, Python-aware normalization, exact matching, deterministic output, a suffix-array-based detector, a reproducible benchmark suite, and a deliberately small architecture.</p>
<p>But none of those things were the original idea.</p>
<p>The original idea was much simpler:</p>
<p><strong>I was tired of waiting.</strong></p>
<p><a href="https://github.com/sponge-b0b/arid"><strong>Arid</strong></a> is open source and available on GitHub. If duplicate-code detection is part of your Python workflow, give it a try. Feedback, bug reports, and contributions are welcome.</p>
<h3>About the Author</h3>
<p><strong>Bob Taylor</strong> is a software engineer and architect who builds developer tools and AI systems. He is currently developing <a href="https://github.com/sponge-b0b/arid">Arid</a>, a fast Python duplicate-code checker written in Rust, and <a href="https://github.com/sponge-b0b/Polaris">Polaris</a>, an AI-assisted portfolio intelligence platform.</p>
<p><a href="https://github.com/sponge-b0b">GitHub</a></p>
]]></content:encoded></item></channel></rss>