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
uvx --from sarj-lint-configs sarj-standards init
init detects Python and/or TypeScript, installs the toolchain and hooks, and 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
ESLint peer set that resolves together. Pass --no-install to write only the wiring.
init --dry-run prints the whole plan without touching anything. Existing Ruff,
Pyright, ESLint flat-config, package-manager, and pre-commit files are merged
only when their structure is unambiguous; user-owned settings are preserved. If
a file cannot be wired safely, init explains the exact manual change and makes
no changes. A failed sync or dependency install restores every tracked file.
Application repositories can opt into the preferred-library policy at adoption time:
uvx sarj-lint-configs init --profile application
The selected standard or application profile is recorded in
.sarj-standards.toml, so later update --configs-only, update --configs-only --check, and check select the
same standalone Ruff and ESLint artifacts automatically. Existing manifests
without a profile field remain on standard. The application profile is an
intentional stack policy: it treats cataloged imports such as argparse and
pandas as errors even when the old library remains maintained.
It also checks direct dependencies in pyproject.toml, PEP 735 groups,
Poetry/PDM/uv tables, authored requirements files, and every npm dependency
field. Run that manifest gate independently—or measure an unadopted repository—
with:
uvx sarj-lint-configs check --dependencies --profile application
Findings carry stable LIB### IDs and migration cautions. Architectural
replacements such as Flask to FastAPI, pandas to Polars, and Axios to Ky are
deliberately errors, but are never broadly autofixed because their APIs and
runtime semantics differ.
Your TypeScript does not have to be at the repo root
init writes each ecosystem's configs into the directory that owns it, not into
the repo root: the ESLint config goes beside the npm lockfile, the ruff and
pyright configs beside the pyproject.toml. That is not cosmetic. A repo whose
TypeScript lives in frontend/ has no node_modules and no tsconfig.json at
its root, and ESLint does not search upward for a flat config — a root-level
eslint.config.mjs is a file that can never load, written by a tool that reports
success.
The project root is found by lockfile, not by package.json: a repo can carry
a root package.json declaring nothing but packageManager while the real
project is a directory down.
The detected destinations are recorded in .sarj-standards.toml, so plain
update --configs-only and update --configs-only --check find them again
without CI having to restate them. Override
detection with init --python-dest / --typescript-dest.
npm, pnpm, Yarn and Bun
The peer set below does not install without an override block, and every client
spells that block differently: npm and Bun use package.json#overrides, pnpm 11
wants overrides in pnpm-workspace.yaml (older standalone pnpm projects use
package.json#pnpm.overrides) with a parent>child selector, Yarn wants resolutions with a
parent/child path and no $dep indirection. init detects the client from the
lockfile (or a packageManager field, which Corepack enforces), writes the right
one, and prints the matching install command. Writing npm's spelling everywhere
was worse than writing nothing: pnpm and Yarn ignore a stray overrides key, so
the install failed identically while package.json looked fixed.
Keep current
uv run --frozen sarj-standards doctor # read-only diagnosis with exact fixes
uv run --frozen sarj-standards update --check # preview whether an upgrade is needed
uv run --frozen sarj-standards update # resolve latest, migrate, install, postflight
uv run --frozen sarj-standards check # run every adoption and lint gate
uv run --frozen sarj-standards fix # apply formatter and safe lint fixes
uv run --frozen sarj-standards show state # print detected adoption state
uv run --frozen sarj-standards check --noise-only . # comment/artifact ratchet only
Run check in CI. init prints it as a ready-made job. With no paths, check
runs the complete repository verification. With paths, it runs the applicable
custom rules for those paths, which keeps pre-commit fast.
check --noise-only covers Python and text/config inputs; TypeScript comment
noise is enforced by the generated strict ESLint config.
check checks the whole repository by default. A tool repository whose rule
fixtures intentionally contain rejected code can narrow only the custom-rule
pass, without weakening Ruff, BasedPyright, or ESLint:
[verify]
paths = ["src", "tests", "README.md"]
Paths are repository-relative and cannot escape the repository root.
Maintainers can declare repository policy in .sarj-standards.toml and replace
ad hoc scripts with sarj-standards maintain check. The same command checks private
references, CI history, filenames, rule/test/registry pairing, canonical config
references, and version coverage; maintain sync-ledger, maintain comment-corpus, maintain hooks install, and show rules provide the related maintenance
operations.
The package also owns repository setup and release policy; workflows and the Makefile are intentionally thin adapters rather than a second implementation:
sarj-standards maintain setup --check
sarj-standards maintain release check-tag typescript-v9.13.0
sarj-standards maintain release lock-age packages/typescript/package-lock.json --exclude-file .github/release-age-exclusions.txt
sarj-standards maintain release typescript check
sarj-standards maintain release publish typescript
The former sync, list, path, peers, upgrade, verify, format,
inspect, library-policy, and repo commands remain exact compatibility
aliases. Existing CI and hooks keep working while new usage stays within the
seven consumer verbs shown by sarj-standards --help.
Programmatic consumers should import sarj_lint_configs.api. Business logic is
grouped under sarj_lint_configs.libs by adoption, linting, repository, setup,
and release domains; __main__ is only an entrypoint and cli/ owns argument
parsing and presentation. The former top-level modules remain identity-preserving
compatibility aliases for existing imports and monkeypatches.
The Python facade mirrors the consumer vocabulary without parsing CLI output:
from pathlib import Path
from sarj_lint_configs.api import Standards
standards = Standards(Path.cwd())
health = standards.doctor()
preview = standards.init(dry_run=True)
result = standards.init(install=False)
status = standards.check(("src", "tests"))
The preferred API is deliberately small: Standards, Result, Finding,
Change, Inspection, Status, and __version__. Each method calls the same
typed services as the CLI. Existing sarj_lint_configs.api exports remain as
compatibility aliases. Maintainer plan/apply, rule evaluation, corpus, and
release APIs live in the public sarj_lint_configs.libs namespace.
Release-age exception files contain one exact package@version per line, with
optional # comments. Name-wide exceptions are rejected in files, so a future
lockfile upgrade cannot silently inherit a temporary whitelist.
sarj-standards is the preferred entrypoint. sarj-lint-configs remains an
alias for compatibility. check routes Python, SQL, Terraform, YAML/TFTPL,
TOML, JSONC, Markdown, INI-style config, environment files, shell scripts,
Dockerfile variants, Makefiles, and Justfiles in one pass. It rejects
commented-out config (SARJ301), dense config narration (SARJ300), and AI
execution diaries, bug-hunt dumps, or large point-in-time audits that should
have been reduced to durable README/docs/ADR facts (SARJ302). Large documents
need at least two independent artifact signals, so size alone never reports.
SARJ302 is visible but non-blocking for its first minor release; promotion is
an explicit per-rule metadata change after corpus calibration, never a global
warning-to-error rewrite. The new constructor and shadcn checks are likewise
warnings, and shadcn guidance exists only in the application profile, outside
tests, fixtures, and design-system implementation directories.
Generated or intentionally instructional config can be excluded explicitly; there is no blanket directory exemption:
[text]
exclude = ["examples/generated-values/**"]
In source code, use names, types, small functions, and data structures to carry the explanation. One sentence of nearby rationale is clean; exactly two sentences is a non-blocking warning; three or more is an error and should be reduced to the local constraint or moved to an ADR/doc. Fully typed functions must not repeat parameters or returns in docstring/JSDoc tables, while external contracts, invariants, failures, examples, generated docs, directives, licenses, and runtime-consumed prompt/tool/route documentation remain valid.
doctor — one version, not three
Run it without preparation:
sarj-standards doctor
sarj-standards doctor --format json # stable schema and finding IDs for automation
It is read-only, exits 0 for a healthy or not-yet-adopted repository, 1 for
actionable drift, and 2 for invalid input such as malformed TOML/JSON. Every
drift finding includes a concrete remediation. Intentional compatibility
fixtures can be excluded narrowly with [doctor] exclude = ["tests/fixtures/**"].
Upgrading is one command from an installed environment:
sarj-standards update
The command bootstraps the newest published compatibility bundle with uvx --refresh, previews each changed path, blocks on retired rule references,
updates the single manifest version, syncs configs, safely repairs wiring and
peer pins, installs dependencies, and rolls everything back if installation or
the doctor postflight fails. Use update --check in automation. The legacy
upgrade --offline --no-install spelling remains available for maintainers
testing the already-installed bundle.
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.32.0
drift .github/workflows/ci.yml: sarj-python-lint==0.12.2 -- installed sarj-python-lint is 0.37.0
drift pyproject.toml: sarj-python-lint==0.25.0 -- installed sarj-python-lint is 0.37.0
ok pyproject.toml: sarj-lint-configs==0.32.0 -- matches the installed wheel
drift package.json: @sarj/eslint-plugin@2.16.0 -- the bundled eslint.strict.mjs is tested against 9.0.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.37.0 for sarj-lint-configs 0.32.0) that nobody should
have to translate by hand.
The block init writes has no rev: at all. One repo: local hook runs the CLI
from the environment your pyproject.toml pin already fixed, which deletes the
second pin site and starts the toolchain only once per commit:
repos:
- repo: local
hooks:
- id: sarj-standards-check
name: sarj standards -- staged checks
entry: uv run --frozen sarj-standards check --staged
language: system
verbose: true
files: '(?i)(\.py|\.tsx?|\.jsx?|\.sql|\.tf|\.tfvars|\.hcl|\.ya?ml|\.toml|\.jsonc|\.mdx?|\.(?:bash|cfg|conf|env|ini|properties|sh|tftpl|zsh)|(?:^|/)\.env(?:\..*)?$|(?:^|/)(?:Dockerfile(?:\..*)?|Gnumakefile|Justfile|Makefile))$'
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 --frozen;
check --staged routes only staged paths to their applicable linters in either
ecosystem.
Removed and renamed rules — the upgrade that crashes
Deleting a rule is not a lint-level change for the repo that uses it. A flat config still naming it makes ESLint exit 2 before it reads a single file:
TypeError: Key "rules": Key "@sarj/prefer-setup-file-mocks": Could not find
"prefer-setup-file-mocks" in plugin "@sarj".
The whole repo stops linting. A pre-commit hook id that no longer exists fails
the same way, and because the strict config sets
reportUnusedDisableDirectives: "error", every orphaned
// eslint-disable-next-line @sarj/<removed-rule> is an error of its own.
rule-ledger.json ships inside the wheel and records every rule identifier this
toolchain has ever shipped, along with what became of it. doctor reads it and
names every stale reference — in configs, in suppression baselines, in
pre-commit, and in ordinary source — before the upgrade that would break
them:
drift eslint.config.mjs: @sarj/prefer-setup-file-mocks x1 -- no longer exists -- Delete the config entry and suppressions; there is no replacement.
drift src/legacy.ts: @sarj/no-implicit-attribute-access x1 -- no longer exists -- ...
drift .pre-commit-config.yaml: no-implicit-attribute-access x1 -- no longer exists -- removed in sarj-python-lint 0.37.0 ...
drift .sarj-python-baseline.json: SARJ083 x1 -- no longer exists -- removed in sarj-python-lint 0.37.0 ...
drift eslint.config.mjs: @sarj/strict-test-assertions x1 -- renamed to @sarj/prefer-whole-object-assertion -- the old name no longer resolves: @sarj/eslint-plugin 9.0.0 deleted the deprecated aliases 7.0.0 shipped ...
There is nothing to fall back on in any of these cases. A deleted rule, a
SARJnnn code that was renumbered and a rule renamed without an alias all fail
identically at load time, and the failure names the identifier but never says it
went deliberately or what replaced it. The ledger is where that is written down.
The ledger is generated by make sync-rule-ledger, which never deletes: a rule
that leaves a registry is retired, and tests in both this package and the
plugin fail until the ledger matches the live registries again. So the next
removal is recorded whether or not its author thought about consumers.
update --configs-only --check — synced configs are unmodified
Ruff cannot extend a config out of an installed package's path portably, which
is why config refresh writes a copy into your repo instead of you referencing one. A copy
can be edited, so update --configs-only --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.
update --configs-only and its --check mode 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] ignorein yourpyproject.tomlis additive over the extended file's list — verified, not assumed. Addingignore = ["ANN001"]silences ANN001 and leaves all 43 canonical ignores in force. Put every local ruff decision inpyproject.tomland leave.ruff-strict.tomlalone. - ESLint. Your
eslint.config.mjsspreads the synced array and appends. Flat config is last-wins, so an override block after...strictrelaxes a rule, scopes one to a directory, or adds a framework exemption — and still receives every rule added upstream.initwrites that block for you, commented, with aunicorn/filename-caseexample, 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 eleven 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. no-declaration-comment-wall arrived in 7.1.0, four rules were renamed in 7.0.0 and their aliases deleted in 9.0.0, 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-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.
All 18 eslint-plugin-react rules remain active on ESLint 10. Version 7.37.5
calls rule-context APIs removed by ESLint 10, so the config wraps it with
ESLint's official @eslint/compat adapter. Runtime tests lint real TSX and fail
if the adapter or any configured React rule becomes inert.
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.json → extends |
| eslint | eslint.strict.mjs |
eslint.config.mjs → import |
| 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-standards update --configs-only --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.
Legacy config-path exports remain importable for compatibility. New automation
should use sarj-standards show config NAME or the typed adoption libraries.
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.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distributions
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 sarj_lint_configs-0.49.0-py3-none-any.whl.
File metadata
- Download URL: sarj_lint_configs-0.49.0-py3-none-any.whl
- Upload date:
- Size: 196.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5434e6092ff9a20baa29f7f5dc511ec4754e4b4daef290ffc69aac18dc927450
|
|
| MD5 |
5c1fbd56578ba78cb81009749fb2c7ee
|
|
| BLAKE2b-256 |
3724c4f22435f68da165f723ccc133164de30890a0056afb63bfdf5d7208e1f9
|
Provenance
The following attestation bundles were made for sarj_lint_configs-0.49.0-py3-none-any.whl:
Publisher:
release.yml on sarj-ai/standards
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
sarj_lint_configs-0.49.0-py3-none-any.whl -
Subject digest:
5434e6092ff9a20baa29f7f5dc511ec4754e4b4daef290ffc69aac18dc927450 - Sigstore transparency entry: 2349225287
- Sigstore integration time:
-
Permalink:
sarj-ai/standards@a1fdfa4d11ca7591c68be6c005d8800bf7040312 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/sarj-ai
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@a1fdfa4d11ca7591c68be6c005d8800bf7040312 -
Trigger Event:
push
-
Statement type: