ruff-legibility
ruff-legibility is a Ruff-adjacent Python linter for readability and reviewability rules inspired by eslint-plugin-legibility.
Rules
Each rule has an inline dos / don'ts diff example in Examples.
| Code | Rule | Default |
|---|---|---|
LEG001 |
Limit readability operators inside a single expression. | on |
LEG002 |
Prefer a named boolean before operator-heavy if / while conditions. |
on |
LEG003 |
Limit nested control-flow depth. | on |
LEG004 |
Avoid complex ternary expressions. | on |
LEG005 |
Flag likely quadratic patterns such as nested loops and repeated membership checks in loops. | on |
LEG006 |
Avoid redundant boolean comparisons and boolean ternaries like flag == True or True if flag else False. |
on |
LEG007 |
Prefer positive condition names over names like is_not_ready. |
on |
LEG008 |
Avoid trivial wrapper functions that only forward parameters to another call. | on |
LEG009 |
Avoid else branches after a branch that already exits. |
on |
LEG010 |
Prefer guard clauses over wrapping the main path in one large if block. |
on |
LEG011 |
Limit consecutive collection-style method chains. | on |
LEG012 |
Prefer named values before returning computed expressions or building dict values. | on |
LEG013 |
Avoid mutations and assignment expressions hidden inside expressions. | on |
LEG014 |
Avoid standalone list mutation calls when an expression is clearer. | on |
LEG015 |
Prefer explicit collection composition over starred literal unpacking. | on |
LEG016 |
Require configured executable Python source files to start with a shebang. | on |
LEG017 |
Prefer smoke-testing installed Python package entry points. | on |
LEG018 |
Avoid repeated scans over the same collection in one scope. | on |
LEG019 |
Avoid aliases that only rename another value for one use. | on |
LEG020 |
Avoid lambdas that only forward their parameters to another callable. | on |
LEG021 |
Prefer a flat comprehension over map followed by flattening. | on |
LEG022 |
Avoid map/filter callbacks that keep every item unchanged. | on |
LEG023 |
Avoid fallback expressions that only return None unchanged. |
on |
LEG024 |
Prefer set or dict lookups over long equality-or chains. | on |
LEG025 |
Require files in named subdirectories to match the directory name. | on |
LEG026 |
Avoid filenames that mix casing conventions. | on |
LEG027 |
Avoid comprehensions that keep every item unchanged. | on |
LEG028 |
Prefer comprehensions over map/filter calls with lambdas. | on |
LEG029 |
Prefer comprehensions over simple list-building append loops. | on |
LEG030 |
Avoid filtering the same collection with comprehensions multiple times in one scope. | on |
LEG031 |
Avoid deep subscript chains without named intermediate values. | on |
LEG032 |
Prefer named context when wrapping or logging broad exceptions. | on |
LEG033 |
Avoid positive boolean names assigned from inverted expressions. | on |
LEG034 |
Reject unmatched comments and unowned comment stacking within functions. | on |
Examples
Removed lines are don'ts. Added lines are dos.
LEG001 max-expression-operators
LEG001 example diff
- return user.is_active and user.score > 10 and (user.role == "admin" or user.role == "owner")
+ is_admin = user.role == "admin"
+ is_owner = user.role == "owner"
+ has_privileged_role = is_admin or is_owner
+ return user.is_active and user.score > 10 and has_privileged_role
LEG002 hoist-if-operators
LEG002 example diff
- if user and user.is_active and not user.is_locked:
- send_invite(user)
+ can_invite_user = user and user.is_active and not user.is_locked
+ if can_invite_user:
+ send_invite(user)
LEG003 max-control-flow-depth
LEG003 example diff
- if user:
- for invite in invites:
- if invite.pending:
- while invite.retries < 3:
- send_invite(invite)
+ if not user:
+ return
+ pending_invites = [invite for invite in invites if invite.pending]
+ for invite in pending_invites:
+ retry_invite(invite)
LEG004 no-complex-ternary
LEG004 example diff
- label = "owner" if user.is_owner else "admin" if user.is_admin else "member"
+ if user.is_owner:
+ label = "owner"
+ elif user.is_admin:
+ label = "admin"
+ else:
+ label = "member"
LEG005 no-quadratic-patterns
LEG005 example diff
- for user in users:
- for owner in owners:
- if user.id == owner.user_id:
- assign_owner(user, owner)
+ owners_by_user_id = {owner.user_id: owner for owner in owners}
+ for user in users:
+ owner = owners_by_user_id.get(user.id)
+ if owner is not None:
+ assign_owner(user, owner)
LEG006 no-redundant-boolean-logic
LEG006 example diff
- return True if flag == True else False
+ return flag
LEG007 prefer-positive-condition-names
LEG007 example diff
- is_not_ready = status != "ready"
- if is_not_ready:
+ is_ready = status == "ready"
+ if not is_ready:
return
LEG008 no-trivial-wrapper-functions
LEG008 example diff
- def normalize(value):
- return clean(value)
- result = normalize(value)
+ result = clean(value)
LEG009 prefer-early-return
LEG009 example diff
- if not user:
- return None
- else:
- return user.email
+ if not user:
+ return None
+ return user.email
LEG010 prefer-guard-clauses
LEG010 example diff
- if user:
- prepare(user)
- send_invite(user)
+ if not user:
+ return
+ prepare(user)
+ send_invite(user)
LEG011 max-array-chain-depth
LEG011 example diff
- users = query.filter(active=True).order_by("name").limit(10)
+ active_users = query.filter(active=True)
+ sorted_users = active_users.order_by("name")
+ users = sorted_users.limit(10)
LEG012 no-computed-values
LEG012 example diff
- return total + tax - discount
+ subtotal = total + tax
+ return subtotal - discount
LEG013 no-hidden-side-effects
LEG013 example diff
- return cache.setdefault(key, build_value())
+ if key not in cache:
+ cache[key] = build_value()
+ return cache[key]
LEG014 no-standalone-array-mutations
LEG014 example diff
- items.append(item)
- return items
+ return items + [item]
LEG015 prefer-concat-object-assign
LEG015 example diff
- payload = {**base_payload, "id": user_id}
+ payload = base_payload | {"id": user_id}
LEG016 require-executable-shebang
LEG016 example diff
- # scripts/report.py
- print("ok")
+ #!/usr/bin/env python3
+ print("ok")
LEG017 no-direct-python-bin-smoke
LEG017 example diff
- subprocess.run(["python", "src/example/cli.py", "--help"], check=True)
+ subprocess.run(["example", "--help"], check=True)
LEG018 no-repeated-collection-search
LEG018 example diff
- if user_id in ids and owner_id in ids:
- return True
+ id_lookup = set(ids)
+ required_ids = {user_id, owner_id}
+ return required_ids.issubset(id_lookup)
LEG019 no-single-use-renaming-alias
LEG019 example diff
- current_user = request.user
- return current_user.email
+ return request.user.email
LEG020 no-unnecessary-lambda
LEG020 example diff
- users = sorted(users, key=lambda user: normalize(user))
+ users = sorted(users, key=normalize)
LEG021 prefer-flat-comprehension
LEG021 example diff
- values = list(chain.from_iterable(map(expand, items)))
+ values = [value for item in items for value in expand(item)]
LEG022 no-identity-array-callback
LEG022 example diff
- names = list(map(lambda name: name, names))
+ names = list(names)
LEG023 no-redundant-none-fallback
LEG023 example diff
- return value if value is not None else None
+ return value
LEG024 prefer-object-lookup
LEG024 example diff
- if status == "new" or status == "open" or status == "pending":
- queue_item(item)
+ if status in {"new", "open", "pending"}:
+ queue_item(item)
LEG025 require-filename-matches-dirname
LEG025 example diff
- src/billing/customer/profile.py
+ src/billing/customer/customer.py
LEG026 no-mixed-filename-casing
LEG026 example diff
- user_Profile.py
+ user_profile.py
LEG027 no-identity-comprehension
LEG027 example diff
- copied = [item for item in items]
+ copied = list(items)
LEG028 prefer-comprehension-over-map-filter
LEG028 example diff
- names = list(map(lambda user: user.name, users))
+ names = [user.name for user in users]
LEG029 no-loop-append-comprehension
LEG029 example diff
- names = []
- for user in users:
- names.append(user.name)
+ names = [user.name for user in users]
LEG030 no-repeated-comprehension-filter
LEG030 example diff
- active_users = [user for user in users if user.active]
- admin_users = [user for user in users if user.is_admin]
+ filtered_users = [user for user in users if user.active or user.is_admin]
+ active_users = [user for user in filtered_users if user.active]
+ admin_users = [user for user in filtered_users if user.is_admin]
LEG031 no-deep-subscript-chain
LEG031 example diff
- return payload["user"]["profile"]["email"]
+ user = payload["user"]
+ profile = user["profile"]
+ return profile["email"]
LEG032 prefer-named-exception-context
LEG032 example diff
- except Exception as error:
- raise RuntimeError(error)
+ except Exception as error:
+ message = "Failed to load user profile"
+ raise RuntimeError(message) from error
LEG033 no-boolean-parameter-name-drift
LEG033 example diff
- is_ready = status != "ready"
+ is_ready = status == "ready"
LEG034 no-unmatched-comments
LEG034 checks standalone and trailing inline # comments.
LEG034 example diffs
Fails with two diagnostics:
# Explain the assignment.
value = 1 # Keep this value stable.
Fix:
- # Convert cents to dollars.
- total_dollars = cents / 100 # Store the converted total.
+ total_dollars = cents / 100
LEG034 setup
[tool.ruff-legibility]
comment-matchers = [
'\b(ENG|OPS)-\d+\b',
'^\s*(noqa\b|type:\s*|ruff:\s*noqa\b)',
'^\s*(fmt|isort):\s*(on|off|skip)\b',
'^\s*pragma:\s*no cover\b',
]
comment-prefix-identifiers = ["HUMAN", "LEGAL", "SECURITY"]
comment-suffix-identifiers = ["@owned"]
| Setting | Allows the comment | Exempt from one-comment-per-function limit |
|---|---|---|
comment-matchers |
Regex matches | no |
comment-prefix-identifiers |
Block starts with identifier | yes |
comment-suffix-identifiers |
Block ends with identifier | yes |
Matching is case-insensitive and ignores the leading #.
Regex-matched comments
Passes with the setup above:
# ENG-481: Provider retries must remain ordered.
for retry in retries:
send(retry)
timeout = 30 # OPS-92: Keep worker and provider timeouts aligned.
Only one regex-matched physical comment is allowed per function. This reports the second comment:
def load_value():
value = load() # ENG-481: Load the configured value once.
# ENG-482: Preserve the configured fallback.
return value or fallback()
Fix by keeping one useful comment:
def load_value():
- value = load() # ENG-481: Load the configured value once.
- # ENG-482: Preserve the configured fallback.
+ # ENG-481: Load once and use the configured fallback when empty.
+ value = load()
return value or fallback()
The same limit applies immediately above and below a function. The second line in each block reports:
# ENG-481: First leading comment.
# ENG-482: Second leading comment.
def first():
return 1
def second():
return 2
# ENG-483: First trailing comment.
# ENG-484: Second trailing comment.
An indented comment after the final statement still belongs to the function. This reports the second comment:
def load_value():
# ENG-481: Load the configured value.
value = load()
return value
# ENG-482: Preserve the provider contract.
Nested functions have separate counts:
def outer():
# ENG-481: Preserve the outer contract.
value = load()
def inner():
# ENG-482: Preserve the inner contract.
return value
return inner()
A comment immediately below a nested function belongs to that nested function. This reports the second comment:
def outer():
def inner():
# ENG-481: Preserve the inner contract.
return 1
# ENG-482: Keep this adjacent to the inner function.
return inner()
Human-owned comment blocks
Prefix- and suffix-owned blocks pass and do not count toward the function limit:
def load_value():
# HUMAN: The provider requires ordered retries.
# Reordering these calls breaks failover.
retry()
# The operator selects this timeout.
# The worker must use the same value. @owned
return timeout
Identifiers require a boundary:
- # HUMANIZED: This is not owned.
+ # HUMAN: This block is owned.
- value = 1 # Preserve this not@owned
+ value = 1 # Preserve this. @owned
Ignored Python metadata
These pass without comment configuration:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Module documentation."""
def load():
"""Load the value."""
Encoding cookies must occupy a comment-only line:
- value = 1 # coding: utf-8
+ # coding: utf-8
+ value = 1
Recipes
Use the same committed comment ownership configuration in every context. Change only the scope and when a diagnostic blocks work.
| Context | Why | How |
|---|---|---|
| Agent | Agents can produce redundant comments at scale. | Block every edited Python file. |
| Human | A hard failure on every save interrupts editing. | Warn while editing; block before commit. |
| CI | Local checks can be skipped or narrowly scoped. | Block the whole repository. |
Run the tested Docker examples with make test-e2e.
Agent: block edited files
Why: an agent should remove narration it introduced, not disguise it with an ownership marker. Existing human-owned comments remain untouched.
How: install the shared skill once, give the agent the policy, and make its edited-file check blocking.
ruff-legibility install-skill --target auto
Use $ruff-legibility on every Python file you edit. Remove unmatched comments
you add. Do not add ownership markers, matchers, or noqa suppressions.
ruff check src/package/changed.py
ruff-legibility check src/package/changed.py
Human: warn while editing, block before commit
Why: advisory output keeps the policy visible without breaking the edit loop. The blocking check keeps unmatched comments out of commits.
How: use --exit-zero while editing, then remove it for the changed-file gate.
ruff-legibility check src/package --select LEG034 --exit-zero
ruff-legibility check src/package/changed.py
pre-commit can install the pinned package and pass staged Python filenames to
the blocking command:
repos:
- repo: local
hooks:
- id: ruff-legibility
name: ruff-legibility
entry: ruff-legibility check
language: python
additional_dependencies:
- ruff-legibility==0.4.0
types: [python]
CI: block the repository
Why: CI is the backstop for skipped hooks, partial local checks, and files changed outside the normal edit loop.
How: install from the lockfile, run both linters across the repository, and use GitHub annotations for review visibility.
- run: uv sync --locked --all-groups
- run: uv run --locked ruff check .
- run: uv run --locked ruff-legibility check . --output-format github
Existing codebase: warn, fix, then block
Why: a new gate should not stop unrelated work because of an existing comment baseline.
How: inventory LEG034 without blocking, tune the ownership configuration,
fix the baseline, then remove --exit-zero.
ruff-legibility check . --select LEG034 --exit-zero
ruff-legibility check . --select LEG034
Install
pip install ruff-legibility
For local development:
uv sync --all-groups
make check
Usage
Run it beside Ruff:
ruff check .
ruff-legibility check .
ruff-legibility check src tests --output-format json
ruff-legibility check . --select LEG001,LEG002 --ignore LEG007
ruff-legibility check . --exit-zero
Agent Skill
The package includes a reusable Claude/Codex skill, but it is never installed
automatically. Install it explicitly when you want local agents to use the
ruff-legibility loop:
ruff-legibility install-skill
ruff-legibility install-skill --target auto
ruff-legibility install-skill --target codex
ruff-legibility install-skill --path ~/.agents/skills --force
Default installs copy the skill to ~/.agents/skills/ruff-legibility. Codex
target installs copy it to $CODEX_HOME/skills/ruff-legibility, or
~/.codex/skills/ruff-legibility when CODEX_HOME is not set.
Auto target detection uses the packaged static target registry, prefers a
configured target such as CODEX_HOME, then falls back to an existing known
skill root, then ~/.agents/skills.
Use --path for any other agent skill root instead of adding vendor-specific
folders to this repository.
After installing the skill, use it in an agent prompt:
Use $ruff-legibility to check Python readability and iterate on LEG diagnostics.
Generate tracked package and shared-skill files:
make build-agent
Generate ignored local rule pointers:
uv run python -m scripts.agent.build --target codex
uv run python -m scripts.agent.build --target claude
Check generated files without writing:
make check-agent
Configuration
Keep # noqa: LEG001 valid when Ruff checks unknown noqa codes:
[tool.ruff.lint]
external = ["LEG"]
Configuration can live in pyproject.toml under [tool.ruff-legibility], or in ruff-legibility.toml / .ruff-legibility.toml.
[tool.ruff-legibility]
select = ["LEG"]
extend-select = []
ignore = ["LEG007"]
extend-ignore = []
exclude = [".venv", "build", "dist"]
max-expression-operators = 4
max-if-operators = 0
max-ternary-operators = 2
max-computed-value-operators = 1
max-control-flow-depth = 3
max-array-chain-depth = 2
min-object-lookup-chain-length = 3
min-dirname-match-depth = 3
comment-matchers = []
comment-prefix-identifiers = []
comment-suffix-identifiers = []
[tool.ruff-legibility.per-file-ignores]
"tests/*" = ["LEG003"]
Standalone config files omit the tool.ruff-legibility wrapper:
select = ["LEG"]
ignore = ["LEG007"]
This repository includes a ruff-legibility.toml for its own source. The default package thresholds stay stricter than the project-local development config.
Development
Common commands:
uv sync --all-groups
uv run ruff check .
uv run ruff-legibility check src tests scripts
uv run pytest
uv build
Repository scripts:
./scripts/setup.sh
./scripts/test_setup.sh
uv run python -m scripts.agent.build --target package,agents
Local release artifact checks should use:
uv build --no-sources
Tagged releases are published by GitHub Actions. The workflow builds a Python
3.11+ abi3 manylinux wheel so supported CPython versions can install without
building from source.
Publishing is configured for PyPI Trusted Publishing:
uv publish
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file ruff_legibility-0.4.0.tar.gz.
File metadata
- Download URL: ruff_legibility-0.4.0.tar.gz
- Upload date:
- Size: 80.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
uv/0.7.19
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ff307f1754b05034d0b72da7b95ac9351001aac8ec3620de9c7e6a94b88ff118
|
|
| MD5 |
6358623fd83b93d49af9a4fc19da5f23
|
|
| BLAKE2b-256 |
a2e25191f13a7949eb53028e0a0193e680e9607a4afb2091ceddf7cf3de92d09
|
File details
Details for the file ruff_legibility-0.4.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.
File metadata
- Download URL: ruff_legibility-0.4.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- Upload date:
- Size: 1.2 MB
- Tags: CPython 3.11+, manylinux: glibc 2.17+ x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
uv/0.7.19
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
eaa40b7053750fa2ad1c015b1f90e7fd6b091f514732f39fdf7cd1da4c1bf60b
|
|
| MD5 |
61cc0d378b3b30a952d6e0abb48b0f0a
|
|
| BLAKE2b-256 |
99359dfa545e9c74e9e96c59e380375d339c1af4146933a0c3f5fbe27b3427d4
|