Vai al contenuto

๐Ÿšฆ Lint Gates & Code Policy

LibreFolio's Python hygiene rules are enforced by gates, not by convention documents: everything listed here lives in [tool.ruff.lint] in the repo-root pyproject.toml and runs on every ./dev.py lint invocation. The audit lesson behind this setup (audit 08, P1-2): "level zero" cleanups do not hold without a gate that fails the build when the level regresses.

./dev.py lint            # ruff check --fix (auto-fixes what is safe)
./dev.py format          # black, line-length 300
./dev.py lint --dead-code  # vulture dead-code scan (config in pyproject.toml)

๐Ÿงฑ The gate list

Gate Rule family Why it is on
E / W pycodestyle errors / warnings Baseline style (E501 line-length stays off โ€” black owns it)
F pyflakes Unused imports, undefined names
I isort Import ordering
B flake8-bugbear Real-bug patterns (B008 stays off โ€” FastAPI Depends() needs call defaults)
C4 flake8-comprehensions Unnecessary comprehension/list/dict calls
UP pyupgrade Modern syntax โ€” with UP006/UP007/UP035/UP045 off by choice: List[X], Union[X, Y], Optional[X] and typing imports are kept because they read more explicitly next to Pydantic
PLC0415 import-outside-toplevel Imports belong at module top; the few legitimate function-local imports carry a justified # noqa: PLC0415
S110 try-except-pass No silent except: pass swallows โ€” audit 08/P0-3 fixed the instances, the gate keeps them from coming back
C901 cyclomatic complexity Gated at 10 ([tool.ruff.lint.mccabe] max-complexity = 10) โ€” see the policy below
RUF022 sorted __all__ Barrel modules keep __all__ sorted โ€” with 4 deliberate exceptions, see below

ruff check backend/ is green under these gates; a regression is a lint failure, not a discussion.

๐Ÿ“ The C901 policy: flat packers vs real complexity

McCabe counts every decision point โ€” including ternaries and boolean operators โ€” so a flat "unpack-and-repack" data mapper with 12 ternaries scores 13 while being perfectly linear code. Cognitive complexity would be the honest metric for that shape, but ruff does not implement it. The threshold was therefore set low (10) and the resulting triage distinguishes two kinds of over-threshold function:

  1. Flat data shuffling (packers, CSV parsers, per-rule validators, bulk loops with early returns) โ†’ keeps a per-function justified noqa that says why it is flat:
def validate_chain_steps(  # noqa: C901 โ€” flat numbered rule-chain validation
  1. Genuinely nested logic (orchestrators, state machines, nested closures) โ†’ marked TODO(P2-refactor) in the same comment and tracked in the audit backlog for real decomposition:
async def get_lots_analysis(  # noqa: C901 โ€” TODO(P2-refactor): long orchestrator; extract per-analysis stage helpers

The transaction batch refactor demonstrates the preferred outcome: TransactionService.execute_batch() no longer has a C901 exemption or TODO(P2-refactor). Its former 637-line body is now an approximately 50-line orchestrator over TransactionBatchContext and ordered functions in transaction_batch_stages.py.

As of 2026-09-11, the current tree carries 201 # noqa: C901 sites and 24 TODO(P2-refactor) markers. Relative to the tracked pre-refactor tree, execute_batch() accounts for the one-site reduction in both totals (202 โ†’ 201 and 25 โ†’ 24). Recheck live counts instead of copying these numbers:

grep -rn "noqa: C901" backend/ scripts/ --include="*.py" | wc -l
grep -rn "TODO(P2-refactor)" backend/ scripts/ --include="*.py" | wc -l

Writing a new # noqa: C901

Read the whole function first. If its branches are flat format/error handling, write the noqa with the flat-shape justification. If you found real nesting, mark it TODO(P2-refactor) โ€” or better, extract a helper and drop the noqa entirely.

๐Ÿ›ข๏ธ The 4 RUF022 exceptions

__all__ is kept sorted everywhere except four barrel modules that group their exports by domain with section comments โ€” sorting would scatter related names away from their group:

  • backend/app/db/__init__.py
  • backend/app/db/base.py
  • backend/app/schemas/__init__.py
  • backend/app/schemas/assets.py

Each carries # noqa: RUF022 โ€” grouped by domain with section comments; sorting would scatter related names.

๐Ÿชต Logging inside except (TRY400 convention)

Inside an exception handler, use logger.exception(...) โ€” never logger.error(...): the former attaches the active traceback, the latter silently drops it. Audit 08 converted all 55 logger.error-in-except sites (03/09) and the codebase is TRY400-clean today (ruff check --select TRY400 passes), although the rule is not yet part of the select list โ€” treat it as mandatory style regardless.

๐Ÿงช Beyond the linter: JSON validator vs sanitizer

Two JSON-safety helpers coexist with opposite error policies, and they must not be merged (audit 08, report 03 ยงN-03-A):

  • ensure_json_safe (backend/app/utils/json_utils.py) โ€” the validator: recursively checks a value is natively JSON-serializable and raises ValueError otherwise. Used by Pydantic field_validators on contract boundaries (signals, AI Export payloads), where a non-JSON value means the producer is broken and must be rejected.
  • _json_safe_details (local to backend/app/services/asset_source.py) โ€” the sanitizer: stringifies anything non-primitive and never raises. It exists for one field (AssetSourceError.details in provider diagnostics), preserves None/empty as "omit the field", and sanitizes one list level deep โ€” exactly what provider-error localization needs.

Rejecting and stringifying are opposite answers to the same input. If a boundary needs validation, call the validator; if a diagnostic field needs to survive arbitrary provider data, call the sanitizer. Never let one grow the other's behavior.