sarj-python-lint 0.14.0__tar.gz → 0.15.0__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 (55) hide show
  1. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/PKG-INFO +27 -1
  2. sarj_python_lint-0.15.0/README.md +66 -0
  3. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/pyproject.toml +1 -1
  4. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/_registry.py +18 -0
  5. sarj_python_lint-0.15.0/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +159 -0
  6. sarj_python_lint-0.15.0/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +144 -0
  7. sarj_python_lint-0.15.0/src/sarj_python_lint/rules/mock_without_spec.py +225 -0
  8. sarj_python_lint-0.15.0/src/sarj_python_lint/rules/parametrize_case_needs_id.py +153 -0
  9. sarj_python_lint-0.15.0/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +156 -0
  10. sarj_python_lint-0.15.0/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +187 -0
  11. sarj_python_lint-0.15.0/src/sarj_python_lint/rules/xfail_requires_strict.py +170 -0
  12. sarj_python_lint-0.15.0/src/sarj_python_lint/rules/zero_assertion_test.py +190 -0
  13. sarj_python_lint-0.14.0/README.md +0 -40
  14. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/.gitignore +0 -0
  15. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/__init__.py +0 -0
  16. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/__main__.py +0 -0
  17. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/_secret_names.py +0 -0
  18. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/_version.py +0 -0
  19. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/py.typed +0 -0
  20. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rule_base.py +0 -0
  21. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  22. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  23. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  24. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  25. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -0
  26. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +0 -0
  27. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  28. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  29. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  30. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
  31. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  32. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -0
  33. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  34. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  35. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  36. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -0
  37. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  38. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  39. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  40. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  41. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
  42. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
  43. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
  44. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  45. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  46. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  47. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  48. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  49. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  50. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  51. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  52. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  53. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/single_public_export.py +0 -0
  54. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  55. {sarj_python_lint-0.14.0 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sarj-python-lint
3
- Version: 0.14.0
3
+ Version: 0.15.0
4
4
  Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
5
5
  Project-URL: Homepage, https://github.com/sarj-ai/standards/tree/main/packages/python
6
6
  Project-URL: Repository, https://github.com/sarj-ai/standards
@@ -42,6 +42,32 @@ uv tool install sarj-python-lint
42
42
  - id: sarj-no-fstring-in-log
43
43
  ```
44
44
 
45
+ ### Test-quality rules (0.15.0)
46
+
47
+ Mined from an AST audit of ~7,500 test functions across two production repos.
48
+ Every one is scoped to test files and carries the false-positive guard that made
49
+ it shippable; the module docstring for each records the population it was
50
+ measured against.
51
+
52
+ ```yaml
53
+ - id: sarj-mock-without-spec # SARJ040
54
+ - id: sarj-test-loops-over-literal-cases # SARJ041
55
+ - id: sarj-parametrize-case-needs-id # SARJ042
56
+ - id: sarj-zero-assertion-test # SARJ043
57
+ - id: sarj-fixture-returns-bare-tuple # SARJ044
58
+ - id: sarj-kwarg-heavy-construction-in-test # SARJ045
59
+ - id: sarj-xfail-requires-strict # SARJ046
60
+ - id: sarj-sleep-with-computed-arg-in-test # SARJ047
61
+ ```
62
+
63
+ Adopting these against an existing suite is easier through the baseline ratchet
64
+ than as a big-bang fix — snapshot the current counts, then let them only shrink:
65
+
66
+ ```bash
67
+ sarj-python-lint check --rule mock-without-spec --update-baseline test-quality-baseline.json python/
68
+ sarj-python-lint check --rule mock-without-spec --baseline test-quality-baseline.json python/
69
+ ```
70
+
45
71
  ## CLI
46
72
 
47
73
  ```bash
@@ -0,0 +1,66 @@
1
+ # sarj-python-lint
2
+
3
+ Custom Python lint rules via stdlib `ast`. Designed for pre-commit. For SQL rules see [`sarj-sql-lint`](../sql/).
4
+
5
+ ```bash
6
+ uv tool install sarj-python-lint
7
+ ```
8
+
9
+ ## Pre-commit
10
+
11
+ ```yaml
12
+ - repo: https://github.com/sarj-ai/standards
13
+ rev: python-v0.2.0
14
+ hooks:
15
+ - id: sarj-no-sequential-await
16
+ - id: sarj-inefficient-string-concat-in-loop
17
+ - id: sarj-prefer-str-enum
18
+ - id: sarj-no-fat-try-blocks
19
+ - id: sarj-pydantic-at-boundaries
20
+ - id: sarj-prefer-class-row
21
+ - id: sarj-prefer-timedelta-for-durations
22
+ - id: sarj-prefer-struct-over-namedtuple
23
+ - id: sarj-no-comment-cruft
24
+ - id: sarj-no-fstring-in-log
25
+ ```
26
+
27
+ ### Test-quality rules (0.15.0)
28
+
29
+ Mined from an AST audit of ~7,500 test functions across two production repos.
30
+ Every one is scoped to test files and carries the false-positive guard that made
31
+ it shippable; the module docstring for each records the population it was
32
+ measured against.
33
+
34
+ ```yaml
35
+ - id: sarj-mock-without-spec # SARJ040
36
+ - id: sarj-test-loops-over-literal-cases # SARJ041
37
+ - id: sarj-parametrize-case-needs-id # SARJ042
38
+ - id: sarj-zero-assertion-test # SARJ043
39
+ - id: sarj-fixture-returns-bare-tuple # SARJ044
40
+ - id: sarj-kwarg-heavy-construction-in-test # SARJ045
41
+ - id: sarj-xfail-requires-strict # SARJ046
42
+ - id: sarj-sleep-with-computed-arg-in-test # SARJ047
43
+ ```
44
+
45
+ Adopting these against an existing suite is easier through the baseline ratchet
46
+ than as a big-bang fix — snapshot the current counts, then let them only shrink:
47
+
48
+ ```bash
49
+ sarj-python-lint check --rule mock-without-spec --update-baseline test-quality-baseline.json python/
50
+ sarj-python-lint check --rule mock-without-spec --baseline test-quality-baseline.json python/
51
+ ```
52
+
53
+ ## CLI
54
+
55
+ ```bash
56
+ sarj-python-lint check --rule no-sequential-await path/to/file.py
57
+ sarj-python-lint list-rules
58
+ ```
59
+
60
+ Diagnostic format is `path:line:col: CODE message` — Ruff-compatible.
61
+
62
+ ## Suppression
63
+
64
+ Inline `# sarj-noqa: SARJ00X — <reason>` on the offending line.
65
+
66
+ Each rule's source under `src/sarj_python_lint/rules/` carries its own `description` and diagnostic message.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.14.0"
3
+ version = "0.15.0"
4
4
  description = "Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "sarj-ai" }]
@@ -2,10 +2,13 @@ from __future__ import annotations
2
2
 
3
3
  from typing import TYPE_CHECKING
4
4
 
5
+ from sarj_python_lint.rules.fixture_returns_bare_tuple import FixtureReturnsBareTuple
5
6
  from sarj_python_lint.rules.inefficient_string_concat_in_loop import (
6
7
  InefficientStringConcatInLoop,
7
8
  )
9
+ from sarj_python_lint.rules.kwarg_heavy_construction_in_test import KwargHeavyConstructionInTest
8
10
  from sarj_python_lint.rules.kwonly_same_type_params import KwonlySameTypeParams
11
+ from sarj_python_lint.rules.mock_without_spec import MockWithoutSpec
9
12
  from sarj_python_lint.rules.no_aggregation_in_store_query import (
10
13
  NoAggregationInStoreQuery,
11
14
  )
@@ -29,6 +32,7 @@ from sarj_python_lint.rules.no_sleep_in_test_body import NoSleepInTestBody
29
32
  from sarj_python_lint.rules.no_unreachable_after_terminal import (
30
33
  NoUnreachableAfterTerminal,
31
34
  )
35
+ from sarj_python_lint.rules.parametrize_case_needs_id import ParametrizeCaseNeedsId
32
36
  from sarj_python_lint.rules.prefer_class_row import PreferClassRow
33
37
  from sarj_python_lint.rules.prefer_constant_time_secret_compare import (
34
38
  PreferConstantTimeSecretCompare,
@@ -49,10 +53,16 @@ from sarj_python_lint.rules.prefer_timedelta_for_durations import (
49
53
  )
50
54
  from sarj_python_lint.rules.pydantic_at_boundaries import PydanticAtBoundaries
51
55
  from sarj_python_lint.rules.single_public_export import SinglePublicExport
56
+ from sarj_python_lint.rules.sleep_with_computed_arg_in_test import SleepWithComputedArgInTest
52
57
  from sarj_python_lint.rules.stepdown import Stepdown
53
58
  from sarj_python_lint.rules.store_insert_requires_on_conflict import (
54
59
  StoreInsertRequiresOnConflict,
55
60
  )
61
+ from sarj_python_lint.rules.test_loops_over_literal_cases import (
62
+ TestLoopsOverLiteralCases,
63
+ )
64
+ from sarj_python_lint.rules.xfail_requires_strict import XfailRequiresStrict
65
+ from sarj_python_lint.rules.zero_assertion_test import ZeroAssertionTest
56
66
 
57
67
 
58
68
  if TYPE_CHECKING:
@@ -98,6 +108,14 @@ REGISTRY: dict[str, type[Rule]] = {
98
108
  NoRawSqlInTests.id: NoRawSqlInTests,
99
109
  NoFileLevelSuppression.id: NoFileLevelSuppression,
100
110
  PreferModuleLevelConstant.id: PreferModuleLevelConstant,
111
+ MockWithoutSpec.id: MockWithoutSpec,
112
+ TestLoopsOverLiteralCases.id: TestLoopsOverLiteralCases,
113
+ ParametrizeCaseNeedsId.id: ParametrizeCaseNeedsId,
114
+ FixtureReturnsBareTuple.id: FixtureReturnsBareTuple,
115
+ KwargHeavyConstructionInTest.id: KwargHeavyConstructionInTest,
116
+ XfailRequiresStrict.id: XfailRequiresStrict,
117
+ SleepWithComputedArgInTest.id: SleepWithComputedArgInTest,
118
+ ZeroAssertionTest.id: ZeroAssertionTest,
101
119
  }
102
120
 
103
121
  __all__ = ["REGISTRY"]
@@ -0,0 +1,159 @@
1
+ """SARJ044: a fixture returning a bare tuple forces positional unpacking everywhere.
2
+
3
+ `return org, user` makes every consumer write `org, user = setup_orgs()` and
4
+ know the order by heart. Adding a third value silently shifts every call site's
5
+ meaning rather than breaking it loudly, and a mis-ordered unpack
6
+ (`user, org = ...`) type-checks fine and fails somewhere far away. A `NamedTuple`
7
+ or a small frozen dataclass names the fields, so consumers destructure by name,
8
+ new fields are additive, and the type checker catches a swap at the call site.
9
+
10
+ This is the house rule the codebase already applies to production code, applied
11
+ to fixtures. `CLAUDE.md` states it directly for ordinary functions — "No bare
12
+ multi-field tuples across a boundary... a `NamedTuple`... never a positional
13
+ `tuple[A, B]`" — and SARJ026 enforces it there. Fixtures were never covered,
14
+ which is exactly why they drifted.
15
+
16
+ Fires when ALL of these hold:
17
+
18
+ * the file is a test file, and the function carries a `@pytest.fixture` or
19
+ `@pytest_asyncio.fixture` decorator (in any spelling: bare, called, or
20
+ attribute-qualified),
21
+ * and the fixture's own body has a top-level `return`/`yield` of a **tuple
22
+ display** with at least two elements.
23
+
24
+ The nearest-enclosing-function check (the SARJ031 technique) is what makes this
25
+ safe: a factory fixture that returns a closure which itself returns a tuple is
26
+ attributed to the closure, not the fixture, and does not fire. That pattern is
27
+ common and legitimate — the tuple crosses the closure's boundary, not the
28
+ fixture's.
29
+
30
+ Deliberately NOT flagged:
31
+
32
+ * a `NamedTuple`, dataclass, or any other constructor call — those are
33
+ `ast.Call` nodes, never `ast.Tuple`, so the correct alternative can never be
34
+ mistaken for the smell,
35
+ * a single-element tuple — nothing to mis-order,
36
+ * a starred tuple (`return *pair, extra`) — the arity is not statically known,
37
+ * a tuple returned from a nested helper or closure inside the fixture.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import ast
43
+ from typing import TYPE_CHECKING, override
44
+
45
+ from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
46
+ from sarj_python_lint.rules._paths import is_test_path
47
+
48
+
49
+ if TYPE_CHECKING:
50
+ from pathlib import Path
51
+
52
+
53
+ _FIXTURE = "fixture"
54
+
55
+ _MIN_FIELDS = 2
56
+
57
+ _FUNC_NODES = (ast.FunctionDef, ast.AsyncFunctionDef)
58
+
59
+
60
+ class FixtureReturnsBareTuple(Rule):
61
+ """A fixture returning a bare tuple forces every consumer to unpack by position."""
62
+
63
+ id: str = "fixture-returns-bare-tuple"
64
+ code: str = "SARJ044"
65
+ description: str = (
66
+ "Fixture returns a bare multi-field tuple — return a NamedTuple so consumers destructure by name."
67
+ )
68
+
69
+ @override
70
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
71
+ """Flag pytest fixtures whose own body returns or yields a bare tuple.
72
+
73
+ Returns:
74
+ One diagnostic per bare-tuple return, sorted by position.
75
+
76
+ """
77
+ if not is_test_path(path):
78
+ return []
79
+ tree = parse_or_none(path, source)
80
+ if tree is None:
81
+ return []
82
+
83
+ diags = [
84
+ Diagnostic(
85
+ path=path,
86
+ line=node.lineno,
87
+ col=node.col_offset + 1,
88
+ code=self.code,
89
+ message=(
90
+ f"this fixture hands back a bare {count}-field tuple, so every consumer unpacks it "
91
+ "positionally and a reorder fails silently. Return a `NamedTuple` (or a frozen "
92
+ "dataclass) so the fields are named."
93
+ ),
94
+ )
95
+ for node, count in _bare_tuple_results(tree)
96
+ ]
97
+ diags.sort(key=lambda d: (d.line, d.col))
98
+ return diags
99
+
100
+
101
+ def _bare_tuple_results(tree: ast.Module) -> list[tuple[ast.expr, int]]:
102
+ hits: list[tuple[ast.expr, int]] = []
103
+ for node in ast.walk(tree):
104
+ if not isinstance(node, _FUNC_NODES) or not _is_fixture(node):
105
+ continue
106
+ hits.extend(_tuple_results_of(node))
107
+ return hits
108
+
109
+
110
+ def _is_fixture(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
111
+ return any(_names_fixture(dec) for dec in node.decorator_list)
112
+
113
+
114
+ def _names_fixture(dec: ast.expr) -> bool:
115
+ # `@pytest.fixture`, `@pytest.fixture(scope=...)`, `@fixture`, `@pytest_asyncio.fixture`.
116
+ target = dec.func if isinstance(dec, ast.Call) else dec
117
+ if isinstance(target, ast.Attribute):
118
+ return target.attr == _FIXTURE
119
+ return isinstance(target, ast.Name) and target.id == _FIXTURE
120
+
121
+
122
+ def _tuple_results_of(fixture: ast.FunctionDef | ast.AsyncFunctionDef) -> list[tuple[ast.expr, int]]:
123
+ found: list[tuple[ast.expr, int]] = []
124
+ for stmt in fixture.body:
125
+ found.extend(_scan_for_results(stmt))
126
+ return found
127
+
128
+
129
+ def _scan_for_results(node: ast.AST) -> list[tuple[ast.expr, int]]:
130
+ # Descend through control flow but never into a nested function: a tuple
131
+ # returned by a closure the fixture builds crosses the closure's boundary,
132
+ # not the fixture's, so it is a different (and legitimate) shape.
133
+ if isinstance(node, (*_FUNC_NODES, ast.Lambda)):
134
+ return []
135
+ found: list[tuple[ast.expr, int]] = []
136
+ value = _returned_value(node)
137
+ if value is not None:
138
+ count = _bare_tuple_arity(value)
139
+ if count >= _MIN_FIELDS:
140
+ found.append((value, count))
141
+ for child in ast.iter_child_nodes(node):
142
+ found.extend(_scan_for_results(child))
143
+ return found
144
+
145
+
146
+ def _returned_value(node: ast.AST) -> ast.expr | None:
147
+ if isinstance(node, ast.Return):
148
+ return node.value
149
+ if isinstance(node, ast.Expr) and isinstance(node.value, ast.Yield):
150
+ return node.value.value
151
+ return None
152
+
153
+
154
+ def _bare_tuple_arity(value: ast.expr) -> int:
155
+ if not isinstance(value, ast.Tuple):
156
+ return 0
157
+ if any(isinstance(elt, ast.Starred) for elt in value.elts):
158
+ return 0
159
+ return len(value.elts)
@@ -0,0 +1,144 @@
1
+ """SARJ045: a domain object built with many kwargs inline belongs in a builder.
2
+
3
+ A test that constructs `SarjBeneficiary(id=..., name=..., iban=..., bank=...,
4
+ status=..., created_at=..., updated_at=..., owner=..., currency=...)` in its own
5
+ body states nine facts, and typically only one of them is the thing under test.
6
+ The other eight are noise the reader must scan past to find the interesting
7
+ field, and every one of them has to be revisited when the model gains a required
8
+ column — across every test that spells the object out. A builder or factory with
9
+ defaults collapses that to `build_beneficiary(status="frozen")`, which says what
10
+ the test is about.
11
+
12
+ Fires when ALL of these hold:
13
+
14
+ * the file is a test file, and the **nearest enclosing function** of the call is
15
+ named `test_*`,
16
+ * and the call passes more than eight keyword arguments.
17
+
18
+ The nearest-enclosing-function guard is what makes this rule worth having rather
19
+ than noise. A blind sweep of both corpora found 113 kwarg-heavy constructions,
20
+ but 96 of them sit inside a module-level `_make_*`/`_build_*` helper — which is
21
+ precisely the factory this rule asks for, already written. Counting those would
22
+ have meant nagging at the well-factored code and rewarding the sloppy kind.
23
+ Scoped to calls directly in a test body, the population drops to 17.
24
+
25
+ The threshold is deliberately high. Eight keywords is well past the point where
26
+ a constructor call is self-explanatory, and it was chosen so the rule fires only
27
+ where the audited corpora showed a genuine builder was missing — in at least
28
+ three cases (`digital-bank/banking-ai/chat/tests/test_chat_store.py`) the fix is
29
+ a one-line import of a `build_sarj_beneficiary` helper that already exists in
30
+ `common/testing/builders.py`.
31
+
32
+ Deliberately NOT flagged:
33
+
34
+ * calls inside a fixture, a `_make_*` helper, or any non-test function — that is
35
+ the factory, and it is allowed to be verbose exactly once,
36
+ * positional arguments — a call with many positionals is a different smell, and
37
+ ruff's own rules already discourage it,
38
+ * `dict(...)` and literal dict displays — those are data, not a domain object,
39
+ and naming their keys is the point rather than the problem.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import ast
45
+ from typing import TYPE_CHECKING, override
46
+
47
+ from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
48
+ from sarj_python_lint.rules._paths import is_test_path
49
+
50
+
51
+ if TYPE_CHECKING:
52
+ from pathlib import Path
53
+
54
+
55
+ _MAX_KEYWORDS = 8
56
+
57
+ # `dict(a=1, b=2, ...)` is a mapping literal, not a domain object.
58
+ _DATA_CALLABLES = frozenset({"dict"})
59
+
60
+
61
+ class KwargHeavyConstructionInTest(Rule):
62
+ """A >8-keyword construction directly in a test body wants a builder."""
63
+
64
+ id: str = "kwarg-heavy-construction-in-test"
65
+ code: str = "SARJ045"
66
+ description: str = "Object built with many keywords inline in a test — extract a builder with defaults."
67
+
68
+ @override
69
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
70
+ """Flag kwarg-heavy constructions sitting directly in a test body.
71
+
72
+ Returns:
73
+ One diagnostic per over-wide construction, sorted by position.
74
+
75
+ """
76
+ if not is_test_path(path):
77
+ return []
78
+ tree = parse_or_none(path, source)
79
+ if tree is None:
80
+ return []
81
+
82
+ visitor = _KwargHeavyVisitor()
83
+ visitor.visit(tree)
84
+ diags = [
85
+ Diagnostic(
86
+ path=path,
87
+ line=node.lineno,
88
+ col=node.col_offset + 1,
89
+ code=self.code,
90
+ message=(
91
+ f"this call passes {count} keywords inline, so the one field under test is buried "
92
+ "and every other test repeats the same boilerplate. Extract a builder with "
93
+ "defaults and override only what this test is about."
94
+ ),
95
+ )
96
+ for node, count in visitor.hits
97
+ ]
98
+ diags.sort(key=lambda d: (d.line, d.col))
99
+ return diags
100
+
101
+
102
+ class _KwargHeavyVisitor(ast.NodeVisitor):
103
+ """Flag wide keyword calls whose nearest enclosing function is a test.
104
+
105
+ Mirrors SARJ031's enclosing-function stack so a construction inside a
106
+ `_make_*` helper or fixture declared anywhere in the file is attributed to
107
+ that helper — the factory is allowed to be verbose.
108
+ """
109
+
110
+ def __init__(self) -> None:
111
+ super().__init__()
112
+ self._func_names: list[str | None] = []
113
+ self.hits: list[tuple[ast.Call, int]] = []
114
+
115
+ def _visit_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
116
+ self._func_names.append(node.name)
117
+ self.generic_visit(node)
118
+ self._func_names.pop()
119
+
120
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
121
+ self._visit_function(node)
122
+
123
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
124
+ self._visit_function(node)
125
+
126
+ def visit_Lambda(self, node: ast.Lambda) -> None:
127
+ self._func_names.append(None)
128
+ self.generic_visit(node)
129
+ self._func_names.pop()
130
+
131
+ def visit_Call(self, node: ast.Call) -> None:
132
+ if self._in_test_function() and not _is_data_callable(node.func):
133
+ named = [kw for kw in node.keywords if kw.arg is not None]
134
+ if len(named) > _MAX_KEYWORDS:
135
+ self.hits.append((node, len(named)))
136
+ self.generic_visit(node)
137
+
138
+ def _in_test_function(self) -> bool:
139
+ nearest = self._func_names[-1] if self._func_names else None
140
+ return nearest is not None and nearest.startswith("test_")
141
+
142
+
143
+ def _is_data_callable(func: ast.expr) -> bool:
144
+ return isinstance(func, ast.Name) and func.id in _DATA_CALLABLES