Skip to main content

Maximally-strict ruff + pyright + ESLint configs for sarj-ai Python/TypeScript projects.

Project description

sarj-lint-configs

Ships the maximally-strict ruff / pyright / ESLint configs from sarj-ai/standards as a pip-installable package, plus the commands that adopt them and keep them current.

Adopt

uv add --dev sarj-lint-configs
uv run --frozen sarj-lint-configs init

init detects Python and/or TypeScript, writes only the configs that ecosystem uses, wires them up ([tool.ruff] extend, pyrightconfig.json, eslint.config.mjs), writes a pre-commit block, records the adopted version in .sarj-standards.toml, and prints the CI snippet and — for TypeScript — the one npm install command that gets the ESLint peers right.

init --dry-run prints the whole plan without touching anything. init never overwrites a file that already exists unless you pass --force, so it is safe to re-run and safe on a repo that is already half-adopted.

Keep current

uv run --frozen sarj-lint-configs doctor      # every version pin agrees?
uv run --frozen sarj-lint-configs sync --check # the synced configs are unmodified?
uv run --frozen sarj-lint-configs check .      # the custom Python/SQL/IaC rules pass?

Run all three in CI. init prints them as a ready-made job.

doctor — one version, not three

A consumer repo used to state a Sarj version in three independent places: the pyproject.toml pin, the pre-commit rev:, and whatever a CI job typed on its own command line. Nothing compared them, so they drifted apart and stayed drifted — a repo could run one linter version at commit time, a second in CI, and a third locally, and pass its own build.

doctor finds every pin site under a repo root and checks each against the installed wheel:

ok     .sarj-standards.toml  --  version 0.23.0
drift  .github/workflows/ci.yml: sarj-python-lint==0.12.2  --  installed sarj-python-lint is 0.34.0
drift  pyproject.toml: sarj-python-lint==0.25.0  --  installed sarj-python-lint is 0.34.0
ok     pyproject.toml: sarj-lint-configs==0.23.0  --  matches the installed wheel
drift  package.json: @sarj/eslint-plugin@2.16.0  --  the bundled eslint.strict.mjs is tested against 6.1.0

It reports; it never rewrites. Exit 1 on drift.

The sibling linter versions are not yours to pick. sarj-lint-configs pins sarj-python-lint, sarj-sql-lint and sarj-iac-lint exactly, so doctor reads them out of the wheel you already installed and derives what every other site should say — including the pre-commit tag, which lives in a different namespace (python-v0.34.0 for sarj-lint-configs 0.23.0) that nobody should have to translate by hand.

The block init writes has no rev: at all. A repo: local hook runs the CLI from the environment your pyproject.toml pin already fixed, which deletes the second pin site rather than checking it:

repos:
  - repo: local
    hooks:
      - id: sarj-standards-drift
        name: sarj standards -- config + version drift
        entry: uv run --frozen sarj-lint-configs doctor
        language: system
        pass_filenames: false
      - id: sarj-standards-check
        name: sarj standards -- custom rules
        entry: uv run --frozen sarj-lint-configs check
        language: system
        types_or: [python, sql, yaml]

That is the block a Python repo gets. A TypeScript-only repo gets the same hook with uvx --from sarj-lint-configs==<version> instead of uv run --frozenuv run in a repo with no pyproject.toml is error: Failed to spawn: sarj-lint-configs, exit 2, on every commit — and no check hook, because check runs the Python/SQL/IaC registries and a TypeScript repo has nothing to feed them.

sync --check — the synced configs are unmodified

Ruff cannot extend a config out of an installed package's path portably, which is why sync writes a copy into your repo instead of you referencing one. A copy can be edited, so sync --check compares each one byte-for-byte and fails CI when it differs. One consumer's copy of the ESLint config had quietly drifted to 120 rules against a canonical 145 — missing 30, carrying 5 that no longer exist upstream — and nothing caught it.

sync and sync --check operate on the config set in .sarj-standards.toml, so a Python repo is not asked to carry an ESLint config it never wanted. That matters: sync --check used to insist on all six files, report permanent drift on the two a repo had no use for, and so never made it into anyone's CI.

Your own settings are not clobbered, and you do not need to fork anything.

  • Ruff. [tool.ruff.lint] ignore in your pyproject.toml is additive over the extended file's list — verified, not assumed. Adding ignore = ["ANN001"] silences ANN001 and leaves all 43 canonical ignores in force. Put every local ruff decision in pyproject.toml and leave .ruff-strict.toml alone.
  • ESLint. Your eslint.config.mjs spreads the synced array and appends. Flat config is last-wins, so an override block after ...strict relaxes a rule, scopes one to a directory, or adds a framework exemption — and still receives every rule added upstream. init writes that block for you, commented, with a unicorn/filename-case example, because "the canonical config does not know about my framework's filenames" is the most common reason people forked it.

ESLint peers

eslint.strict.mjs imports ten npm packages, and the set does not resolve on its own. The unicorn floor (72) pulls eslint >= 10.4, while eslint-plugin-react@7.37.5 — the newest published release — peers eslint <= ^9.7. npm install exits ERESOLVE, so the config is unreachable until you add an overrides entry:

{ "overrides": { "eslint-plugin-react": { "eslint": "$eslint" } } }

Following the README used to mean hitting Cannot find package nine times and then that dead end, with nothing naming the escape. That is most of why TypeScript repos vendored the file. init writes the block; peers prints it.

uv run --frozen sarj-lint-configs peers   # prints the set and one install command

The set is pinned in eslint.peers.json inside the wheel, packages/typescript installs exactly it, and tests there both load the shipped config through a real ESLint and lint real files with it — so "these versions resolve and this config works" is a CI claim, not a sentence in a README.

Three of the pins are load-bearing floors rather than just "current":

Peer Why the pin is a floor
@sarj/eslint-plugin Every custom rule the config names has to exist. prefer-module-level-schema arrived in 6.1.0, no-type-member-comment-wall in 5.1.0, prefer-zod-infer in 4.1.0 and prefer-zod-enum in 2.17.0; naming a rule the installed plugin lacks is "Definition for rule was not found", once per file.
eslint-plugin-unicorn The config enables 213 unicorn rules; most do not exist below 72.
eslint-plugin-zod The config imports it, so a missing or older install is a hard config error rather than a skipped rule. zod/prefer-nullish and zod/no-any-schema both land in 4.9.0.
eslint-plugin-perfectionist sort-modules does not exist before 4. On 3.x an unknown rule is a hard config error, not a soft degrade.

eslint-plugin-zod returns to the config after being dropped in #155, when the one rule taken from it (zod/prefer-enum-over-literal-union) was replaced by @sarj/prefer-zod-enum and the import went with it. Two of its rules are worth the dependency on their own measured evidence — zod/prefer-nullish (691 hits across 12 of 17 audited repos, autofixable) and zod/no-any-schema (159 hits across 10) — and enabling a maintained upstream rule beats keeping a local copy of it. The rest of the plugin stays off: most of it is Zod v3 → v4 migration advice (no-number-schema-with-int, prefer-top-level-string-formats, the no-schema-with-is-* deprecations) that would misfire on a v3 consumer.

On ESLint 10 the config skips eslint-plugin-react's 18 rules. 7.37.5, the newest release, calls the context.getFilename() removed in ESLint 10, so every react rule throws on the first file linted — and the unicorn floor above means ESLint 10 is not optional. The rules come back automatically on ESLint 9, and a test fails as soon as a fixed eslint-plugin-react ships, so the workaround cannot quietly become permanent.

You do not have to install Python to get the ESLint config. If your repo is TypeScript-only, either run init once from uvx and commit the result, or skip sarj-lint-configs and use the plugin's own preset:

// eslint.config.mjs
import sarj from "@sarj/eslint-plugin";
export default [sarj.configs.strict];

That preset carries the @sarj rules only — not the typescript-eslint, unicorn, react or perfectionist layers the shipped config adds — but it needs one npm package and no Python. It is flat-config shaped as of @sarj/eslint-plugin 6.0.0; before that both presets declared plugins in eslintrc array form and ESLint threw on sight, which is the other reason people copied the file.

Rules removed in a breaking release

An eslint-disable naming a rule that no longer exists reports "Definition for rule was not found" until the comment is dropped. 3.0.0 removed no-unsafe-cast, prefer-shadcn, no-sequential-await, require-schema-validate-search and single-public-export; 5.1.0 removed ban-loose-type-guards-in-tests, no-implicit-attribute-access and prefer-setup-file-mocks.

What each config lands as

Config Written to Referenced from
ruff .ruff-strict.toml pyproject.toml[tool.ruff] extend
pyright .pyright-strict.json pyrightconfig.jsonextends
eslint eslint.strict.mjs eslint.config.mjsimport
markdownlint .markdownlint.yaml picked up by name
taplo .taplo.toml picked up by name
yamllint .yamllint.yaml picked up by name

Polyglot repos can route the two ecosystems to their own roots:

uv run --frozen sarj-lint-configs sync --python-dest python --typescript-dest web --force

check — the custom rules

check discovers every rule from the exact registry versions installed with this package. Files and recursively discovered directory contents are routed to the applicable registry by suffix. It is deliberately zero-tolerance: it does not accept suppression baselines that a change could inflate to conceal new findings.

Do not copy the runner into consumer repositories. Keeping it inside the wheel ensures the CLI implementation and its exact registry dependencies upgrade as one tested unit.

Programmatic access: from sarj_lint_configs import RUFF_STRICT, PYRIGHT_STRICT, ESLINT_STRICT, ESLINT_PEERS (each a pathlib.Path into the wheel).

0.8.0 — PLC2701 moved out of ruff

PLC2701 import-private-name is in the ignore list. It cannot tell a private name of ours from a private name of a dependency's: its exemption is "same top-level package", so from livekit.agents.inference_runner import _InferenceRunner — an API livekit made private in 1.6.6, with no public replacement — is flagged identically to a first-party helper someone forgot to export. Ruff has no configuration surface that separates them.

The check is replaced by SARJ048 in sarj-python-lint, which resolves the imported module against your project tree and fires only on first-party modules. It runs as part of sarj-lint-configs check.

If you are not running sarj-lint-configs check, re-enable PLC2701 in your own [tool.ruff.lint] extend-select — an over-firing check beats no check.

Attribute access (session._stt) is unchanged: ruff's SLF001 and pyright's reportPrivateUsage both still fire, and neither can make the first/third-party distinction. Both configs carry the rationale and the escape hatches inline.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

sarj_lint_configs-0.24.0.tar.gz (49.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

sarj_lint_configs-0.24.0-py3-none-any.whl (57.7 kB view details)

Uploaded Python 3

File details

Details for the file sarj_lint_configs-0.24.0.tar.gz.

File metadata

  • Download URL: sarj_lint_configs-0.24.0.tar.gz
  • Upload date:
  • Size: 49.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.12.0 {"installer":{"name":"uv","version":"0.12.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for sarj_lint_configs-0.24.0.tar.gz
Algorithm Hash digest
SHA256 fb215a9ffacf331beb285aa19409ad5a317f7f2b5317179ab7d07f8e4345db53
MD5 696033ef98fac72d4e532f51d1a2386e
BLAKE2b-256 30f4d10ff12aa3ab9c91dd525134a9a5d8e3889cef60960cf76ffd8067df31d4

See more details on using hashes here.

File details

Details for the file sarj_lint_configs-0.24.0-py3-none-any.whl.

File metadata

  • Download URL: sarj_lint_configs-0.24.0-py3-none-any.whl
  • Upload date:
  • Size: 57.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.12.0 {"installer":{"name":"uv","version":"0.12.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for sarj_lint_configs-0.24.0-py3-none-any.whl
Algorithm Hash digest
SHA256 e2f7d7f8e3d8ae5ba9b218ed126295f1cad9f40156252b5d2cf4db8bd30a7600
MD5 65b9665bf76023c23861c6f4e61e1cb2
BLAKE2b-256 80b6587f6175e66c5637c0931c3dc47e0d68d3d2e6bb560232361efbf24c28d2

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page