sarj-python-lint 0.71.0__tar.gz → 0.72.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 (114) hide show
  1. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/__main__.py +12 -4
  4. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rule_base.py +12 -0
  5. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_first_party.py +5 -0
  6. sarj_python_lint-0.72.0/src/sarj_python_lint/rules/_project_index.py +366 -0
  7. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_registry.py +12 -0
  8. sarj_python_lint-0.72.0/src/sarj_python_lint/rules/preserve_declared_nominal_id.py +143 -0
  9. sarj_python_lint-0.72.0/src/sarj_python_lint/rules/preserve_enum_types.py +136 -0
  10. sarj_python_lint-0.72.0/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +171 -0
  11. sarj_python_lint-0.72.0/src/sarj_python_lint/rules/require_validated_row_factory.py +159 -0
  12. sarj_python_lint-0.72.0/src/sarj_python_lint/rules/sql_requires_injected_pool_owner.py +207 -0
  13. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/.gitignore +0 -0
  14. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/LICENSE +0 -0
  15. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/README.md +0 -0
  16. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/__init__.py +0 -0
  17. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/_filesystem.py +0 -0
  18. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  19. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/_secret_names.py +0 -0
  20. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/_version.py +0 -0
  21. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/py.typed +0 -0
  22. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/ratchet.py +0 -0
  23. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  24. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  25. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  26. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  27. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  28. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_imports.py +0 -0
  29. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  30. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  31. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  32. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  33. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  34. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  35. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  36. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/created_at_order_requires_tiebreaker.py +0 -0
  37. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/defect_xfail_requires_strict.py +0 -0
  38. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  39. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  40. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
  41. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -0
  42. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/fastapi_openapi_contract.py +0 -0
  43. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  44. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  45. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  46. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  47. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  48. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  49. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  50. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  51. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  52. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  53. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
  54. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  55. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  56. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  57. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
  58. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  59. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  60. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  61. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_long_comment.py +0 -0
  62. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  63. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
  64. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  65. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  66. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  67. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  68. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  69. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  70. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
  71. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  72. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  73. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  74. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  75. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  76. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  77. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  78. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  79. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  80. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  81. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  82. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  83. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  84. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  85. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  86. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  87. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  88. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  89. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  90. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  91. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  92. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  93. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  94. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  95. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  96. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  97. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  98. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  99. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  100. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  101. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  102. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  103. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  104. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  105. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  106. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  107. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  108. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/source_coupled_test.py +0 -0
  109. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  110. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  111. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  112. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
  113. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  114. {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sarj-python-lint
3
- Version: 0.71.0
3
+ Version: 0.72.0
4
4
  Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
5
5
  Project-URL: Homepage, https://code-standards.sarj.ai/rules/python/
6
6
  Project-URL: Documentation, https://code-standards.sarj.ai/rules/python/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.71.0"
3
+ version = "0.72.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" }]
@@ -13,9 +13,10 @@ from typing import NamedTuple
13
13
 
14
14
  from sarj_python_lint import __version__
15
15
  from sarj_python_lint._filesystem import atomic_write_text
16
- from sarj_python_lint.rule_base import Diagnostic, Severity, is_suppressed
16
+ from sarj_python_lint.rule_base import Diagnostic, ProjectRule, Severity, is_suppressed
17
17
  from sarj_python_lint.rules import REGISTRY
18
18
  from sarj_python_lint.rules._paths import clear_path_caches
19
+ from sarj_python_lint.rules._project_index import ProjectIndexSet
19
20
 
20
21
 
21
22
  SKIP_DIR_NAMES = frozenset(
@@ -80,12 +81,19 @@ def _check(rule_ids: list[str], paths: list[Path]) -> list[Diagnostic]:
80
81
  rules = [REGISTRY[rid]() for rid in rule_ids]
81
82
  clear_path_caches()
82
83
  expanded = _expand_paths(paths)
83
- diags: list[Diagnostic] = []
84
- for p in expanded:
84
+ loaded: dict[Path, str] = {}
85
+ for path in expanded:
85
86
  try:
86
- source = p.read_text(encoding="utf-8", errors="replace")
87
+ loaded[path] = path.read_text(encoding="utf-8", errors="replace")
87
88
  except OSError:
88
89
  continue
90
+ project_rules = [rule for rule in rules if isinstance(rule, ProjectRule)]
91
+ if project_rules:
92
+ indexes = ProjectIndexSet.build(expanded, loaded)
93
+ for rule in project_rules:
94
+ rule.prepare(indexes)
95
+ diags: list[Diagnostic] = []
96
+ for p, source in loaded.items():
89
97
  source_lines = source.splitlines()
90
98
  raw = [diagnostic for rule in rules for diagnostic in rule.check(p, source)]
91
99
  diags.extend(
@@ -14,6 +14,8 @@ from typing import TYPE_CHECKING, ClassVar, Final, Self
14
14
  if TYPE_CHECKING:
15
15
  from collections.abc import Sequence
16
16
 
17
+ from sarj_python_lint.rules._project_index import ProjectIndexSet
18
+
17
19
 
18
20
  # Each rule points directly to its executable examples.
19
21
  REPO_BLOB: Final = "https://github.com/sarj-ai/standards/blob/main"
@@ -309,6 +311,16 @@ class Rule(ABC):
309
311
  return () if spec is None else spec.public_examples
310
312
 
311
313
 
314
+ class ProjectRule(Rule):
315
+ """A rule that may resolve first-party symbols prepared once per CLI run."""
316
+
317
+ _project_indexes: ProjectIndexSet | None = None
318
+
319
+ def prepare(self, indexes: ProjectIndexSet) -> None:
320
+ """Attach immutable project symbols before checking the selected files."""
321
+ self._project_indexes = indexes
322
+
323
+
312
324
  _last_parse: tuple[str, str, ast.Module | None] | None = None
313
325
 
314
326
 
@@ -126,6 +126,11 @@ def has_first_party_source(module: str, path: Path) -> bool:
126
126
  )
127
127
 
128
128
 
129
+ def project_root(path: Path) -> Path | None:
130
+ """Return the conservative repository/workspace root used by project-aware rules."""
131
+ return _project_root(path)
132
+
133
+
129
134
  def _declares_module(package_dir: Path, segments: list[str]) -> bool:
130
135
  target = package_dir.joinpath(*segments)
131
136
  try:
@@ -0,0 +1,366 @@
1
+ """Bounded, run-scoped first-party symbol index for project-aware rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from collections.abc import Mapping
7
+ from contextlib import suppress
8
+ from dataclasses import dataclass
9
+ import os
10
+ from pathlib import Path
11
+ import re
12
+ from types import MappingProxyType
13
+ from typing import TYPE_CHECKING, final
14
+
15
+ from sarj_python_lint.rules._first_party import project_root
16
+
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Iterator, Sequence
20
+ from typing import Self
21
+
22
+
23
+ _SKIP_DIRS = frozenset(
24
+ {
25
+ ".git",
26
+ ".mypy_cache",
27
+ ".pytest_cache",
28
+ ".ruff_cache",
29
+ ".tox",
30
+ ".venv",
31
+ "__pycache__",
32
+ "build",
33
+ "dist",
34
+ "node_modules",
35
+ "site-packages",
36
+ "vendor",
37
+ "vendored",
38
+ "venv",
39
+ }
40
+ )
41
+ _MAX_ROOTS = 8
42
+ _MAX_DIRS_PER_ROOT = 3_000
43
+ _MAX_FILES_PER_ROOT = 10_000
44
+ _MAX_FILE_BYTES = 500_000
45
+ _NEW_TYPE_MIN_ARGS = 2
46
+ _MATCH_CLASS_RE: re.Pattern[str] = re.compile(r"\bcase\s+([A-Z][A-Za-z0-9_]*)\s*\(")
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class SymbolRef:
51
+ module: str
52
+ name: str
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class ClassSummary:
57
+ symbol: SymbolRef
58
+ fields: Mapping[str, ast.expr]
59
+ is_enum: bool
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class SourceUnit:
64
+ path: Path
65
+ module: str | None
66
+ source: str
67
+ tree: ast.Module | None
68
+ imports: Mapping[str, SymbolRef]
69
+
70
+
71
+ @dataclass(frozen=True, slots=True)
72
+ class LoadedSource:
73
+ path: Path
74
+ source: str
75
+
76
+
77
+ @final
78
+ class ProjectIndexSet:
79
+ """Immutable summaries for every bounded first-party root in one lint run."""
80
+
81
+ def __init__(self, units: Mapping[Path, SourceUnit]) -> None:
82
+ self._units = MappingProxyType(dict(units))
83
+ by_module = {unit.module: unit for unit in units.values() if unit.module is not None}
84
+ self._by_module = MappingProxyType(by_module)
85
+ classes: dict[SymbolRef, ClassSummary] = {}
86
+ nominals: dict[str, set[SymbolRef]] = {}
87
+ for unit in units.values():
88
+ if unit.module is None or unit.tree is None:
89
+ continue
90
+ for statement in unit.tree.body:
91
+ if isinstance(statement, ast.ClassDef):
92
+ symbol = SymbolRef(unit.module, statement.name)
93
+ fields = {
94
+ item.target.id: item.annotation
95
+ for item in statement.body
96
+ if isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name)
97
+ }
98
+ classes[symbol] = ClassSummary(
99
+ symbol=symbol,
100
+ fields=MappingProxyType(fields),
101
+ is_enum=any(_tail(base) in {"Enum", "IntEnum", "StrEnum"} for base in statement.bases),
102
+ )
103
+ nominal = _new_type(statement, unit.module)
104
+ if nominal is not None:
105
+ nominals.setdefault(_field_key(nominal.name), set()).add(nominal)
106
+ self._classes = MappingProxyType(classes)
107
+ self._nominals = MappingProxyType({key: frozenset(value) for key, value in nominals.items()})
108
+
109
+ @classmethod
110
+ def build(cls, paths: Sequence[Path], loaded: Mapping[Path, str]) -> Self:
111
+ roots = _project_roots(paths)
112
+ sources: dict[Path, str] = {}
113
+ for path, source in loaded.items():
114
+ try:
115
+ sources[path.resolve()] = source
116
+ except OSError:
117
+ continue
118
+ for root in roots:
119
+ count = 0
120
+ for path in _python_files(root):
121
+ if count >= _MAX_FILES_PER_ROOT:
122
+ break
123
+ try:
124
+ if path.resolve() in sources:
125
+ count += 1
126
+ continue
127
+ except OSError:
128
+ continue
129
+ loaded_source = _read_bounded_source(root, path)
130
+ if loaded_source is None:
131
+ continue
132
+ sources.setdefault(loaded_source.path, loaded_source.source)
133
+ count += 1
134
+ return cls(_units(sources, roots))
135
+
136
+ @classmethod
137
+ def single(cls, path: Path, source: str) -> Self:
138
+ return cls(_units({path: source}))
139
+
140
+ def unit(self, path: Path) -> SourceUnit | None:
141
+ direct = self._units.get(path)
142
+ if direct is not None:
143
+ return direct
144
+ try:
145
+ return self._units.get(path.resolve())
146
+ except OSError:
147
+ return None
148
+
149
+ def nominal_for_field(self, name: str) -> SymbolRef | None:
150
+ matches = self._nominals.get(name)
151
+ return next(iter(matches)) if matches is not None and len(matches) == 1 else None
152
+
153
+ @staticmethod
154
+ def resolve(unit: SourceUnit, expression: ast.expr) -> SymbolRef | None:
155
+ if unit.module is None:
156
+ return None
157
+ if isinstance(expression, ast.Name):
158
+ return unit.imports.get(expression.id) or SymbolRef(unit.module, expression.id)
159
+ if isinstance(expression, ast.Attribute) and isinstance(expression.value, ast.Name):
160
+ root = unit.imports.get(expression.value.id)
161
+ if root is not None:
162
+ return SymbolRef(root.module, expression.attr) if not root.name else None
163
+ return None
164
+
165
+ def class_for(self, unit: SourceUnit, expression: ast.expr) -> ClassSummary | None:
166
+ symbol = self.resolve(unit, expression)
167
+ return self._classes.get(symbol) if symbol is not None else None
168
+
169
+ def annotation_contains_enum(self, unit: SourceUnit, annotation: ast.expr) -> bool:
170
+ for member in ast.walk(annotation):
171
+ if not isinstance(member, (ast.Name, ast.Attribute)):
172
+ continue
173
+ symbol = self.resolve(unit, member)
174
+ summary = self._classes.get(symbol) if symbol is not None else None
175
+ if summary is not None and summary.is_enum:
176
+ return True
177
+ return False
178
+
179
+ def source_unit(self, module: str) -> SourceUnit | None:
180
+ return self._by_module.get(module)
181
+
182
+
183
+ def _units(sources: Mapping[Path, str], roots: Sequence[Path] = ()) -> dict[Path, SourceUnit]:
184
+ matched_classes = {
185
+ match.group(1)
186
+ for source in sources.values()
187
+ if "match " in source and "str(" in source
188
+ for match in _MATCH_CLASS_RE.finditer(source)
189
+ }
190
+ parsed: dict[Path, tuple[str | None, str, ast.Module | None]] = {}
191
+ for path, source in sources.items():
192
+ if not _is_index_candidate(source, matched_classes):
193
+ continue
194
+ tree: ast.Module | None = None
195
+ with suppress(SyntaxError):
196
+ tree = ast.parse(source, filename=str(path))
197
+ parsed[path] = (_module_name(path, roots), source, tree)
198
+ return {
199
+ path: SourceUnit(
200
+ path=path,
201
+ module=module,
202
+ source=source,
203
+ tree=tree,
204
+ imports=MappingProxyType(_imports(module, tree, is_package=path.name == "__init__.py")),
205
+ )
206
+ for path, (module, source, tree) in parsed.items()
207
+ }
208
+
209
+
210
+ def _is_index_candidate(source: str, matched_classes: set[str]) -> bool:
211
+ return (
212
+ "NewType(" in source
213
+ or ("class " in source and any(marker in source for marker in ("Enum", "IntEnum", "StrEnum")))
214
+ or ("match " in source and "str(" in source)
215
+ or any(f"class {name}" in source for name in matched_classes)
216
+ )
217
+
218
+
219
+ def _module_name(path: Path, roots: Sequence[Path]) -> str | None:
220
+ resolved = path.resolve()
221
+ root = next((candidate for candidate in roots if resolved == candidate or candidate in resolved.parents), None)
222
+ if root is not None:
223
+ package_dir: Path | None = None
224
+ for ancestor in resolved.parents:
225
+ if ancestor == root:
226
+ break
227
+ try:
228
+ if (ancestor / "__init__.py").is_file():
229
+ package_dir = ancestor
230
+ except OSError:
231
+ return None
232
+ if package_dir is not None:
233
+ relative = resolved.relative_to(package_dir)
234
+ suffix = relative.parts[:-1] if relative.name == "__init__.py" else (*relative.parts[:-1], relative.stem)
235
+ return ".".join((package_dir.name, *suffix))
236
+ parts: list[str] = []
237
+ parent = path.parent
238
+ try:
239
+ while (parent / "__init__.py").is_file():
240
+ parts.append(parent.name)
241
+ parent = parent.parent
242
+ except OSError:
243
+ return None
244
+ if not parts:
245
+ if path.is_absolute():
246
+ return None
247
+ relative = [part for part in path.parts if part not in {".", ".."}]
248
+ if not relative:
249
+ return None
250
+ if path.name == "__init__.py":
251
+ return ".".join(relative[:-1]) or None
252
+ return ".".join([*relative[:-1], path.stem])
253
+ parts.reverse()
254
+ if path.name != "__init__.py":
255
+ parts.append(path.stem)
256
+ return ".".join(parts)
257
+
258
+
259
+ def _project_roots(paths: Sequence[Path]) -> tuple[Path, ...]:
260
+ roots: list[Path] = []
261
+ for path in paths:
262
+ try:
263
+ resolved = path.resolve()
264
+ except OSError:
265
+ continue
266
+ if any(resolved == root or root in resolved.parents for root in roots):
267
+ continue
268
+ root = project_root(path)
269
+ if root is not None and root not in roots:
270
+ roots.append(root)
271
+ if len(roots) >= _MAX_ROOTS:
272
+ break
273
+ return tuple(sorted(roots))
274
+
275
+
276
+ def _python_files(root: Path) -> Iterator[Path]:
277
+ for scanned, (directory, dir_names, file_names) in enumerate(os.walk(root), start=1):
278
+ if scanned > _MAX_DIRS_PER_ROOT:
279
+ return
280
+ parent = Path(directory)
281
+ dir_names[:] = [
282
+ name
283
+ for name in sorted(dir_names)
284
+ if not name.startswith(".") and name not in _SKIP_DIRS and not (parent / name / ".git").exists()
285
+ ]
286
+ for name in sorted(file_names):
287
+ if name.endswith(".py"):
288
+ yield parent / name
289
+
290
+
291
+ def _imports(module: str | None, tree: ast.Module | None, *, is_package: bool) -> dict[str, SymbolRef]:
292
+ if module is None or tree is None:
293
+ return {}
294
+ result: dict[str, SymbolRef] = {}
295
+ package = module if is_package else module.rpartition(".")[0]
296
+ for node in tree.body:
297
+ if isinstance(node, ast.ImportFrom) and not any(alias.name == "*" for alias in node.names):
298
+ target = _relative_module(package, node.level, node.module)
299
+ if target is None:
300
+ continue
301
+ for alias in node.names:
302
+ result[alias.asname or alias.name] = SymbolRef(target, alias.name)
303
+ elif isinstance(node, ast.Import):
304
+ for alias in node.names:
305
+ if alias.asname:
306
+ result[alias.asname] = SymbolRef(alias.name, "")
307
+ return result
308
+
309
+
310
+ def _relative_module(package: str, level: int, module: str | None) -> str | None:
311
+ if level == 0:
312
+ return module
313
+ parts = package.split(".") if package else []
314
+ if level > len(parts) + 1:
315
+ return None
316
+ base = parts[: len(parts) - level + 1]
317
+ if module:
318
+ base.extend(module.split("."))
319
+ return ".".join(base) if base else None
320
+
321
+
322
+ def _new_type(statement: ast.stmt, module: str) -> SymbolRef | None:
323
+ target: ast.Name | None = None
324
+ value: ast.expr | None = None
325
+ if isinstance(statement, ast.Assign) and len(statement.targets) == 1 and isinstance(statement.targets[0], ast.Name):
326
+ target, value = statement.targets[0], statement.value
327
+ elif isinstance(statement, ast.AnnAssign) and isinstance(statement.target, ast.Name):
328
+ target, value = statement.target, statement.value
329
+ if (
330
+ target is None
331
+ or not isinstance(value, ast.Call)
332
+ or _tail(value.func) != "NewType"
333
+ or len(value.args) < _NEW_TYPE_MIN_ARGS
334
+ ):
335
+ return None
336
+ declared = value.args[0]
337
+ carrier = value.args[1]
338
+ if not (
339
+ isinstance(declared, ast.Constant) and declared.value == target.id and _tail(carrier) in {"UUID", "int", "str"}
340
+ ):
341
+ return None
342
+ return SymbolRef(module, target.id)
343
+
344
+
345
+ def _field_key(type_name: str) -> str:
346
+ first = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", type_name)
347
+ return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", first).lower()
348
+
349
+
350
+ def _tail(node: ast.expr) -> str:
351
+ if isinstance(node, ast.Name):
352
+ return node.id
353
+ if isinstance(node, ast.Attribute):
354
+ return node.attr
355
+ return ""
356
+
357
+
358
+ def _read_bounded_source(root: Path, path: Path) -> LoadedSource | None:
359
+ try:
360
+ if path.is_symlink() or not path.is_file() or path.stat().st_size > _MAX_FILE_BYTES:
361
+ return None
362
+ resolved = path.resolve()
363
+ resolved.relative_to(root.resolve())
364
+ return LoadedSource(resolved, resolved.read_text(encoding="utf-8", errors="replace"))
365
+ except OSError, ValueError:
366
+ return None
@@ -109,6 +109,8 @@ from sarj_python_lint.rules.prefer_walrus_comprehension_filter import (
109
109
  )
110
110
  from sarj_python_lint.rules.prefer_walrus_regex_match import PreferWalrusRegexMatch
111
111
  from sarj_python_lint.rules.prefer_walrus_stream_loop import PreferWalrusStreamLoop
112
+ from sarj_python_lint.rules.preserve_declared_nominal_id import PreserveDeclaredNominalId
113
+ from sarj_python_lint.rules.preserve_enum_types import PreserveEnumTypes
112
114
  from sarj_python_lint.rules.production_derived_test_cases import ProductionDerivedTestCases
113
115
  from sarj_python_lint.rules.pydantic_at_boundaries import PydanticAtBoundaries
114
116
  from sarj_python_lint.rules.redundant_class_docstring import RedundantClassDocstring
@@ -122,8 +124,13 @@ from sarj_python_lint.rules.require_port_for_service import RequirePortForServic
122
124
  from sarj_python_lint.rules.require_pydantic_for_external_json import (
123
125
  RequirePydanticForExternalJson,
124
126
  )
127
+ from sarj_python_lint.rules.require_pydantic_ordinal_lower_bound import (
128
+ RequirePydanticOrdinalLowerBound,
129
+ )
130
+ from sarj_python_lint.rules.require_validated_row_factory import RequireValidatedRowFactory
125
131
  from sarj_python_lint.rules.restated_test_docstring import RestatedTestDocstring
126
132
  from sarj_python_lint.rules.source_coupled_test import SourceCoupledTest
133
+ from sarj_python_lint.rules.sql_requires_injected_pool_owner import SqlRequiresInjectedPoolOwner
127
134
  from sarj_python_lint.rules.stepdown import Stepdown
128
135
  from sarj_python_lint.rules.store_insert_requires_on_conflict import (
129
136
  StoreInsertRequiresOnConflict,
@@ -220,6 +227,11 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
220
227
  ProductionDerivedTestCases.id: ProductionDerivedTestCases,
221
228
  UncontrolledRandomnessInTest.id: UncontrolledRandomnessInTest,
222
229
  RepeatedStaticCallCases.id: RepeatedStaticCallCases,
230
+ RequireValidatedRowFactory.id: RequireValidatedRowFactory,
231
+ SqlRequiresInjectedPoolOwner.id: SqlRequiresInjectedPoolOwner,
232
+ PreserveDeclaredNominalId.id: PreserveDeclaredNominalId,
233
+ PreserveEnumTypes.id: PreserveEnumTypes,
234
+ RequirePydanticOrdinalLowerBound.id: RequirePydanticOrdinalLowerBound,
223
235
  }
224
236
  )
225
237
 
@@ -0,0 +1,143 @@
1
+ """SARJ416 preserves project-declared nominal identifier roles.
2
+
3
+ Examples: https://github.com/sarj-ai/standards/blob/main/packages/python/tests/rules/test_preserve_declared_nominal_id.py
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import ast
9
+ from pathlib import PurePosixPath
10
+ from typing import TYPE_CHECKING, ClassVar, final, override
11
+
12
+ from sarj_python_lint.rule_base import (
13
+ AutofixPolicy,
14
+ Diagnostic,
15
+ ExampleFile,
16
+ ExampleOutcome,
17
+ ProjectRule,
18
+ RuleCategory,
19
+ RuleDocumentation,
20
+ RuleExample,
21
+ Severity,
22
+ parse_or_none,
23
+ )
24
+ from sarj_python_lint.rules._paths import is_generated, is_test_path
25
+ from sarj_python_lint.rules._project_index import ProjectIndexSet
26
+
27
+
28
+ if TYPE_CHECKING:
29
+ from pathlib import Path
30
+
31
+
32
+ @final
33
+ class PreserveDeclaredNominalId(ProjectRule):
34
+ id = "preserve-declared-nominal-id"
35
+ code = "SARJ416"
36
+ documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
37
+ summary="Keep project-declared nominal identifier types in test overrides.",
38
+ rationale="A fake override that widens a declared ID role back to its primitive carrier defeats type-checker swap protection.",
39
+ remediation="Import and propagate the matching project `NewType` instead of annotating the role with its carrier.",
40
+ category=RuleCategory.CORRECTNESS,
41
+ autofix=AutofixPolicy.NONE,
42
+ limitations=(
43
+ "The field name must map exactly and unambiguously to a first-party NewType declaration.",
44
+ "Only explicit override methods in test files are inspected; production boundary discovery remains SARJ093's responsibility.",
45
+ ),
46
+ examples=(
47
+ RuleExample(
48
+ example_id="declared-id-erased",
49
+ title="A declared nominal ID is widened to string",
50
+ outcome=ExampleOutcome.MATCH,
51
+ files=(
52
+ ExampleFile.python(
53
+ "tests/fake.py",
54
+ "from typing import NewType, override\nSipTrunkId = NewType('SipTrunkId', str)\nclass Fake:\n @override\n def route(self, sip_trunk_id: str): ...\n",
55
+ ),
56
+ ),
57
+ focus_path=PurePosixPath("tests/fake.py"),
58
+ expected_count=1,
59
+ public=True,
60
+ ),
61
+ RuleExample(
62
+ example_id="declared-id-preserved",
63
+ title="A declared nominal ID remains nominal",
64
+ outcome=ExampleOutcome.NO_MATCH,
65
+ files=(
66
+ ExampleFile.python(
67
+ "tests/fake.py",
68
+ "from typing import NewType, override\nSipTrunkId = NewType('SipTrunkId', str)\nclass Fake:\n @override\n def route(self, sip_trunk_id: SipTrunkId): ...\n",
69
+ ),
70
+ ),
71
+ focus_path=PurePosixPath("tests/fake.py"),
72
+ expected_count=0,
73
+ public=True,
74
+ ),
75
+ ),
76
+ )
77
+ description = documentation.summary
78
+
79
+ @override
80
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
81
+ if (
82
+ ("@override" not in source and ".override" not in source)
83
+ or is_generated(path, source)
84
+ or not is_test_path(path)
85
+ or "migrations" in {part.lower() for part in path.parts}
86
+ ):
87
+ return []
88
+ tree = parse_or_none(path, source)
89
+ if tree is None:
90
+ return []
91
+ indexes = self._project_indexes or ProjectIndexSet.single(path, source)
92
+ diagnostics: list[Diagnostic] = []
93
+ for node in ast.walk(tree):
94
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
95
+ if not any(_tail(item) == "override" for item in node.decorator_list):
96
+ continue
97
+ for argument in [*node.args.posonlyargs, *node.args.args, *node.args.kwonlyargs]:
98
+ diagnostics.extend(_diagnose(path, argument.arg, argument.annotation, indexes, self.code))
99
+ return sorted(diagnostics, key=lambda item: (item.line, item.col))
100
+
101
+
102
+ def _diagnose(
103
+ path: Path,
104
+ name: str,
105
+ annotation: ast.expr | None,
106
+ indexes: ProjectIndexSet,
107
+ code: str,
108
+ ) -> list[Diagnostic]:
109
+ nominal = indexes.nominal_for_field(name)
110
+ if nominal is None or annotation is None or not _raw_primitive(annotation):
111
+ return []
112
+ return [
113
+ Diagnostic(
114
+ path=path,
115
+ line=annotation.lineno,
116
+ col=annotation.col_offset + 1,
117
+ code=code,
118
+ severity=Severity.WARNING,
119
+ message=f"`{name}` erases declared nominal `{nominal.name}`; propagate the nominal ID type through this boundary",
120
+ )
121
+ ]
122
+
123
+
124
+ def _raw_primitive(node: ast.expr | None) -> bool:
125
+ match node:
126
+ case ast.Name(id=name):
127
+ return name in {"UUID", "int", "str"}
128
+ case ast.BinOp(left=left, op=ast.BitOr(), right=right):
129
+ return _raw_primitive(left) or _raw_primitive(right)
130
+ case ast.Subscript(slice=member):
131
+ return _raw_primitive(member)
132
+ case ast.Tuple(elts=members):
133
+ return any(_raw_primitive(item) for item in members)
134
+ case _:
135
+ return False
136
+
137
+
138
+ def _tail(node: ast.expr) -> str:
139
+ if isinstance(node, ast.Name):
140
+ return node.id
141
+ if isinstance(node, ast.Attribute):
142
+ return node.attr
143
+ return ""