pytest-orm-boundaries 0.3.1__tar.gz → 0.5.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 (17) hide show
  1. {pytest_orm_boundaries-0.3.1 → pytest_orm_boundaries-0.5.0}/PKG-INFO +82 -16
  2. pytest_orm_boundaries-0.5.0/README.md +158 -0
  3. {pytest_orm_boundaries-0.3.1 → pytest_orm_boundaries-0.5.0}/pyproject.toml +8 -2
  4. pytest_orm_boundaries-0.3.1/src/pytest_orm_boundaries/call_stack.py → pytest_orm_boundaries-0.5.0/src/pytest_orm_boundaries/callstack.py +1 -1
  5. pytest_orm_boundaries-0.3.1/src/pytest_orm_boundaries/read_config.py → pytest_orm_boundaries-0.5.0/src/pytest_orm_boundaries/config.py +22 -9
  6. pytest_orm_boundaries-0.5.0/src/pytest_orm_boundaries/crossings.py +131 -0
  7. pytest_orm_boundaries-0.5.0/src/pytest_orm_boundaries/guard.py +176 -0
  8. {pytest_orm_boundaries-0.3.1 → pytest_orm_boundaries-0.5.0}/src/pytest_orm_boundaries/ignores.py +1 -1
  9. pytest_orm_boundaries-0.5.0/src/pytest_orm_boundaries/model_resolution.py +29 -0
  10. {pytest_orm_boundaries-0.3.1 → pytest_orm_boundaries-0.5.0}/src/pytest_orm_boundaries/plugin.py +36 -15
  11. pytest_orm_boundaries-0.5.0/src/pytest_orm_boundaries/prefetch_resolution.py +72 -0
  12. pytest_orm_boundaries-0.3.1/src/pytest_orm_boundaries/build_report.py → pytest_orm_boundaries-0.5.0/src/pytest_orm_boundaries/report.py +20 -20
  13. pytest_orm_boundaries-0.5.0/src/pytest_orm_boundaries/sql_parsing.py +58 -0
  14. pytest_orm_boundaries-0.3.1/README.md +0 -95
  15. pytest_orm_boundaries-0.3.1/src/pytest_orm_boundaries/guard.py +0 -197
  16. {pytest_orm_boundaries-0.3.1 → pytest_orm_boundaries-0.5.0}/LICENSE +0 -0
  17. {pytest_orm_boundaries-0.3.1 → pytest_orm_boundaries-0.5.0}/src/pytest_orm_boundaries/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-orm-boundaries
3
- Version: 0.3.1
3
+ Version: 0.5.0
4
4
  Summary: Pytest plugin that fails tests when ORM queries cross DDD aggregate boundaries (Django supported today).
5
5
  Keywords: django,pytest,plugin,orm,ddd,aggregate,boundaries,architecture
6
6
  Author: Evgeniia Chibisova
@@ -17,11 +17,14 @@ Classifier: Programming Language :: Python :: 3.13
17
17
  Classifier: Topic :: Software Development :: Quality Assurance
18
18
  Classifier: Topic :: Software Development :: Testing
19
19
  Requires-Dist: pytest>=8
20
+ Requires-Dist: sqlglot>=30,<31
21
+ Requires-Dist: django>=4.2 ; extra == 'django'
20
22
  Requires-Python: >=3.11
21
23
  Project-URL: Homepage, https://github.com/evchibisova/pytest-orm-boundaries
22
24
  Project-URL: Repository, https://github.com/evchibisova/pytest-orm-boundaries
23
25
  Project-URL: Issues, https://github.com/evchibisova/pytest-orm-boundaries/issues
24
26
  Project-URL: Changelog, https://github.com/evchibisova/pytest-orm-boundaries/blob/main/CHANGELOG.md
27
+ Provides-Extra: django
25
28
  Description-Content-Type: text/markdown
26
29
 
27
30
  # pytest-orm-boundaries
@@ -41,17 +44,19 @@ lookups make it easy to cross those boundaries silently:
41
44
  Purchase.objects.get(client__name="John")
42
45
  ```
43
46
 
44
- `pytest-orm-boundaries` watches the queries your test suite executes and
45
- reports the ones that step outside their aggregate, whether through `__`
46
- lookups, subqueries, or other joins.
47
+ `pytest-orm-boundaries` watches the queries your test suite executes and reports
48
+ the ones that step outside their aggregate through `__` lookups,
49
+ `select_related`, `prefetch_related`, subqueries, or hand-written `.raw()` SQL.
47
50
 
48
51
  ## Install
49
52
 
53
+ Install the plugin with Django support:
54
+
50
55
  ```bash
51
- pip install pytest-orm-boundaries
56
+ pip install "pytest-orm-boundaries[django]"
52
57
  ```
53
58
 
54
- pytest picks the plugin up automatically.
59
+ pytest discovers the plugin automatically.
55
60
 
56
61
  ## Configure
57
62
 
@@ -68,25 +73,75 @@ purchase = ["bookshop.Purchase", "bookshop.PurchaseLine"]
68
73
  Models are written as `app_label.Model`. Models not listed in any aggregate are
69
74
  not checked. Without a config file the plugin emits a warning and runs no checks.
70
75
 
76
+ ## What it catches
77
+
78
+ The plugin flags queries that read across an aggregate boundary. Each example below couples
79
+ the `purchase` and `client` aggregates:
80
+
81
+ - `__` relation lookups:
82
+
83
+ ```python
84
+ Purchase.objects.get(client__name="John")
85
+ ```
86
+
87
+ - `select_related`:
88
+
89
+ ```python
90
+ Purchase.objects.select_related("client")
91
+ ```
92
+
93
+ - Subqueries - a table reached through a subquery still counts:
94
+
95
+ ```python
96
+ berlin_clients = Client.objects.filter(city="Berlin").values("id")
97
+ Purchase.objects.filter(client_id__in=berlin_clients)
98
+ ```
99
+
100
+ - Hand-written `.raw()` SQL:
101
+
102
+ ```python
103
+ Purchase.objects.raw(
104
+ "SELECT p.id FROM bookshop_purchase p "
105
+ "JOIN bookshop_client c ON p.client_id = c.id"
106
+ )
107
+ ```
108
+
109
+ - Bare `cursor.execute()` - the same join reached through a raw cursor.
110
+
111
+ - `prefetch_related`:
112
+
113
+ ```python
114
+ Purchase.objects.prefetch_related("client")
115
+ ```
116
+
117
+ Queries that don't actually join across the boundary are **not** flagged — for
118
+ example a foreign-key lookup by id, which Django resolves without a join:
119
+
120
+ ```python
121
+ Purchase.objects.filter(client_id=42) # reads one table
122
+ Purchase.objects.filter(client__pk=42) # Django trims the join
123
+ ```
124
+
71
125
  ## The report
72
126
 
73
127
  At the end of the run, the plugin prints one grouped entry per offending place:
74
128
 
75
129
  ```
76
- ===================== orm-boundaries: boundary violations ======================
130
+ ====================== orm-boundaries: boundary crossings ======================
77
131
  1 place(s) in your code crossed aggregate boundaries, affecting 1 test(s):
78
132
 
79
- bookshop/purchases.py:29
80
- code: return list(Purchase.objects.filter(client__name=name))
81
- crosses: client, purchase
82
- affected tests (1):
83
- test_purchases.py::test_get_purchases_by_client_name
133
+ bookshop/reports.py:13
134
+ crossed aggregates: client ↔ purchase
135
+ models: bookshop.Client, bookshop.Purchase
136
+ 1 test(s) affected:
137
+ test_purchases.py::test_list_purchases_with_client
84
138
 
85
- orm-boundaries: FAILED - 1 boundary violation(s), run exits non-zero.
139
+ orm-boundaries: FAILED - 1 boundary crossing(s), run exits non-zero.
86
140
  ```
87
141
 
88
- The run exits non-zero when there are violations, so CI catches them.
89
- Pass `-v` to list all affected tests.
142
+ Each entry names the aggregates the query crossed and the models it joined.
143
+ Places are ordered by how many tests they affect. Pass `-v` to see every affected test
144
+ (otherwise the list is capped at 5 per place).
90
145
 
91
146
  ## Ignoring files
92
147
 
@@ -111,11 +166,22 @@ boundary, the plugin says so at the end:
111
166
 
112
167
  ```
113
168
  ======================= orm-boundaries: stale ignores ========================
114
- These [ignore] entries no longer suppress any boundary violation - their files are clean now.
169
+ These [ignore] entries no longer suppress any boundary crossing - their files are clean now.
115
170
  Remove them from boundaries.toml:
116
171
  - app/billing.py
117
172
  ```
118
173
 
174
+ ## A note on Django internals
175
+
176
+ Catching `prefetch_related` relies on Django internals that come with no stability
177
+ promise, so new Django releases may require compatibility updates.
178
+
179
+ ## Known gaps (on the project roadmap)
180
+
181
+ - lazy attribute access (e.g. `purchase.client`);
182
+ - related-manager reads/writes (`client.purchases.all()`, `client.purchases.create(...)`);
183
+ - a direct query on another aggregate's model, e.g. `Client.objects.get(...)` written inside purchase code.
184
+
119
185
  ## Status
120
186
 
121
187
  Alpha - testing basic version.
@@ -0,0 +1,158 @@
1
+ # pytest-orm-boundaries
2
+
3
+ > 💡 **Even if you control your imports — boundaries still can leak through the ORM**
4
+
5
+ A `pytest-orm-boundaries` is a pytest plugin that reports ORM queries crossing your DDD aggregate boundaries.
6
+
7
+ Currently works with Django ORM.
8
+
9
+ In domain-driven design, an aggregate is a consistency boundary: code in one
10
+ aggregate should not reach into the internals of another. Django's `__` relation
11
+ lookups make it easy to cross those boundaries silently:
12
+
13
+ ```python
14
+ # Purchase and Client belong to different aggregates — this query couples them.
15
+ Purchase.objects.get(client__name="John")
16
+ ```
17
+
18
+ `pytest-orm-boundaries` watches the queries your test suite executes and reports
19
+ the ones that step outside their aggregate — through `__` lookups,
20
+ `select_related`, `prefetch_related`, subqueries, or hand-written `.raw()` SQL.
21
+
22
+ ## Install
23
+
24
+ Install the plugin with Django support:
25
+
26
+ ```bash
27
+ pip install "pytest-orm-boundaries[django]"
28
+ ```
29
+
30
+ pytest discovers the plugin automatically.
31
+
32
+ ## Configure
33
+
34
+ Declare your aggregates in `boundaries.toml` at the project root (or point at
35
+ the file with `--boundaries-config` / the `boundaries_config` ini option):
36
+
37
+ ```toml
38
+ [aggregates]
39
+ client = ["bookshop.Client"]
40
+ book = ["bookshop.Book"]
41
+ purchase = ["bookshop.Purchase", "bookshop.PurchaseLine"]
42
+ ```
43
+
44
+ Models are written as `app_label.Model`. Models not listed in any aggregate are
45
+ not checked. Without a config file the plugin emits a warning and runs no checks.
46
+
47
+ ## What it catches
48
+
49
+ The plugin flags queries that read across an aggregate boundary. Each example below couples
50
+ the `purchase` and `client` aggregates:
51
+
52
+ - `__` relation lookups:
53
+
54
+ ```python
55
+ Purchase.objects.get(client__name="John")
56
+ ```
57
+
58
+ - `select_related`:
59
+
60
+ ```python
61
+ Purchase.objects.select_related("client")
62
+ ```
63
+
64
+ - Subqueries - a table reached through a subquery still counts:
65
+
66
+ ```python
67
+ berlin_clients = Client.objects.filter(city="Berlin").values("id")
68
+ Purchase.objects.filter(client_id__in=berlin_clients)
69
+ ```
70
+
71
+ - Hand-written `.raw()` SQL:
72
+
73
+ ```python
74
+ Purchase.objects.raw(
75
+ "SELECT p.id FROM bookshop_purchase p "
76
+ "JOIN bookshop_client c ON p.client_id = c.id"
77
+ )
78
+ ```
79
+
80
+ - Bare `cursor.execute()` - the same join reached through a raw cursor.
81
+
82
+ - `prefetch_related`:
83
+
84
+ ```python
85
+ Purchase.objects.prefetch_related("client")
86
+ ```
87
+
88
+ Queries that don't actually join across the boundary are **not** flagged — for
89
+ example a foreign-key lookup by id, which Django resolves without a join:
90
+
91
+ ```python
92
+ Purchase.objects.filter(client_id=42) # reads one table
93
+ Purchase.objects.filter(client__pk=42) # Django trims the join
94
+ ```
95
+
96
+ ## The report
97
+
98
+ At the end of the run, the plugin prints one grouped entry per offending place:
99
+
100
+ ```
101
+ ====================== orm-boundaries: boundary crossings ======================
102
+ 1 place(s) in your code crossed aggregate boundaries, affecting 1 test(s):
103
+
104
+ bookshop/reports.py:13
105
+ crossed aggregates: client ↔ purchase
106
+ models: bookshop.Client, bookshop.Purchase
107
+ 1 test(s) affected:
108
+ test_purchases.py::test_list_purchases_with_client
109
+
110
+ orm-boundaries: FAILED - 1 boundary crossing(s), run exits non-zero.
111
+ ```
112
+
113
+ Each entry names the aggregates the query crossed and the models it joined.
114
+ Places are ordered by how many tests they affect. Pass `-v` to see every affected test
115
+ (otherwise the list is capped at 5 per place).
116
+
117
+ ## Ignoring files
118
+
119
+ Add exceptions so that known offenders keep passing while you fix them one file at a time:
120
+
121
+ ```toml
122
+ [ignore]
123
+ files = [
124
+ "app/billing.py",
125
+ "app/legacy/*",
126
+ ]
127
+ ```
128
+
129
+ Each entry is a glob ([`fnmatch`](https://docs.python.org/3/library/fnmatch.html),
130
+ resolved relative to pytest's root directory and matched against either:
131
+
132
+ - the file that issues the query, or
133
+ - the test file.
134
+
135
+ If an ignored file runs queries through the whole suite without ever crossing a
136
+ boundary, the plugin says so at the end:
137
+
138
+ ```
139
+ ======================= orm-boundaries: stale ignores ========================
140
+ These [ignore] entries no longer suppress any boundary crossing - their files are clean now.
141
+ Remove them from boundaries.toml:
142
+ - app/billing.py
143
+ ```
144
+
145
+ ## A note on Django internals
146
+
147
+ Catching `prefetch_related` relies on Django internals that come with no stability
148
+ promise, so new Django releases may require compatibility updates.
149
+
150
+ ## Known gaps (on the project roadmap)
151
+
152
+ - lazy attribute access (e.g. `purchase.client`);
153
+ - related-manager reads/writes (`client.purchases.all()`, `client.purchases.create(...)`);
154
+ - a direct query on another aggregate's model, e.g. `Client.objects.get(...)` written inside purchase code.
155
+
156
+ ## Status
157
+
158
+ Alpha - testing basic version.
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "pytest-orm-boundaries"
7
- version = "0.3.1"
7
+ version = "0.5.0"
8
8
  description = "Pytest plugin that fails tests when ORM queries cross DDD aggregate boundaries (Django supported today)."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -33,7 +33,10 @@ classifiers = [
33
33
  "Topic :: Software Development :: Quality Assurance",
34
34
  "Topic :: Software Development :: Testing",
35
35
  ]
36
- dependencies = ["pytest>=8"]
36
+ dependencies = ["pytest>=8", "sqlglot>=30,<31"]
37
+
38
+ [project.optional-dependencies]
39
+ django = ["django>=4.2"]
37
40
 
38
41
  [project.urls]
39
42
  Homepage = "https://github.com/evchibisova/pytest-orm-boundaries"
@@ -44,6 +47,9 @@ Changelog = "https://github.com/evchibisova/pytest-orm-boundaries/blob/main/CHAN
44
47
  [project.entry-points.pytest11]
45
48
  boundaries = "pytest_orm_boundaries.plugin"
46
49
 
50
+ [tool.uv]
51
+ exclude-newer = "2 weeks"
52
+
47
53
  [tool.pytest.ini_options]
48
54
  testpaths = ["tests"]
49
55
 
@@ -15,7 +15,7 @@ def _is_third_party(*, relative: Path) -> bool:
15
15
  return not _THIRD_PARTY_SEGMENTS.isdisjoint(relative.parts)
16
16
 
17
17
 
18
- def find_in_project_frames(*, root: Path) -> list[tuple[str, int]]:
18
+ def find_frames_inside_project(*, root: Path) -> list[tuple[str, int]]:
19
19
  """(root-relative path, line) for each in-project frame, innermost first.
20
20
 
21
21
  Skips stdlib (outside root) and installed packages; frames[0] is the line
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import tomllib
6
+ from dataclasses import dataclass
6
7
  from pathlib import Path
7
8
  from typing import Any
8
9
 
@@ -13,6 +14,14 @@ class BoundariesConfigError(Exception):
13
14
  """Raised when the config file is malformed or semantically invalid."""
14
15
 
15
16
 
17
+ @dataclass(frozen=True)
18
+ class BoundariesConfig:
19
+ """Parsed and validated content of a ``boundaries.toml``."""
20
+
21
+ aggregates_by_model: dict[str, str] # {"app.model" lower-cased: aggregate_name}
22
+ ignored_files: list[str] # glob patterns
23
+
24
+
16
25
  def discover_config_path(*, explicit: str | None, rootpath: Path) -> Path | None:
17
26
  """Resolve the config path.
18
27
 
@@ -29,6 +38,14 @@ def discover_config_path(*, explicit: str | None, rootpath: Path) -> Path | None
29
38
  return default if default.is_file() else None
30
39
 
31
40
 
41
+ def load_config(*, path: Path) -> BoundariesConfig:
42
+ data = _read_config(path=path)
43
+ return BoundariesConfig(
44
+ aggregates_by_model=_parse_aggregates(data=data, path=path),
45
+ ignored_files=_parse_ignored_files(data=data, path=path),
46
+ )
47
+
48
+
32
49
  def _read_config(*, path: Path) -> dict[str, Any]:
33
50
  try:
34
51
  with path.open("rb") as fh:
@@ -37,16 +54,14 @@ def _read_config(*, path: Path) -> dict[str, Any]:
37
54
  raise BoundariesConfigError(f"{path}: invalid TOML ({exc})") from exc
38
55
 
39
56
 
40
- def load_aggregates_from_config(*, path: Path) -> dict[str, str]:
41
- """Parse the config into a {model_label: aggregate_name} map.
57
+ def _parse_aggregates(*, data: dict[str, Any], path: Path) -> dict[str, str]:
58
+ """Build a {model_label: aggregate_name} map from the ``[aggregates]`` table.
42
59
 
43
60
  Model labels are lower-cased ("app_label.modelname") for case-insensitive
44
61
  matching.
45
62
  """
46
- config = _read_config(path=path)
47
-
48
63
  aggregates_by_model: dict[str, str] = {}
49
- for aggregate, members in config.get("aggregates", {}).items():
64
+ for aggregate, members in data.get("aggregates", {}).items():
50
65
  if isinstance(members, dict):
51
66
  members = members.get("models", [])
52
67
  if not isinstance(members, list):
@@ -67,10 +82,8 @@ def load_aggregates_from_config(*, path: Path) -> dict[str, str]:
67
82
  return aggregates_by_model
68
83
 
69
84
 
70
- def load_ignored_files_from_config(*, path: Path) -> list[str]:
71
- """Parse ``[ignore] files`` into a list of glob patterns."""
72
- data = _read_config(path=path)
73
-
85
+ def _parse_ignored_files(*, data: dict[str, Any], path: Path) -> list[str]:
86
+ """Read ``[ignore] files`` into a list of glob patterns."""
74
87
  ignore = data.get("ignore", {})
75
88
  if not isinstance(ignore, dict):
76
89
  raise BoundariesConfigError(f"{path}: [ignore] must be a table")
@@ -0,0 +1,131 @@
1
+ """Track aggregate crossings: apply the rule to models and accumulate the crossings found."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ from pytest_orm_boundaries.callstack import find_frames_inside_project
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Iterable
13
+
14
+ from pytest_orm_boundaries.ignores import IgnoreTracker
15
+
16
+
17
+ @dataclass
18
+ class CrossingRecord:
19
+ """One offending call place and the tests that reached it."""
20
+
21
+ file: str
22
+ line_number: int
23
+ crossed_aggregates: tuple[str, ...] # ("order", "payment")
24
+ involved_models: tuple[str, ...] # ("order.Invoice", "payrolls.IncomePayment")
25
+ tests: set[str] = field(default_factory=set)
26
+
27
+
28
+ class CrossingTracker:
29
+ """Checks label sets against the aggregate rule and records the crossings,
30
+ skipping ignored files and naming each crossing's call place and test."""
31
+
32
+ def __init__(
33
+ self,
34
+ *,
35
+ aggregates_config: dict[str, str],
36
+ ignore_tracker: IgnoreTracker,
37
+ root: Path,
38
+ ) -> None:
39
+ self._aggregates_config = aggregates_config
40
+ self._ignore_tracker = ignore_tracker
41
+ self._root_path = Path(root)
42
+ self._current_test: str | None = None
43
+ self._crossings: dict[tuple[str, int, tuple[str, ...]], CrossingRecord] = {}
44
+
45
+ def set_current_test(self, nodeid: str | None) -> None:
46
+ """Remember which test is running so a recorded crossing can name it."""
47
+ self._current_test = nodeid
48
+
49
+ def check(self, *, label_sets: Iterable[Iterable[str]]) -> None:
50
+ """Record a crossing for each label set that spans aggregates, unless an
51
+ active ignore covers the call place.
52
+ """
53
+ # ``frames`` is the in-project part of the call stack, gathered lazily:
54
+ # for the ignore/stale bookkeeping and to name each crossing's place.
55
+ frames: list[tuple[str, int]] | None = None
56
+ file_paths: set[str] | None = None
57
+ if self._ignore_tracker.is_active:
58
+ frames = find_frames_inside_project(root=self._root_path)
59
+ file_paths = {path for path, _ in frames}
60
+ self._ignore_tracker.mark_seen(file_paths=file_paths)
61
+
62
+ for labels in label_sets:
63
+ crossing = self.find_crossing(labels=labels)
64
+ if crossing is None:
65
+ continue
66
+ if file_paths is not None and self._ignore_tracker.has_ignore_for(
67
+ file_paths=file_paths
68
+ ):
69
+ self._ignore_tracker.mark_used(file_paths=file_paths)
70
+ continue
71
+ if frames is None:
72
+ frames = find_frames_inside_project(root=self._root_path)
73
+ call_place = frames[0] if frames else None
74
+ self.add_record(
75
+ call_place=call_place, crossing=crossing, test=self._current_test
76
+ )
77
+
78
+ def find_crossing(
79
+ self, *, labels: Iterable[str]
80
+ ) -> tuple[tuple[str, ...], tuple[str, ...]] | None:
81
+ """Return ``(crossed aggregate names, involved model labels)`` if the
82
+ labels span more than one aggregate, else None.
83
+
84
+ e.g. ``(("order", "payment"), ("order.Invoice", "payrolls.IncomePayment"))``.
85
+
86
+ ``labels`` are model labels.
87
+ """
88
+ aggregate_by_label = {
89
+ label: aggregate
90
+ for label in labels
91
+ if (aggregate := self._aggregates_config.get(label.lower())) is not None
92
+ }
93
+ aggregates = set(aggregate_by_label.values())
94
+ if len(aggregates) <= 1:
95
+ return None
96
+ return tuple(sorted(aggregates)), tuple(sorted(aggregate_by_label.keys()))
97
+
98
+ def add_record(
99
+ self,
100
+ *,
101
+ call_place: tuple[str, int] | None,
102
+ crossing: tuple[tuple[str, ...], tuple[str, ...]],
103
+ test: str | None,
104
+ ) -> None:
105
+ """Add one crossing, merging into the record for its call place."""
106
+ crossed_aggregates, involved_models = crossing
107
+ file, line = call_place if call_place is not None else ("<unknown>", 0)
108
+ key = (file, line, crossed_aggregates)
109
+ record = self._crossings.get(key)
110
+ if record is None:
111
+ record = CrossingRecord(
112
+ file=file,
113
+ line_number=line,
114
+ crossed_aggregates=crossed_aggregates,
115
+ involved_models=involved_models,
116
+ )
117
+ self._crossings[key] = record
118
+ if test is not None:
119
+ record.tests.add(test)
120
+
121
+ @property
122
+ def crossings(self) -> list[CrossingRecord]:
123
+ """Recorded crossings, most-affecting first."""
124
+ return sorted(
125
+ self._crossings.values(),
126
+ key=lambda v: (-len(v.tests), v.file, v.line_number),
127
+ )
128
+
129
+ def find_stale_patterns(self) -> list[str]:
130
+ """Ignore globs whose file ran but never crossed -- safe to delete."""
131
+ return self._ignore_tracker.find_stale_patterns()