sarj-python-lint 0.18.0__tar.gz → 0.19.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 (56) hide show
  1. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/PKG-INFO +26 -1
  2. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/README.md +25 -0
  3. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/pyproject.toml +1 -1
  4. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rule_base.py +5 -5
  5. sarj_python_lint-0.19.0/src/sarj_python_lint/rules/_first_party.py +192 -0
  6. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/_registry.py +4 -0
  7. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +2 -0
  8. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +34 -1
  9. sarj_python_lint-0.19.0/src/sarj_python_lint/rules/no_first_party_private_import.py +184 -0
  10. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_sequential_await.py +2 -0
  11. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/prefer_str_enum.py +18 -0
  12. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +21 -0
  13. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/stepdown.py +2 -0
  14. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/.gitignore +0 -0
  15. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/__init__.py +0 -0
  16. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/__main__.py +0 -0
  17. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/_secret_names.py +0 -0
  18. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/_version.py +0 -0
  19. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/py.typed +0 -0
  20. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  21. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  22. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  23. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  24. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  25. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  26. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  27. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  28. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  29. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  30. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
  31. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  32. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -0
  33. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  34. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  35. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  36. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -0
  37. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  38. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  39. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  40. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  41. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
  42. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
  43. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +0 -0
  44. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  45. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  46. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  47. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  48. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  49. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  50. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  51. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/single_public_export.py +0 -0
  52. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +0 -0
  53. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  54. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +0 -0
  55. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/xfail_requires_strict.py +0 -0
  56. {sarj_python_lint-0.18.0 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/zero_assertion_test.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sarj-python-lint
3
- Version: 0.18.0
3
+ Version: 0.19.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
@@ -60,6 +60,31 @@ measured against.
60
60
  - id: sarj-sleep-with-computed-arg-in-test # SARJ047
61
61
  ```
62
62
 
63
+ ### Private access, first-party only (0.19.0)
64
+
65
+ ```yaml
66
+ - id: sarj-no-first-party-private-import # SARJ048
67
+ ```
68
+
69
+ Reaching past a module's public surface is a design finding when the module is
70
+ ours and an unavoidable fact of life when it is not: a dependency that moves an
71
+ API private in a minor release leaves no edit that satisfies the lint.
72
+
73
+ `SARJ048` fires only when the module declaring the private name resolves to a
74
+ package inside your own project. Third-party privates are never flagged.
75
+
76
+ **It replaces ruff's `PLC2701 import-private-name`,** whose only exemption is
77
+ *same top-level package* — a different question, and one that cannot separate
78
+ `from bulbul.stores.task_store import _row_to_task` (real; export it) from
79
+ `from livekit.agents.inference_runner import _InferenceRunner` (no fix exists).
80
+ `sarj-lint-configs` ≥ 0.8.0 ships `PLC2701` in its ignore list for exactly this
81
+ reason; if you take that config, turn this hook on, or you lose the check
82
+ entirely.
83
+
84
+ Attribute access (`session._stt`) is out of scope and stays with ruff's
85
+ `SLF001`, which cannot make the distinction either — see the rationale in
86
+ `ruff.strict.toml`.
87
+
63
88
  Adopting these against an existing suite is easier through the baseline ratchet
64
89
  than as a big-bang fix — snapshot the current counts, then let them only shrink:
65
90
 
@@ -42,6 +42,31 @@ measured against.
42
42
  - id: sarj-sleep-with-computed-arg-in-test # SARJ047
43
43
  ```
44
44
 
45
+ ### Private access, first-party only (0.19.0)
46
+
47
+ ```yaml
48
+ - id: sarj-no-first-party-private-import # SARJ048
49
+ ```
50
+
51
+ Reaching past a module's public surface is a design finding when the module is
52
+ ours and an unavoidable fact of life when it is not: a dependency that moves an
53
+ API private in a minor release leaves no edit that satisfies the lint.
54
+
55
+ `SARJ048` fires only when the module declaring the private name resolves to a
56
+ package inside your own project. Third-party privates are never flagged.
57
+
58
+ **It replaces ruff's `PLC2701 import-private-name`,** whose only exemption is
59
+ *same top-level package* — a different question, and one that cannot separate
60
+ `from bulbul.stores.task_store import _row_to_task` (real; export it) from
61
+ `from livekit.agents.inference_runner import _InferenceRunner` (no fix exists).
62
+ `sarj-lint-configs` ≥ 0.8.0 ships `PLC2701` in its ignore list for exactly this
63
+ reason; if you take that config, turn this hook on, or you lose the check
64
+ entirely.
65
+
66
+ Attribute access (`session._stt`) is out of scope and stays with ruff's
67
+ `SLF001`, which cannot make the distinction either — see the rationale in
68
+ `ruff.strict.toml`.
69
+
45
70
  Adopting these against an existing suite is easier through the baseline ratchet
46
71
  than as a big-bang fix — snapshot the current counts, then let them only shrink:
47
72
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.18.0"
3
+ version = "0.19.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" }]
@@ -87,7 +87,7 @@ class Rule(ABC):
87
87
  raise NotImplementedError
88
88
 
89
89
 
90
- _last_parse: tuple[tuple[str, int, int], ast.Module | None] | None = None
90
+ _last_parse: tuple[str, str, ast.Module | None] | None = None
91
91
 
92
92
 
93
93
  def parse_or_none(path: Path, source: str) -> ast.Module | None:
@@ -98,12 +98,12 @@ def parse_or_none(path: Path, source: str) -> ast.Module | None:
98
98
 
99
99
  """
100
100
  global _last_parse # ruff:ignore[global-statement] — single-slot memo; the CLI runs rules per file sequentially
101
- key = (str(path), len(source), hash(source))
102
- if _last_parse is not None and _last_parse[0] == key:
103
- return _last_parse[1]
101
+ path_key = str(path)
102
+ if _last_parse is not None and _last_parse[0] == path_key and _last_parse[1] is source:
103
+ return _last_parse[2]
104
104
  try:
105
105
  tree = ast.parse(source, filename=str(path))
106
106
  except SyntaxError:
107
107
  tree = None
108
- _last_parse = (key, tree)
108
+ _last_parse = (path_key, source, tree)
109
109
  return tree
@@ -0,0 +1,192 @@
1
+ """Shared first-party / third-party module resolution.
2
+
3
+ Every "don't reach into privates" rule needs one thing no purely syntactic
4
+ checker has: whether the module that *declares* the private name is ours or
5
+ somebody else's. Reaching into our own module's underscore names is a design
6
+ problem we can fix by exporting a public surface. Reaching into a dependency's
7
+ underscore names is often the only option available — when a library moves an
8
+ API private in a minor release, the "just use the public API" advice names an
9
+ API that no longer exists, and the lint finding becomes an instruction to
10
+ perform an impossible edit.
11
+
12
+ Resolution is filesystem-based and deliberately conservative:
13
+
14
+ * a module is FIRST-PARTY when its top-level name is a package directory (one
15
+ containing `__init__.py`) found inside the enclosing project;
16
+ * everything else — stdlib, site-packages, anything unresolvable — is treated
17
+ as THIRD-PARTY, because the failure mode of guessing "third-party" is a
18
+ missed finding, while the failure mode of guessing "first-party" is exactly
19
+ the impossible-edit demand these rules exist to avoid.
20
+
21
+ The `__init__.py` requirement is load-bearing, not incidental: bulbul carries a
22
+ `python/bulbul/livekit/` directory of SIP trunk JSON, and a name-only match
23
+ would have classified the `livekit` dependency as first-party and re-flagged
24
+ the very imports this distinction exists to exempt. Requiring an importable
25
+ package makes a top-level name collide only when a real first-party package
26
+ shadows the distribution — at which point flagging it is correct.
27
+
28
+ The project root is the nearest ancestor holding `.git`, falling back to the
29
+ topmost contiguous run of ancestors holding `pyproject.toml` (worktrees,
30
+ sdist checkouts, and vendored trees all resolve). Scanning stops at the first
31
+ package directory on each branch, so only *top-level* package names are
32
+ collected — `agent.lk.custom_models` contributes `agent`, never `lk`.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from functools import lru_cache
38
+ import sys
39
+ from typing import TYPE_CHECKING
40
+
41
+
42
+ if TYPE_CHECKING:
43
+ from pathlib import Path
44
+
45
+
46
+ # `.git` is a file, not a directory, inside a linked worktree — test existence,
47
+ # never `is_dir()`.
48
+ _GIT_MARKER = ".git"
49
+ _PROJECT_MARKER = "pyproject.toml"
50
+
51
+ # Never descend into these: a virtualenv or vendored tree holds *third-party*
52
+ # packages, and collecting their names would classify every dependency as ours.
53
+ _SKIP_DIR_NAMES = frozenset({
54
+ ".git",
55
+ ".mypy_cache",
56
+ ".next",
57
+ ".pytest_cache",
58
+ ".ruff_cache",
59
+ ".tox",
60
+ ".turbo",
61
+ ".venv",
62
+ "__pycache__",
63
+ "build",
64
+ "coverage",
65
+ "dist",
66
+ "node_modules",
67
+ "site-packages",
68
+ "venv",
69
+ })
70
+
71
+ # Deep enough for a uv/pnpm-style monorepo (`<root>/packages/python/src/<pkg>`),
72
+ # shallow enough that the scan stays a few hundred `iterdir()` calls.
73
+ _MAX_SCAN_DEPTH = 5
74
+
75
+ # Hard stop so a pathological tree (a huge monorepo, a symlink loop) degrades to
76
+ # "nothing is first-party" — under-flagging — rather than hanging the linter.
77
+ _MAX_DIRS_SCANNED = 3000
78
+
79
+ # Walking up forever from a relative path is how a linter ends up scanning $HOME.
80
+ _MAX_ANCESTORS = 24
81
+
82
+
83
+ def is_first_party_module(module: str, path: Path) -> bool:
84
+ """Report whether dotted `module` is declared inside `path`'s own project.
85
+
86
+ Returns:
87
+ True when the module's top-level name resolves to a first-party package.
88
+
89
+ """
90
+ top = module.partition(".")[0]
91
+ if not top or top in sys.stdlib_module_names:
92
+ return False
93
+ root = _project_root(path)
94
+ if root is None:
95
+ return False
96
+ return top in _first_party_roots(root)
97
+
98
+
99
+ def own_top_package(path: Path) -> str | None:
100
+ """Return the name of the top-level package `path` itself belongs to.
101
+
102
+ The OUTERMOST importable ancestor wins rather than the outermost of an
103
+ unbroken `__init__.py` run, because PEP 420 namespace subpackages are
104
+ routine — bulbul's `agent/agent/lk/custom_models/` carries no `__init__.py`
105
+ while `agent/agent/` does, and a break-on-first-gap walk would report the
106
+ file as belonging to no package at all.
107
+
108
+ Returns:
109
+ The top-level package name, or None when `path` is not inside a package.
110
+
111
+ """
112
+ resolved = _resolved(path)
113
+ if resolved is None:
114
+ return None
115
+ root = _project_root(resolved)
116
+ top: str | None = None
117
+ for ancestor in list(resolved.parents)[:_MAX_ANCESTORS]:
118
+ if (ancestor / "__init__.py").exists():
119
+ top = ancestor.name
120
+ if ancestor == root:
121
+ break
122
+ return top
123
+
124
+
125
+ def _resolved(path: Path) -> Path | None:
126
+ try:
127
+ return path.resolve()
128
+ except OSError:
129
+ return None
130
+
131
+
132
+ @lru_cache(maxsize=256)
133
+ def _project_root(path: Path) -> Path | None:
134
+ """Locate the project boundary above `path`, memoized per file path.
135
+
136
+ Returns:
137
+ The project root directory, or None when no marker is found.
138
+
139
+ """
140
+ resolved = _resolved(path)
141
+ if resolved is None:
142
+ return None
143
+ ancestors = list(resolved.parents)[:_MAX_ANCESTORS]
144
+ for ancestor in ancestors:
145
+ if (ancestor / _GIT_MARKER).exists():
146
+ return ancestor
147
+ # No VCS boundary: take the OUTERMOST directory of the unbroken run of
148
+ # pyproject.toml ancestors, which is the workspace root in a uv workspace.
149
+ outermost: Path | None = None
150
+ for ancestor in ancestors:
151
+ if not (ancestor / _PROJECT_MARKER).exists():
152
+ if outermost is not None:
153
+ break
154
+ continue
155
+ outermost = ancestor
156
+ return outermost
157
+
158
+
159
+ @lru_cache(maxsize=32)
160
+ def _first_party_roots(root: Path) -> frozenset[str]:
161
+ """Collect the top-level package names declared anywhere under `root`.
162
+
163
+ Returns:
164
+ Every directory name under `root` that is an importable package.
165
+
166
+ """
167
+ names: set[str] = set()
168
+ queue: list[tuple[Path, int]] = [(root, 0)]
169
+ scanned = 0
170
+ while queue:
171
+ directory, depth = queue.pop()
172
+ scanned += 1
173
+ if scanned > _MAX_DIRS_SCANNED:
174
+ break
175
+ try:
176
+ entries = sorted(directory.iterdir())
177
+ except OSError:
178
+ continue
179
+ for entry in entries:
180
+ if entry.name.startswith(".") or entry.name in _SKIP_DIR_NAMES:
181
+ continue
182
+ try:
183
+ if not entry.is_dir():
184
+ continue
185
+ is_package = (entry / "__init__.py").exists()
186
+ except OSError:
187
+ continue
188
+ if is_package:
189
+ names.add(entry.name)
190
+ elif depth + 1 < _MAX_SCAN_DEPTH:
191
+ queue.append((entry, depth + 1))
192
+ return frozenset(names)
@@ -18,6 +18,9 @@ from sarj_python_lint.rules.no_cors_wildcard_with_credentials import (
18
18
  )
19
19
  from sarj_python_lint.rules.no_fat_try_blocks import NoFatTryBlocks
20
20
  from sarj_python_lint.rules.no_file_level_suppression import NoFileLevelSuppression
21
+ from sarj_python_lint.rules.no_first_party_private_import import (
22
+ NoFirstPartyPrivateImport,
23
+ )
21
24
  from sarj_python_lint.rules.no_fstring_in_log import NoFstringInLog
22
25
  from sarj_python_lint.rules.no_isinstance_union_chain import NoIsinstanceUnionChain
23
26
  from sarj_python_lint.rules.no_offset_pagination import NoOffsetPagination
@@ -116,6 +119,7 @@ REGISTRY: dict[str, type[Rule]] = {
116
119
  XfailRequiresStrict.id: XfailRequiresStrict,
117
120
  SleepWithComputedArgInTest.id: SleepWithComputedArgInTest,
118
121
  ZeroAssertionTest.id: ZeroAssertionTest,
122
+ NoFirstPartyPrivateImport.id: NoFirstPartyPrivateImport,
119
123
  }
120
124
 
121
125
  __all__ = ["REGISTRY"]
@@ -67,6 +67,8 @@ class InefficientStringConcatInLoop(Rule):
67
67
  def check(self, path: Path, source: str) -> list[Diagnostic]:
68
68
  if is_generated_source(source):
69
69
  return []
70
+ if "+" not in source or ("for " not in source and "while " not in source):
71
+ return []
70
72
  tree = parse_or_none(path, source)
71
73
  if tree is None:
72
74
  return []
@@ -157,16 +157,30 @@ _DUCK_PROTOCOL_METHODS = frozenset(
157
157
  #: drawn entirely from one of these reads unambiguously positionally.
158
158
  _CONVENTIONAL_ORDER_GROUPS = (
159
159
  frozenset({"x", "y", "z"}),
160
+ frozenset({"lat", "lon", "alt"}),
161
+ frozenset({"latitude", "longitude", "altitude"}),
160
162
  frozenset({"width", "height", "depth"}),
161
163
  frozenset({"red", "green", "blue", "alpha"}),
162
164
  frozenset({"row", "column"}),
163
165
  frozenset({"top", "right", "bottom", "left"}),
166
+ frozenset({"left", "right"}),
167
+ frozenset({"lo", "hi"}),
168
+ frozenset({"low", "high"}),
169
+ frozenset({"minimum", "maximum"}),
170
+ frozenset({"min_value", "max_value"}),
171
+ frozenset({"begin", "end"}),
172
+ frozenset({"source", "sink"}),
164
173
  frozenset({"year", "month", "day"}),
165
174
  frozenset({"hour", "minute", "second", "microsecond"}),
166
175
  frozenset({"start", "stop", "step"}),
167
176
  )
168
177
 
169
178
  _EXEMPT_NAME_PREFIXES = ("visit_", "test_")
179
+ _RISKY_NAME_PART_RE = re.compile(
180
+ r"(?:^|_)(?:id|key|token|secret|password|signature|hash|email|url|uri|path|file|"
181
+ r"source|src|target|dst|dest|destination|parent|child|from|to|old|new|"
182
+ r"before|after|previous|next|expected|actual|left_id|right_id)(?:_|$)"
183
+ )
170
184
 
171
185
 
172
186
  class KwonlySameTypeParams(Rule):
@@ -350,11 +364,30 @@ def _swap_prone_annotation(args: ast.arguments) -> str | None:
350
364
  for name, arg_names in sorted(groups.items(), key=lambda kv: -len(kv[1])):
351
365
  if len(arg_names) >= _MIN_SAME_TYPE and not (
352
366
  _is_symmetric_numbering(arg_names) or _is_conventional_order(arg_names)
353
- ):
367
+ ) and _is_high_value_group(name, arg_names):
354
368
  return name
355
369
  return None
356
370
 
357
371
 
372
+ def _is_high_value_group(annotation: str, arg_names: list[str]) -> bool:
373
+ """Report whether a same-primitive group is worth enforcing globally.
374
+
375
+ Booleans are always high-risk because positional `True, False` carries no
376
+ call-site meaning. Other primitives fire only when the parameter names carry
377
+ production-domain identifiers or directed relationships (`source_id`,
378
+ `target_id`, `old_key`, `new_key`, `input_path`, `output_path`). This keeps
379
+ math / algorithm APIs such as `power(base, exponent)` and `f(a, b)` out of
380
+ the default rule while preserving the bug class the rule was written for.
381
+
382
+ Returns:
383
+ True when the group should be reported.
384
+
385
+ """
386
+ if annotation == "bool":
387
+ return True
388
+ return sum(1 for name in arg_names if _RISKY_NAME_PART_RE.search(name)) >= _MIN_SAME_TYPE
389
+
390
+
358
391
  def _is_dunder_prefixed(arg: str) -> bool:
359
392
  """Report whether `arg` uses the PEP 484 positional-only naming convention.
360
393
 
@@ -0,0 +1,184 @@
1
+ """SARJ048: importing a private name — but only when the private name is OURS.
2
+
3
+ Reaching past a module's public surface is a design finding when the module is
4
+ ours and an unavoidable fact of life when it is not. `from bulbul.stores.task_store
5
+ import _row_to_task` says a first-party module has a helper someone needed and
6
+ did not export; the fix is to export it. `from livekit.agents.inference_runner
7
+ import _InferenceRunner` says a dependency moved an API private in a minor
8
+ release — livekit-agents 1.6.6 did exactly this to bulbul's custom EOU runner —
9
+ and there is no edit that satisfies the lint short of vendoring the library or
10
+ pinning it forever. A rule that cannot tell those apart is an instruction to
11
+ perform an impossible edit, which is how blanket suppressions get born.
12
+
13
+ This rule fires ONLY on the first case. Third-party privates are never flagged.
14
+
15
+ Ruff's `PLC2701 import-private-name` is the rule this replaces, and it does not
16
+ make the distinction. Its exemption is *same top-level package*, not
17
+ *first-party*: measured over bulbul's five packages it produced 80 findings, of
18
+ which 77 were first-party (real) and 3 were livekit reaches with no available
19
+ fix. Ruff has no configuration surface that separates them — the check is
20
+ purely lexical and never resolves an import to a location on disk. Pyright's
21
+ `reportPrivateUsage` fires on the same third-party import and likewise has no
22
+ first-party/third-party knob, so it still needs a per-line `# pyright: ignore`
23
+ at the reach site; this rule does not change that, it only stops ruff from
24
+ demanding a second, unfixable suppression on the same line.
25
+
26
+ Fires on:
27
+
28
+ * `from <first-party module> import _name` — a private symbol,
29
+ * `from <first-party package>._private_module import Name`, and
30
+ `import <first-party package>._private_module` — a private *submodule*, which
31
+ is just as much a non-public surface as a private symbol.
32
+
33
+ Deliberately NOT flagged:
34
+
35
+ * **anything third-party.** A module is first-party only when its top-level
36
+ name resolves to a package directory inside the enclosing project (see
37
+ `_first_party.py`); stdlib, site-packages and anything unresolvable are
38
+ third-party. Unresolvable defaults to third-party on purpose: a missed
39
+ finding is a smaller failure than an unfixable one.
40
+ * **relative imports** (`from . import _helper`, `from ._impl import Thing`) —
41
+ a relative import cannot leave its own package by construction, and a
42
+ package's own internals are its own business. This matches PLC2701.
43
+ * **same-top-level-package absolute imports** — `from agent.lk.get_models
44
+ import _wire_fallback_metric` written from inside the `agent` package is the
45
+ spelled-out form of the bullet above. Written from `agent/tests/` — which is
46
+ not inside the package — it fires, because a white-box test reaching into a
47
+ module's internals from outside is the finding, not the exemption.
48
+ * **dunder names** (`__version__`, `__all__`) — conventional module metadata,
49
+ not private internals.
50
+ * **a private TOP-LEVEL package name** — `from _infra.fakes import FakeStt`,
51
+ bulbul's shared test-support package. The underscore there is the package's
52
+ own name, not a hidden corner of somebody else's module: there is no public
53
+ spelling to switch to and no surface to widen. PLC2701 flags these (14 hits
54
+ in bulbul's `agent` package alone) with no available fix. Private *sub*module
55
+ segments still fire.
56
+ * `_`-prefixed *aliases* (`import json as _json`) — the alias is local shorthand
57
+ and the imported name is public.
58
+ """
59
+
60
+ from __future__ import annotations
61
+
62
+ import ast
63
+ from typing import TYPE_CHECKING, override
64
+
65
+ from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
66
+ from sarj_python_lint.rules._first_party import is_first_party_module, own_top_package
67
+
68
+
69
+ if TYPE_CHECKING:
70
+ from pathlib import Path
71
+
72
+
73
+ class NoFirstPartyPrivateImport(Rule):
74
+ """A private name imported out of one of OUR modules — export it instead."""
75
+
76
+ id: str = "no-first-party-private-import"
77
+ code: str = "SARJ048"
78
+ description: str = (
79
+ "Importing a private (`_`-prefixed) name or module from a FIRST-PARTY module reaches past a "
80
+ "surface we control and can widen. Third-party privates are never flagged — that API is not ours to change."
81
+ )
82
+
83
+ @override
84
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
85
+ """Flag every private import whose defining module is first-party.
86
+
87
+ Returns:
88
+ One diagnostic per private name (or private module) imported from
89
+ one of this project's own modules, sorted by position.
90
+
91
+ """
92
+ tree = parse_or_none(path, source)
93
+ if tree is None:
94
+ return []
95
+ own_top = own_top_package(path)
96
+ diags = [
97
+ Diagnostic(path=path, line=line, col=col, code=self.code, message=_message(module, name))
98
+ for line, col, module, name in _private_imports(tree)
99
+ if _is_ours(module, path, own_top)
100
+ ]
101
+ diags.sort(key=lambda d: (d.line, d.col))
102
+ return diags
103
+
104
+
105
+ def _message(module: str, name: str) -> str:
106
+ return (
107
+ f"`{name}` is private to `{module}`, which is first-party — importing it reaches past a public "
108
+ f"surface we own and can widen. Export it under a public name, or move the caller behind a "
109
+ f"function `{module}` already exports. (Private imports from third-party packages are never flagged.)"
110
+ )
111
+
112
+
113
+ def _is_ours(module: str, path: Path, own_top: str | None) -> bool:
114
+ """Report whether `module` is a first-party module OUTSIDE the file's own package.
115
+
116
+ Returns:
117
+ True when the private import crosses into another first-party package.
118
+
119
+ """
120
+ top = module.partition(".")[0]
121
+ if own_top is not None and top == own_top:
122
+ return False
123
+ return is_first_party_module(module, path)
124
+
125
+
126
+ def _private_imports(tree: ast.Module) -> list[tuple[int, int, str, str]]:
127
+ """Collect `(line, col, defining module, private name)` for every private import.
128
+
129
+ Returns:
130
+ One entry per private symbol or private module segment imported
131
+ absolutely; relative imports are skipped.
132
+
133
+ """
134
+ hits: list[tuple[int, int, str, str]] = []
135
+ for node in ast.walk(tree):
136
+ if isinstance(node, ast.ImportFrom):
137
+ hits.extend(_from_import_hits(node))
138
+ elif isinstance(node, ast.Import):
139
+ hits.extend(_plain_import_hits(node))
140
+ return hits
141
+
142
+
143
+ def _from_import_hits(node: ast.ImportFrom) -> list[tuple[int, int, str, str]]:
144
+ # `node.level` > 0 is a relative import: inside its own package by construction.
145
+ if node.level or not node.module:
146
+ return []
147
+ private_segment = _private_segment(node.module)
148
+ if private_segment is not None:
149
+ return [(node.lineno, node.col_offset + 1, node.module, private_segment)]
150
+ return [
151
+ (alias.lineno, alias.col_offset + 1, node.module, alias.name)
152
+ for alias in node.names
153
+ if _is_private_name(alias.name)
154
+ ]
155
+
156
+
157
+ def _plain_import_hits(node: ast.Import) -> list[tuple[int, int, str, str]]:
158
+ hits: list[tuple[int, int, str, str]] = []
159
+ for alias in node.names:
160
+ private_segment = _private_segment(alias.name)
161
+ if private_segment is not None:
162
+ hits.append((alias.lineno, alias.col_offset + 1, alias.name, private_segment))
163
+ return hits
164
+
165
+
166
+ def _private_segment(module: str) -> str | None:
167
+ """Return the first private component BELOW the top level of a dotted module path.
168
+
169
+ The top-level name is deliberately excluded. `pkg._internals` is a module
170
+ `pkg` chose not to publish and could publish; a top-level package that is
171
+ simply *named* `_infra` — bulbul's shared test-support package — has no
172
+ other spelling and no wider surface to widen, so flagging its import asks
173
+ for an edit that does not exist.
174
+
175
+ Returns:
176
+ The private segment, or None when every component below the top is public.
177
+
178
+ """
179
+ return next((part for part in module.split(".")[1:] if _is_private_name(part)), None)
180
+
181
+
182
+ def _is_private_name(name: str) -> bool:
183
+ # `__version__` / `__all__` are module metadata by convention, not internals.
184
+ return name.startswith("_") and not (name.startswith("__") and name.endswith("__"))
@@ -76,6 +76,8 @@ class NoSequentialAwait(Rule):
76
76
  def check(self, path: Path, source: str) -> list[Diagnostic]:
77
77
  if _is_test_path(path):
78
78
  return []
79
+ if "await" not in source or "for " not in source:
80
+ return []
79
81
  tree = parse_or_none(path, source)
80
82
  if tree is None:
81
83
  return []
@@ -235,6 +235,8 @@ class PreferStrEnum(Rule):
235
235
 
236
236
  @override
237
237
  def check(self, path: Path, source: str) -> list[Diagnostic]:
238
+ if not _has_str_enum_signal(source):
239
+ return []
238
240
  tree = parse_or_none(path, source)
239
241
  if tree is None:
240
242
  return []
@@ -332,6 +334,22 @@ class PreferStrEnum(Rule):
332
334
  return diags
333
335
 
334
336
 
337
+ def _has_str_enum_signal(source: str) -> bool:
338
+ """Cheap source gate for files that cannot contain this rule's triggers.
339
+
340
+ Returns:
341
+ True when the source contains enough lexical signal to justify parsing.
342
+
343
+ """
344
+ has_string_literal = '"' in source or "'" in source
345
+ if "str" in source and any(name in source.lower() for name in CHOICES_ATTR_NAMES):
346
+ return True
347
+ return (
348
+ has_string_literal
349
+ and ("==" in source or "!=" in source or "case " in source or "match " in source)
350
+ )
351
+
352
+
335
353
  def _cluster_fires(key: str, entry: _ClusterEntry) -> bool:
336
354
  _line, _col, literals, eq_literals, ne_literals, in_literals = entry
337
355
  if not eq_literals and not ne_literals:
@@ -110,6 +110,25 @@ _EXCLUDE_RE = re.compile(
110
110
  )
111
111
 
112
112
  _NUMERIC_NAMES = frozenset({"int", "float"})
113
+ _BARE_UNIT_NAMES = frozenset(
114
+ {
115
+ "day",
116
+ "days",
117
+ "hour",
118
+ "hours",
119
+ "minute",
120
+ "minutes",
121
+ "min",
122
+ "mins",
123
+ "second",
124
+ "seconds",
125
+ "sec",
126
+ "secs",
127
+ "millisecond",
128
+ "milliseconds",
129
+ "ms",
130
+ }
131
+ )
113
132
 
114
133
  #: Roots of the CLI frameworks whose decorators bind a parameter to an argv value.
115
134
  _CLI_MODULES = frozenset({"click", "typer"})
@@ -180,6 +199,8 @@ class PreferTimedeltaForDurations(Rule):
180
199
  ) -> None:
181
200
  if annotation is None:
182
201
  return
202
+ if name.lower() in _BARE_UNIT_NAMES or name.lower().endswith(("_worked", "_elapsed")):
203
+ return
183
204
  if not _UNIT_RE.search(name) or _EXCLUDE_RE.search(name):
184
205
  return
185
206
  numeric = _numeric_annotation(annotation)
@@ -115,6 +115,8 @@ class Stepdown(Rule):
115
115
  return []
116
116
  if _is_test_path(path):
117
117
  return []
118
+ if "def _" not in source and "async def _" not in source:
119
+ return []
118
120
  tree = parse_or_none(path, source)
119
121
  if tree is None:
120
122
  return []