Skip to main content

darnlang

Keep a repository's prose in one language — including the parts a file linter can never see.

Most "no foreign language in the codebase" checks look at source files. That leaves the surfaces that are actually published unguarded: commit messages, pull-request titles and descriptions, and issues. Measured on one public repository the day this tool was extracted — with its file gate green and its tree at zero:

Surface In the wrong language Was anything checking it?
.py comments 0 yes — the only one
.md documentation 5, for two releases no
commit subjects on main 7 no
PR titles 4 no
PR descriptions 7 more no
open issues 2 of 2 no

Nobody had been careless seventeen times. There was simply nothing looking — and those surfaces are the least retractable ones you have. An indexed pull-request title cannot be taken back.

Install

Not on PyPI yet, so install from git and pin the tag:

# no install at all
uvx --from git+https://github.com/txemi/darnlang@v0.9.0 darnlang check

# or properly
pipx install git+https://github.com/txemi/darnlang@v0.9.0
pip install 'darnlang[strict] @ git+https://github.com/txemi/darnlang@v0.9.0'   # + deep layer

Once it is published the short forms — uvx darnlang, pip install darnlang — start working and this section shrinks. They are written out in full here rather than promised, because a README that documents an install command that does not work is the first thing a reader tries and the first way a tool loses their trust.

Python 3.10+. No runtime dependencies in the default path — that is what lets it run first in a pre-commit hook that fires dozens of times a day, and what stops it degrading into a silent no-op on a machine nobody provisioned.

Use

darnlang check                       # whole tree vs the baseline          (CI)
darnlang check --diff                # only the lines your commit ADDS     (pre-commit)
darnlang prose .git/COMMIT_EDITMSG --git-comments   # a commit message
darnlang prose - --label "PR title"  # anything, from stdin                (CI)
darnlang update-baseline             # record the new, lower count

Exit codes are part of the contract, because a caller has to tell "there is debt" from "I could not run": 0 clean · 1 findings · 2 coverage changed, re-seed · 3 usage or environment error.

The ratchet

A repo adopting the rule late already carries debt — the first one to run this had ~1300 offending lines across 172 files. Demanding they all be fixed before the next commit just gets the gate deleted. So legacy lines sit in a baseline whose count may only decrease, while new lines must be clean from now on. A repo that starts clean pins its baseline at 0, which is fail-closed with no extra machinery.

darnlang update-baseline    # writes .darnlang-baseline.json — commit it

Scope

The default is .py. Widen it per repo, on your own schedule:

darnlang check --ext .py,.md,.html      # or DARNLANG_EXTS=.py,.md
darnlang check --ext all

The default is conservative for a measured reason: switching one documentation-heavy repo to .md surfaced 820 lines at once on top of 1242 of existing debt. That is not a fix, it is an avalanche — and an avalanche is how a gate ends up switched off. When you do widen, the tool says so in its own words rather than reporting it as "the count grew":

darnlang: COVERAGE CHANGED -- the baseline counted .py but this run judges .md, .py
  now also judged: .md  (current total: 820)

This is an ADOPTION, not a regression: re-seed once with `darnlang update-baseline`
and the number may only fall from there.

It still fails, because coverage that changes unnoticed is how a gate quietly loosens. And it names the direction, so coverage going down can never hide inside the same message.

What counts as prose

Two file families, two rules, because getting this wrong is the classic failure:

  • code (.py) — only comments and docstrings are judged. Judging every line fires on identifiers.
  • doc (.md, .html, .rst, .txt, templates, and anything unrecognised) — everything is judged except fenced code blocks, inline `code` spans and URLs.

Applying the code rule to a document is not a small miss: it rejects any line containing : or (, which is most of a written paragraph. A sibling tool once opened .html to its scan but kept the code rule, and the 320-line interface template that motivated the change stayed invisible.

File names are judged too, split on _ - . /. A file called analisis_de_errores.py is exactly as public as its contents, and no tool this one descends from looked at names.

Detection

Three layers, cheapest first:

  1. accented characters and inverted punctuation — free, never wrong on its own;
  2. a wordlist of function words that are not also English words — free; drop a darnlang-words.txt in the repo to replace it;
  3. langdetect over the whole textprose --strict, and it needs the strict extra.

⚠️ Layer 3 needs PROSE. Do not feed it a bare commit subject. Measured: hooks: apply darnlang to darnlang is classified Tagalog with 0.86 confidence, and ci: uv venv --clear, setup-uv makes one Estonian with 0.71 — both perfectly English. A subject line is mostly identifiers, and two repetitions of an invented word dominate it. Neither confidence, nor length, nor word count separates those from real findings, which score exactly as high. Use --strict on an issue, a PR description, or a message with a body; on a subject alone it will block honest English, and a gate that does that is a gate people route around.

There is no check --strict for the same reason, one step further: on a tree it produced 11 findings against this repo's own English source, at least 8 of them plainly English. langdetect classifies a sentence; a line of a document is a fragment.

Layer 1 alone is the usual approach, and it is why the usual approach misses: about a third of the Spanish in the repos this was built for carries no accent at all. Layer 2 is what catches that third, and it is why the wordlist is curated rather than clever — no, total, final, sin and solo are deliberately absent, because each one fires on ordinary English. Every removal was made after measuring a real false positive, not out of caution.

A genuine false positive is silenced with lang-ok on the line — # lang-ok in code, <!-- lang-ok --> in Markdown.

Wiring it up

pre-commit (.pre-commit-config.yaml):

repos:
  - repo: https://github.com/txemi/darnlang
    rev: v0.9.0
    hooks:
      - id: darnlang           # added lines only
      - id: darnlang-commit-msg

GitHub Actions — the three published surfaces, which is the point of the tool:

- uses: txemi/darnlang@v0.9.0
  with:
    ext: .py,.md
    surfaces: files,commits,pr    # issues need their own workflow: see action.yml

Jenkins — a declarative stage (complete pipeline: examples/Jenkinsfile). Worth documenting rather than leaving as an exercise, because a self-hosted controller is often the wall that keeps working when a hosted one stops: it does not meter minutes and it does not stop because a card expired.

stage('darnlang') {
  steps {
    sh '''
      set -eu
      # uvx needs no venv and leaves nothing behind. Pin the ref: a version that cannot be resolved
      # fails with rc=1, which a naive pipeline then reports as "wrong language".
      export PATH="$HOME/.local/bin:$PATH"
      uvx --from git+https://github.com/txemi/darnlang@v0.9.0 darnlang check --ext .py,.md
    '''
  }
}

Three things that are specific to Jenkins and cost time to rediscover:

  • The workspace is a real git checkout, so the repo root resolves normally and the baseline is found where you committed it. But a shallow clone (the default in many jobs) still carries the full working tree, which is all this tool reads — no extra depth needed for check.

  • Distinguish "there is debt" from "I could not run". Exit 1 is findings, 3 is an environment problem — a missing baseline, or a scan that examined zero files. Failing the build on both is right; treating 3 as a pass is how a gate silently stops gating:

    script {
      def rc = sh(returnStatus: true, script: 'uvx --from "$DARNLANG" darnlang check --ext .py,.md')
      if (rc == 1) { error 'darnlang found lines in the wrong language' }
      if (rc != 0) { error "darnlang could not run (rc=${rc}) — this is not a language finding" }
    }
    
  • The PR surfaces work here too, via the GitHub/Bitbucket Branch Source plugin, which exposes the change's title and body as environment variables on a multibranch pipeline:

    when { changeRequest() }
    steps {
      sh '''
        set -eu
        printf '%s\\n\\n%s\\n' "$CHANGE_TITLE" "${CHANGE_BODY:-}" > pr.txt
        uvx --from "$DARNLANG" darnlang prose pr.txt --label "PR title/description"
        # FETCH_HEAD, not origin/$CHANGE_TARGET: a Jenkins workspace is often cloned with a narrow
        # single-branch refspec, and then `git fetch <remote> <branch>` updates no tracking ref.
        git fetch --no-tags origin "$CHANGE_TARGET"
        git log --format=%B "FETCH_HEAD..HEAD" > msgs.txt
        uvx --from "$DARNLANG" darnlang prose msgs.txt --label "set of commit messages"
      '''
    }
    

    Note the variables go through the environment, never interpolated into the Groovy string: a change title is written by whoever opened it, and sh "echo ${env.CHANGE_TITLE}" is a shell injection. Jenkins already exports them, so "$CHANGE_TITLE" inside single-quoted ''' is both safer and shorter.

    Issues have no Jenkins equivalent at all — there is no webhook that fires before an issue is published, on any forge. That surface stays with the forge's own automation (examples/lang-issue.yml), and it can only label, never block.

Privacy: the same check, twice, on purpose

.github/workflows/privacy-gate.yml blocks private references from reaching this repo, and hooks/pre-push runs the same check before the push. That is not redundancy — CI fires after, and by then two of the three surfaces are permanent: a pushed commit message needs a history rewrite, and a pull-request body is indexed public HTML that editing does not retract. Local is the last point where the answer is still "amend it".

The pattern list is never in this repo — a list of private names cannot live in what it protects. CI reads a secret; the hook reads $PRIVACY_DENYLIST_FILE or ~/.config/privacy-denylist.txt.

The two ends differ on purpose: CI fails closed without the list, the hook skips loudly. The hook protects the author from publishing their own private names, and somebody who cloned this repo has none of them — blocking their push over a file they cannot have would be hostile for no gain.

Why the matched text is not printed

By default findings are reported as path:line, without the offending text. CI logs of a public repository are public, and the offending line is the single item on the page most likely to contain something you did not mean to publish. --show-text opts back in, and is what you want locally.

Licence

GPL-3.0-or-later.

Release files for darnlang 0.9.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for darnlang 0.9.1
File Size Uploaded
darnlang-0.9.1.tar.gz 75.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for darnlang 0.9.1
File Interpreter ABI Platform
darnlang-0.9.1-py3-none-any.whl Python 3 none any Details

Total release size: 119.2 kB

Release files / darnlang-0.9.1.tar.gz

Download URL darnlang-0.9.1.tar.gz
Size 75.7 kB
Tags Source
SHA-256 checksum
How to use checksums
7fc15e20e721a50b4ae7ff9779fb626b6bff613a4f8b33cee338945d3cb2d6b1
BLAKE2b-256 checksum
How to use checksums
90261269b075cb3e5736d05f8da85f001709ee40c5df8301e69005454fde5d22
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 14, 2026.

Transparency log

Release files / darnlang-0.9.1-py3-none-any.whl

Download URL darnlang-0.9.1-py3-none-any.whl
Size 43.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
9d408b7ee18a8d48ff5e6beaf08c018add70de345f48a8b24a0b5bfc32866efa
BLAKE2b-256 checksum
How to use checksums
1187f4f036e1c4821846248945200206f137612ae708055aba5132e9fac6e3d4
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 14, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.9.1 This release

2 release files

0.9.0

2 release files

0.8.0

2 release files

0.7.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page