python-linters 0.1.7__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. python_linters-0.2.1/PKG-INFO +44 -0
  2. python_linters-0.2.1/README.md +32 -0
  3. python_linters-0.2.1/pyproject.toml +32 -0
  4. python_linters-0.2.1/pyproject.toml.orig +32 -0
  5. python_linters-0.2.1/src/python_linters/__main__.py +11 -0
  6. python_linters-0.2.1/src/python_linters/cli_commands.py +103 -0
  7. python_linters-0.2.1/src/python_linters/config_check.py +440 -0
  8. python_linters-0.2.1/src/python_linters/lint_report.py +298 -0
  9. python_linters-0.2.1/src/python_linters/reference_files.py +25 -0
  10. python_linters-0.2.1/src/python_linters/references/ruff.reference.toml +515 -0
  11. python_linters-0.2.1/src/python_linters/references/ty.reference.toml +203 -0
  12. python_linters-0.2.1/src/python_linters/tool_errors.py +44 -0
  13. python_linters-0.1.7/.gitignore +0 -161
  14. python_linters-0.1.7/LICENSE +0 -21
  15. python_linters-0.1.7/PKG-INFO +0 -214
  16. python_linters-0.1.7/README.md +0 -199
  17. python_linters-0.1.7/pyproject.toml +0 -40
  18. python_linters-0.1.7/python_linters/add_noqa.py +0 -32
  19. python_linters-0.1.7/python_linters/config_files.py +0 -9
  20. python_linters-0.1.7/python_linters/extending_ruff_toml.py +0 -42
  21. python_linters-0.1.7/python_linters/fix_code.py +0 -25
  22. python_linters-0.1.7/python_linters/getting_to_be_linted_folders.py +0 -42
  23. python_linters-0.1.7/python_linters/parse_rules.py +0 -16
  24. python_linters-0.1.7/python_linters/pyrightconfig.json +0 -25
  25. python_linters-0.1.7/python_linters/ruff.toml +0 -159
  26. python_linters-0.1.7/python_linters/ruff_rule_categories.py +0 -80
  27. python_linters-0.1.7/python_linters/ruff_rules.txt +0 -1182
  28. python_linters-0.1.7/python_linters/run_linters.py +0 -49
  29. python_linters-0.1.7/python_linters/utils.py +0 -72
  30. {python_linters-0.1.7 → python_linters-0.2.1/src}/python_linters/__init__.py +0 -0
@@ -0,0 +1,44 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-linters
3
+ Version: 0.2.1
4
+ Summary: Run ruff, ty and pymodularity on a Python project and print one JSON report grouped by rule.
5
+ Author: Tilo Himmelsbach
6
+ License-Expression: MIT
7
+ Requires-Dist: ruff==0.17.3
8
+ Requires-Dist: ty==0.0.90
9
+ Requires-Dist: pymodularity==0.5.0
10
+ Requires-Python: >=3.11
11
+ Description-Content-Type: text/markdown
12
+
13
+ # python-linters
14
+
15
+ Run ruff, ty and pymodularity on a Python project and print one JSON report
16
+ grouped by rule, for agents and CI to read.
17
+
18
+ ## Install
19
+
20
+ As a dev dependency of the project you want to lint:
21
+
22
+ ```
23
+ uv add --dev python-linters
24
+ ```
25
+
26
+ ## Commands
27
+
28
+ - `uv run python-linters lint [path] [--out FILE]` — runs ruff, ty and
29
+ pymodularity (default path: `.`) and prints the merged JSON report to
30
+ stdout, or writes it to `FILE` with `--out`.
31
+ - `uv run python-linters reference ruff|ty` — prints this package's shipped
32
+ reference config for the given tool to stdout. The package never writes
33
+ it into your project — copying it into `ruff.toml`/`ty.toml` (or
34
+ `pyproject.toml`) stays with you.
35
+ - `uv run python-linters check-config ruff|ty <path/to/config>` — compares
36
+ your copy against the pinned tool's current rule set: rules the tool
37
+ knows that your config omits (untriaged), and rules your config names
38
+ that the tool no longer knows (stale).
39
+
40
+ ## Exit codes
41
+
42
+ - `lint`: 0 clean, 1 findings, 2 any tool failure (named on stderr).
43
+ - `check-config`: 0 up to date, 1 untriaged or stale rules found, 2 tool
44
+ failure.
@@ -0,0 +1,32 @@
1
+ # python-linters
2
+
3
+ Run ruff, ty and pymodularity on a Python project and print one JSON report
4
+ grouped by rule, for agents and CI to read.
5
+
6
+ ## Install
7
+
8
+ As a dev dependency of the project you want to lint:
9
+
10
+ ```
11
+ uv add --dev python-linters
12
+ ```
13
+
14
+ ## Commands
15
+
16
+ - `uv run python-linters lint [path] [--out FILE]` — runs ruff, ty and
17
+ pymodularity (default path: `.`) and prints the merged JSON report to
18
+ stdout, or writes it to `FILE` with `--out`.
19
+ - `uv run python-linters reference ruff|ty` — prints this package's shipped
20
+ reference config for the given tool to stdout. The package never writes
21
+ it into your project — copying it into `ruff.toml`/`ty.toml` (or
22
+ `pyproject.toml`) stays with you.
23
+ - `uv run python-linters check-config ruff|ty <path/to/config>` — compares
24
+ your copy against the pinned tool's current rule set: rules the tool
25
+ knows that your config omits (untriaged), and rules your config names
26
+ that the tool no longer knows (stale).
27
+
28
+ ## Exit codes
29
+
30
+ - `lint`: 0 clean, 1 findings, 2 any tool failure (named on stderr).
31
+ - `check-config`: 0 up to date, 1 untriaged or stale rules found, 2 tool
32
+ failure.
@@ -0,0 +1,32 @@
1
+ [project]
2
+ name = "python-linters"
3
+ version = "0.2.1"
4
+ description = "Run ruff, ty and pymodularity on a Python project and print one JSON report grouped by rule."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.11"
8
+ dependencies = [
9
+ "ruff==0.17.3",
10
+ "ty==0.0.90",
11
+ "pymodularity==0.5.0",
12
+ ]
13
+
14
+ [[project.authors]]
15
+ name = "Tilo Himmelsbach"
16
+
17
+ [project.scripts]
18
+ python-linters = "python_linters.__main__:main"
19
+
20
+ [tool.deptry.per_rule_ignores]
21
+ DEP002 = [
22
+ "ruff",
23
+ "ty",
24
+ "pymodularity",
25
+ ]
26
+
27
+ [dependency-groups]
28
+ dev = ["pytest"]
29
+
30
+ [build-system]
31
+ requires = ["uv_build>=0.10.7,<0.11.0"]
32
+ build-backend = "uv_build"
@@ -0,0 +1,32 @@
1
+ [project]
2
+ name = "python-linters"
3
+ version = "0.2.1"
4
+ description = "Run ruff, ty and pymodularity on a Python project and print one JSON report grouped by rule."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.11"
8
+ authors = [{ name = "Tilo Himmelsbach" }]
9
+ dependencies = [
10
+ "ruff==0.17.3",
11
+ "ty==0.0.90",
12
+ "pymodularity==0.5.0",
13
+ ]
14
+
15
+ [project.scripts]
16
+ python-linters = "python_linters.__main__:main"
17
+
18
+ [tool.deptry.per_rule_ignores]
19
+ # DEP002 (unused-dependency) false positive: deptry only sees `import` statements,
20
+ # but this package runs ruff, ty and pymodularity as `sys.executable -m <tool>`
21
+ # subprocesses, never `import`s them. All three are genuinely required at
22
+ # runtime for `python-linters lint` to work.
23
+ DEP002 = ["ruff", "ty", "pymodularity"]
24
+
25
+ [dependency-groups]
26
+ dev = [
27
+ "pytest",
28
+ ]
29
+
30
+ [build-system]
31
+ requires = ["uv_build>=0.10.7,<0.11.0"]
32
+ build-backend = "uv_build"
@@ -0,0 +1,11 @@
1
+ import sys
2
+
3
+ from python_linters.cli_commands import main as _cli_main
4
+
5
+
6
+ def main() -> None:
7
+ sys.exit(_cli_main())
8
+
9
+
10
+ if __name__ == "__main__":
11
+ main()
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from python_linters.config_check import (
9
+ check_ruff_config,
10
+ check_ty_config,
11
+ format_ruff_report,
12
+ format_ty_report,
13
+ )
14
+ from python_linters.lint_report import build_report
15
+ from python_linters.reference_files import reference_text
16
+ from python_linters.tool_errors import ToolFailedError
17
+
18
+
19
+ def _lint(args: argparse.Namespace) -> int:
20
+ project_path = Path(args.path).resolve()
21
+ if not project_path.exists():
22
+ print(f"Error: {project_path} does not exist", file=sys.stderr)
23
+ return 2
24
+
25
+ try:
26
+ output, total_findings = build_report(project_path)
27
+ except ToolFailedError as e:
28
+ print(str(e), file=sys.stderr)
29
+ return 2
30
+
31
+ serialized = json.dumps(output, indent=2)
32
+
33
+ if args.out:
34
+ Path(args.out).write_text(serialized, encoding="utf-8")
35
+ print(f"Written to {args.out}", file=sys.stderr)
36
+ else:
37
+ print(serialized)
38
+
39
+ return 1 if total_findings else 0
40
+
41
+
42
+ def _reference(args: argparse.Namespace) -> int:
43
+ print(reference_text(args.tool), end="")
44
+ return 0
45
+
46
+
47
+ def _run_config_check(tool: str, config_path: Path) -> bool:
48
+ """Run and print the config check for one tool. Returns whether it's clean."""
49
+ if tool == "ruff":
50
+ check = check_ruff_config(config_path)
51
+ print(format_ruff_report(config_path, check))
52
+ else:
53
+ check = check_ty_config(config_path)
54
+ print(format_ty_report(config_path, check))
55
+ return check.is_clean
56
+
57
+
58
+ def _check_config(args: argparse.Namespace) -> int:
59
+ config_path = Path(args.config)
60
+ if not config_path.exists():
61
+ print(f"Error: {config_path} not found", file=sys.stderr)
62
+ return 2
63
+
64
+ try:
65
+ is_clean = _run_config_check(args.tool, config_path)
66
+ except ToolFailedError as e:
67
+ print(str(e), file=sys.stderr)
68
+ return 2
69
+
70
+ return 0 if is_clean else 1
71
+
72
+
73
+ def build_parser() -> argparse.ArgumentParser:
74
+ parser = argparse.ArgumentParser(prog="python-linters")
75
+ subparsers = parser.add_subparsers(dest="command", required=True)
76
+
77
+ lint_parser = subparsers.add_parser(
78
+ "lint", help="run ruff, ty and pymodularity and print one JSON report"
79
+ )
80
+ lint_parser.add_argument("path", nargs="?", default=".")
81
+ lint_parser.add_argument("--out", default=None)
82
+ lint_parser.set_defaults(func=_lint)
83
+
84
+ reference_parser = subparsers.add_parser(
85
+ "reference", help="print a shipped reference config to stdout"
86
+ )
87
+ reference_parser.add_argument("tool", choices=["ruff", "ty"])
88
+ reference_parser.set_defaults(func=_reference)
89
+
90
+ check_config_parser = subparsers.add_parser(
91
+ "check-config", help="compare a config against the pinned tool's current rules"
92
+ )
93
+ check_config_parser.add_argument("tool", choices=["ruff", "ty"])
94
+ check_config_parser.add_argument("config")
95
+ check_config_parser.set_defaults(func=_check_config)
96
+
97
+ return parser
98
+
99
+
100
+ def main(argv: list[str] | None = None) -> int:
101
+ parser = build_parser()
102
+ args = parser.parse_args(argv)
103
+ return args.func(args)
@@ -0,0 +1,440 @@
1
+ """Compare a ruff or ty config against the pinned tool's current rule set.
2
+
3
+ Ported from check_ruff_rules.py and check_ty_rules.py (quality-and-security-tools
4
+ howtos). Both tools run as `sys.executable -m <tool>` — the python-linters
5
+ environment's own pinned version, same resolution as lint_report.py — so
6
+ check_ty_rules.py's `--ty-version` flag (for checking a reference file
7
+ against a version the howto repo doesn't pin) is dropped: python-linters
8
+ always pins its own ty.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import re
15
+ import string
16
+ import sys
17
+ import tomllib
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from python_linters.tool_errors import run_checked
23
+
24
+ # ── ruff ──────────────────────────────────────────────────────────────────
25
+
26
+
27
+ @dataclass
28
+ class RuffConfigCheck:
29
+ version: str
30
+ category_count: int
31
+ selected_count: int
32
+ commented_count: int
33
+ new_categories: list[tuple[str, str]]
34
+ removed: list[str]
35
+ untriaged_codeless: list[str]
36
+ untriaged_preview: list[tuple[str, str]]
37
+
38
+ @property
39
+ def is_clean(self) -> bool:
40
+ return not (
41
+ self.new_categories
42
+ or self.removed
43
+ or self.untriaged_codeless
44
+ or self.untriaged_preview
45
+ )
46
+
47
+
48
+ def _get_ruff_rules() -> list[dict[str, Any]]:
49
+ result = run_checked(
50
+ [sys.executable, "-m", "ruff", "rule", "--all", "--output-format", "json"],
51
+ Path.cwd(),
52
+ "ruff",
53
+ )
54
+ return json.loads(result.stdout)
55
+
56
+
57
+ def _get_ruff_version() -> str:
58
+ result = run_checked([sys.executable, "-m", "ruff", "version"], Path.cwd(), "ruff")
59
+ return result.stdout.strip()
60
+
61
+
62
+ def _get_ruff_categories(rules: list[dict[str, Any]]) -> dict[str, str]:
63
+ """All rule categories (prefix -> one representative linter name, for display)."""
64
+ categories: dict[str, str] = {}
65
+ for r in rules:
66
+ code = r["code"]
67
+ if code is None:
68
+ continue
69
+ prefix = code.rstrip(string.digits)
70
+ if prefix not in categories:
71
+ categories[prefix] = r["linter"]
72
+ return categories
73
+
74
+
75
+ def _get_category_linters(rules: list[dict[str, Any]]) -> dict[str, set[str]]:
76
+ """Every rule category (prefix) -> the full set of linters under it.
77
+
78
+ A single rstrip(digits) prefix can hold rules from more than one ruff
79
+ linter, e.g. "C" holds both flake8-comprehensions and mccabe, "T" holds
80
+ both flake8-debugger and flake8-print. Those prefixes behave like "PL"
81
+ (which spans PLC/PLE/PLR/PLW): selecting them selects several linters
82
+ at once. A prefix with exactly one linter does not: selecting "N"
83
+ (pep8-naming) must not be read as covering "NPY" (NumPy-specific rules)
84
+ just because "NPY" starts with "N".
85
+ """
86
+ category_linters: dict[str, set[str]] = {}
87
+ for r in rules:
88
+ code = r["code"]
89
+ if code is None:
90
+ continue
91
+ prefix = code.rstrip(string.digits)
92
+ category_linters.setdefault(prefix, set()).add(r["linter"])
93
+ return category_linters
94
+
95
+
96
+ def _get_codeless_rules(rules: list[dict[str, Any]]) -> list[str]:
97
+ """Names of rules without a code (`"code": null`, e.g. pytest-fixture-autouse).
98
+
99
+ No prefix selects them, so only a name in select (or `ALL`) enables them.
100
+ """
101
+ return sorted(r["name"] for r in rules if r["code"] is None)
102
+
103
+
104
+ def _parse_toml_lint(toml_path: Path) -> dict[str, Any]:
105
+ """Read the lint table from ruff.toml or pyproject.toml.
106
+
107
+ tomllib, not a regex: a `]` inside a comment (e.g. `[lint.flake8-tidy-imports]`)
108
+ ended a regex-matched array early.
109
+ """
110
+ data = tomllib.loads(toml_path.read_text(encoding="utf-8"))
111
+ ruff = data.get("tool", {}).get("ruff", data)
112
+ lint = dict(ruff.get("lint", {}))
113
+ lint.setdefault("select", ruff.get("select", []))
114
+ lint.setdefault("ignore", ruff.get("ignore", []))
115
+ return lint
116
+
117
+
118
+ def _parse_toml_comments(toml_path: Path) -> set[str]:
119
+ """Find excluded categories, rule prefixes and code-less rule names.
120
+
121
+ Expects lines like: # excluded "ERA", # eradicate
122
+ A line may list several entries before its reason: # excluded "E11", "E2", # why
123
+ """
124
+ content = toml_path.read_text(encoding="utf-8")
125
+ entries: set[str] = set()
126
+ for listed in re.findall(r"^#\s*excluded\s+(.*)$", content, re.MULTILINE):
127
+ entries.update(re.findall(r'"([^"]+)"', listed.split("#", 1)[0]))
128
+ return entries
129
+
130
+
131
+ def _covers(
132
+ selector: str,
133
+ category: str,
134
+ category_linters: dict[str, set[str]],
135
+ meta_prefixes: set[str],
136
+ ) -> bool:
137
+ """True iff a selected/excluded `selector` covers rule `category`.
138
+
139
+ Ruff's own prefix resolution: a selector covers only categories of its
140
+ own linter(s). Equal is always covered. A strict extension is covered
141
+ only if `selector` is a meta prefix (spans several linters, e.g. "PL",
142
+ "C", "T") or `selector` and `category` share the same linter set.
143
+ """
144
+ if category == selector:
145
+ return True
146
+ if not category.startswith(selector):
147
+ return False
148
+ if selector in meta_prefixes:
149
+ return True
150
+ return category_linters.get(selector) == category_linters.get(category)
151
+
152
+
153
+ def _covers_rule(
154
+ selector: str,
155
+ code: str,
156
+ linter: str,
157
+ rules: list[dict[str, Any]],
158
+ meta_prefixes: set[str],
159
+ ) -> bool:
160
+ """Same rule as `_covers`, for one concrete rule code rather than a category.
161
+
162
+ `code` (e.g. "C416") and partial selectors like "C90" are not category
163
+ keys (only the blended rstrip prefix "C" is), so `selector`'s own
164
+ linter set is computed straight from the rules it actually matches.
165
+ """
166
+ if code == selector:
167
+ return True
168
+ if not code.startswith(selector):
169
+ return False
170
+ if selector in meta_prefixes:
171
+ return True
172
+ selector_linters = {
173
+ r["linter"] for r in rules if r["code"] and r["code"].startswith(selector)
174
+ }
175
+ return selector_linters == {linter}
176
+
177
+
178
+ def _get_untriaged_preview(
179
+ rules: list[dict[str, Any]],
180
+ selected: set[str],
181
+ ignored: set[str],
182
+ excluded: set[str],
183
+ ) -> list[tuple[str, str]]:
184
+ """Preview rules a selected category contains but nobody triaged.
185
+
186
+ Triaged means: named (or coded) in select or ignore, or covered by an
187
+ `# excluded` prefix.
188
+ """
189
+ rule_names = {r["name"] for r in rules}
190
+ category_selects = selected - rule_names
191
+ named = selected | ignored
192
+ untriaged = []
193
+ for r in rules:
194
+ code, name = r["code"], r["name"]
195
+ if not r.get("preview") or code is None or name is None:
196
+ continue
197
+ if not any(code.startswith(p) for p in category_selects):
198
+ continue
199
+ if name in named or code in named:
200
+ continue
201
+ if any(code.startswith(p) for p in excluded):
202
+ continue
203
+ untriaged.append((code, name))
204
+ return sorted(untriaged)
205
+
206
+
207
+ def _get_new_categories(
208
+ rules: list[dict[str, Any]],
209
+ categories: dict[str, str],
210
+ category_linters: dict[str, set[str]],
211
+ known: set[str],
212
+ meta_prefixes: set[str],
213
+ ) -> list[tuple[str, str]]:
214
+ """Categories no selector in `known` triages.
215
+
216
+ A multi-linter prefix (e.g. "C" = flake8-comprehensions + mccabe) can be
217
+ fully triaged by several single-linter selectors together (e.g. "C4" +
218
+ "C90"), without any selector covering the blended prefix itself. So a
219
+ category only stays "new" if some of its own rules are left uncovered
220
+ by every selector in known, checked rule by rule.
221
+ """
222
+ new_categories = []
223
+ for prefix, linter in sorted(categories.items()):
224
+ if any(_covers(k, prefix, category_linters, meta_prefixes) for k in known):
225
+ continue
226
+ prefix_rules = [
227
+ r for r in rules if r["code"] and r["code"].rstrip(string.digits) == prefix
228
+ ]
229
+ if all(
230
+ any(
231
+ _covers_rule(k, r["code"], r["linter"], rules, meta_prefixes)
232
+ for k in known
233
+ )
234
+ for r in prefix_rules
235
+ ):
236
+ continue
237
+ new_categories.append((prefix, linter))
238
+ return new_categories
239
+
240
+
241
+ def _get_removed(rules: list[dict[str, Any]], selected: set[str]) -> list[str]:
242
+ """Select entries that are neither a rule name nor a prefix of any real rule code.
243
+
244
+ A misspelt name lands here: ruff itself only warns.
245
+ """
246
+ rule_names = {r["name"] for r in rules}
247
+ rule_codes = [r["code"] for r in rules if r["code"] is not None]
248
+ return [
249
+ code
250
+ for code in sorted(selected)
251
+ if code not in rule_names and not any(c.startswith(code) for c in rule_codes)
252
+ ]
253
+
254
+
255
+ def check_ruff_config(toml_path: Path) -> RuffConfigCheck:
256
+ version = _get_ruff_version()
257
+ rules = _get_ruff_rules()
258
+ categories = _get_ruff_categories(rules)
259
+ category_linters = _get_category_linters(rules)
260
+ lint = _parse_toml_lint(toml_path)
261
+ selected = set(lint["select"])
262
+ commented = _parse_toml_comments(toml_path)
263
+
264
+ known = selected | commented
265
+ meta_prefixes = {"PL"} | {
266
+ p for p, linters in category_linters.items() if len(linters) > 1
267
+ }
268
+
269
+ new_categories = _get_new_categories(
270
+ rules, categories, category_linters, known, meta_prefixes
271
+ )
272
+ removed = _get_removed(rules, selected)
273
+
274
+ untriaged_codeless = [n for n in _get_codeless_rules(rules) if n not in known]
275
+
276
+ untriaged_preview = []
277
+ if lint.get("explicit-preview-rules"):
278
+ untriaged_preview = _get_untriaged_preview(
279
+ rules, selected, set(lint["ignore"]), commented
280
+ )
281
+
282
+ return RuffConfigCheck(
283
+ version=version,
284
+ category_count=len(categories),
285
+ selected_count=len(selected),
286
+ commented_count=len(commented),
287
+ new_categories=new_categories,
288
+ removed=removed,
289
+ untriaged_codeless=untriaged_codeless,
290
+ untriaged_preview=untriaged_preview,
291
+ )
292
+
293
+
294
+ def format_ruff_report(toml_path: Path, check: RuffConfigCheck) -> str:
295
+ lines = [
296
+ f"Ruff version: {check.version}",
297
+ f"Config: {toml_path}",
298
+ f"Categories in ruff: {check.category_count}",
299
+ f"Selected in config: {check.selected_count}",
300
+ f"Commented/excluded: {check.commented_count}",
301
+ "",
302
+ ]
303
+
304
+ if check.new_categories:
305
+ lines.append(f"NEW categories not yet triaged ({len(check.new_categories)}):")
306
+ lines.append(" Add to select or comment out with '# excluded' in ruff.toml:\n")
307
+ lines.extend(
308
+ f' "{prefix}",\t# {linter}' for prefix, linter in check.new_categories
309
+ )
310
+ lines.append("")
311
+
312
+ if check.removed:
313
+ lines.append(
314
+ f"REMOVED/RENAMED categories still in select ({len(check.removed)}):"
315
+ )
316
+ lines.extend(f" {code}" for code in check.removed)
317
+ lines.append("")
318
+
319
+ if check.untriaged_codeless:
320
+ lines.append(
321
+ f"Code-less rules not yet triaged ({len(check.untriaged_codeless)}):"
322
+ )
323
+ lines.append(
324
+ " Add to select by name or comment out with '# excluded' in ruff.toml:\n"
325
+ )
326
+ lines.extend(f' "{name}",' for name in check.untriaged_codeless)
327
+ lines.append("")
328
+
329
+ if check.untriaged_preview:
330
+ lines.append(f"Preview rules not yet triaged ({len(check.untriaged_preview)}):")
331
+ lines.append(
332
+ " Add to select by name or cover with an '# excluded' line in ruff.toml:\n"
333
+ )
334
+ lines.extend(
335
+ f' "{name}",\t# {code}' for code, name in check.untriaged_preview
336
+ )
337
+ lines.append("")
338
+
339
+ if check.is_clean:
340
+ lines.append("All categories accounted for. Config is up to date.")
341
+
342
+ return "\n".join(lines)
343
+
344
+
345
+ # ── ty ────────────────────────────────────────────────────────────────────
346
+
347
+
348
+ @dataclass
349
+ class TyConfigCheck:
350
+ version: str
351
+ rule_count: int
352
+ named_count: int
353
+ untriaged: list[str]
354
+ default_levels: dict[str, str]
355
+ stale: list[str]
356
+
357
+ @property
358
+ def is_clean(self) -> bool:
359
+ return not (self.untriaged or self.stale)
360
+
361
+
362
+ def _get_ty_rules() -> dict[str, str]:
363
+ """Ask ty for every rule it knows (name -> default level)."""
364
+ result = run_checked(
365
+ [sys.executable, "-m", "ty", "explain", "rule", "--output-format", "json"],
366
+ Path.cwd(),
367
+ "ty",
368
+ )
369
+ rules = json.loads(result.stdout)
370
+ return {r["name"]: r["default_level"] for r in rules}
371
+
372
+
373
+ def _get_ty_version() -> str:
374
+ result = run_checked([sys.executable, "-m", "ty", "--version"], Path.cwd(), "ty")
375
+ return result.stdout.strip()
376
+
377
+
378
+ def _parse_toml_rules(config_path: Path) -> set[str]:
379
+ """Read the rule table from ty.toml (`[rules]`) or pyproject.toml (`[tool.ty.rules]`).
380
+
381
+ tomllib, not a regex, for the same reason check_ruff_config uses it:
382
+ a rule name could appear inside a comment without this breaking.
383
+ """
384
+ data = tomllib.loads(config_path.read_text(encoding="utf-8"))
385
+ if "tool" in data:
386
+ rules = data.get("tool", {}).get("ty", {}).get("rules", {})
387
+ else:
388
+ rules = data.get("rules", {})
389
+ return set(rules.keys())
390
+
391
+
392
+ def check_ty_config(config_path: Path) -> TyConfigCheck:
393
+ version = _get_ty_version()
394
+ rules = _get_ty_rules()
395
+ named = _parse_toml_rules(config_path)
396
+
397
+ known = set(rules)
398
+ untriaged = sorted(known - named)
399
+ stale = sorted(named - known)
400
+
401
+ return TyConfigCheck(
402
+ version=version,
403
+ rule_count=len(known),
404
+ named_count=len(named),
405
+ untriaged=untriaged,
406
+ default_levels=rules,
407
+ stale=stale,
408
+ )
409
+
410
+
411
+ def format_ty_report(config_path: Path, check: TyConfigCheck) -> str:
412
+ lines = [
413
+ f"ty version: {check.version}",
414
+ f"Config: {config_path}",
415
+ f"Rules in ty: {check.rule_count}",
416
+ f"Named in config: {check.named_count}",
417
+ "",
418
+ ]
419
+
420
+ if check.untriaged:
421
+ lines.append(
422
+ f"UNTRIAGED rules ty knows that the config omits ({len(check.untriaged)}):"
423
+ )
424
+ lines.extend(
425
+ f' {name} = "???" # default: {check.default_levels[name]}'
426
+ for name in check.untriaged
427
+ )
428
+ lines.append("")
429
+
430
+ if check.stale:
431
+ lines.append(
432
+ f"STALE rules named in config that ty no longer knows ({len(check.stale)}):"
433
+ )
434
+ lines.extend(f" {name}" for name in check.stale)
435
+ lines.append("")
436
+
437
+ if check.is_clean:
438
+ lines.append("All rules accounted for. Config is up to date.")
439
+
440
+ return "\n".join(lines)