pytest-orm-boundaries 0.5.0__tar.gz → 0.7.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 (16) hide show
  1. {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.7.0}/PKG-INFO +40 -20
  2. {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.7.0}/README.md +39 -19
  3. {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.7.0}/pyproject.toml +1 -1
  4. pytest_orm_boundaries-0.7.0/src/pytest_orm_boundaries/allows.py +26 -0
  5. {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.7.0}/src/pytest_orm_boundaries/config.py +48 -14
  6. {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.7.0}/src/pytest_orm_boundaries/crossings.py +18 -10
  7. {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.7.0}/src/pytest_orm_boundaries/guard.py +3 -0
  8. {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.7.0}/src/pytest_orm_boundaries/plugin.py +5 -2
  9. {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.7.0}/src/pytest_orm_boundaries/report.py +2 -2
  10. {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.7.0}/LICENSE +0 -0
  11. {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.7.0}/src/pytest_orm_boundaries/__init__.py +0 -0
  12. {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.7.0}/src/pytest_orm_boundaries/callstack.py +0 -0
  13. {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.7.0}/src/pytest_orm_boundaries/ignores.py +0 -0
  14. {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.7.0}/src/pytest_orm_boundaries/model_resolution.py +0 -0
  15. {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.7.0}/src/pytest_orm_boundaries/prefetch_resolution.py +0 -0
  16. {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.7.0}/src/pytest_orm_boundaries/sql_parsing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-orm-boundaries
3
- Version: 0.5.0
3
+ Version: 0.7.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
@@ -29,11 +29,11 @@ Description-Content-Type: text/markdown
29
29
 
30
30
  # pytest-orm-boundaries
31
31
 
32
- > 💡 **Even if you control your imports boundaries still can leak through the ORM**
32
+ 💡 **Even if you control your imports, boundaries can still leak through the ORM.**
33
33
 
34
- A `pytest-orm-boundaries` is a pytest plugin that reports ORM queries crossing your DDD aggregate boundaries.
34
+ `pytest-orm-boundaries` is a pytest plugin that reports ORM queries crossing your DDD aggregate boundaries.
35
35
 
36
- Currently works with Django ORM.
36
+ Currently works with Django ORM, SQLAlchemy is in roadmap.
37
37
 
38
38
  In domain-driven design, an aggregate is a consistency boundary: code in one
39
39
  aggregate should not reach into the internals of another. Django's `__` relation
@@ -44,8 +44,8 @@ lookups make it easy to cross those boundaries silently:
44
44
  Purchase.objects.get(client__name="John")
45
45
  ```
46
46
 
47
- `pytest-orm-boundaries` watches the queries your test suite executes and reports
48
- the ones that step outside their aggregate through `__` lookups,
47
+ `pytest-orm-boundaries` watches the ORM activity exercised by your test suite
48
+ and reports access that crosses a configured boundary - through `__` lookups,
49
49
  `select_related`, `prefetch_related`, subqueries, or hand-written `.raw()` SQL.
50
50
 
51
51
  ## Install
@@ -60,14 +60,19 @@ pytest discovers the plugin automatically.
60
60
 
61
61
  ## Configure
62
62
 
63
- Declare your aggregates in `boundaries.toml` at the project root (or point at
64
- the file with `--boundaries-config` / the `boundaries_config` ini option):
63
+ Declare your aggregates and their Django models in `boundaries.toml` at the project
64
+ root (or point at the file with `--boundaries-config` / the
65
+ `boundaries_config` ini option):
65
66
 
66
67
  ```toml
67
- [aggregates]
68
- client = ["bookshop.Client"]
69
- book = ["bookshop.Book"]
70
- purchase = ["bookshop.Purchase", "bookshop.PurchaseLine"]
68
+ [aggregates.client]
69
+ models = ["bookshop.Client"]
70
+
71
+ [aggregates.book]
72
+ models = ["bookshop.Book"]
73
+
74
+ [aggregates.purchase]
75
+ models = ["bookshop.Purchase", "bookshop.PurchaseLine"]
71
76
  ```
72
77
 
73
78
  Models are written as `app_label.Model`. Models not listed in any aggregate are
@@ -75,8 +80,9 @@ not checked. Without a config file the plugin emits a warning and runs no checks
75
80
 
76
81
  ## What it catches
77
82
 
78
- The plugin flags queries that read across an aggregate boundary. Each example below couples
79
- the `purchase` and `client` aggregates:
83
+ The plugin flags executed ORM access that spans more than one configured group.
84
+ In the DDD example below, each crossing couples the `purchase` and `client`
85
+ aggregates:
80
86
 
81
87
  - `__` relation lookups:
82
88
 
@@ -114,7 +120,7 @@ the `purchase` and `client` aggregates:
114
120
  Purchase.objects.prefetch_related("client")
115
121
  ```
116
122
 
117
- Queries that don't actually join across the boundary are **not** flagged for
123
+ Queries that don't actually join across the boundary are **not** flagged - for
118
124
  example a foreign-key lookup by id, which Django resolves without a join:
119
125
 
120
126
  ```python
@@ -143,11 +149,22 @@ Each entry names the aggregates the query crossed and the models it joined.
143
149
  Places are ordered by how many tests they affect. Pass `-v` to see every affected test
144
150
  (otherwise the list is capped at 5 per place).
145
151
 
146
- ## Ignoring files
152
+ ## Allow and ignore
147
153
 
148
- Add exceptions so that known offenders keep passing while you fix them one file at a time:
154
+ CQRS read models may cross boundaries intentionally, also existing application code may contain crossings you want to fix over time. [allow] and [ignore] let you tell the plugin which is which:
155
+
156
+ - `[allow]` - the crossing is **intentional**. Use it for code that is meant to
157
+ span aggregates, such as CQRS read models or cross-aggregate reports. An
158
+ allowed crossing is suppressed and never reported.
159
+ - `[ignore]` - the crossing is **known debt** you plan to fix. It is suppressed
160
+ for now, and the plugin reminds you when an entry is no longer needed.
149
161
 
150
162
  ```toml
163
+ [allow]
164
+ files = [
165
+ "app/reports/sales_summary.py",
166
+ ]
167
+
151
168
  [ignore]
152
169
  files = [
153
170
  "app/billing.py",
@@ -155,14 +172,14 @@ files = [
155
172
  ]
156
173
  ```
157
174
 
158
- Each entry is a glob ([`fnmatch`](https://docs.python.org/3/library/fnmatch.html),
175
+ Each entry is a glob ([`fnmatch`](https://docs.python.org/3/library/fnmatch.html)),
159
176
  resolved relative to pytest's root directory and matched against either:
160
177
 
161
178
  - the file that issues the query, or
162
179
  - the test file.
163
180
 
164
- If an ignored file runs queries through the whole suite without ever crossing a
165
- boundary, the plugin says so at the end:
181
+ An `[ignore]` whose file runs through the whole suite without ever crossing a
182
+ boundary is stale — it is clean now, so the plugin lists it for removal:
166
183
 
167
184
  ```
168
185
  ======================= orm-boundaries: stale ignores ========================
@@ -171,6 +188,9 @@ Remove them from boundaries.toml:
171
188
  - app/billing.py
172
189
  ```
173
190
 
191
+ `[allow]` entries are never reported this way. If a file sits in both sections,
192
+ the allow wins and its `[ignore]` entry shows up as stale to remove.
193
+
174
194
  ## A note on Django internals
175
195
 
176
196
  Catching `prefetch_related` relies on Django internals that come with no stability
@@ -1,10 +1,10 @@
1
1
  # pytest-orm-boundaries
2
2
 
3
- > 💡 **Even if you control your imports boundaries still can leak through the ORM**
3
+ 💡 **Even if you control your imports, boundaries can still leak through the ORM.**
4
4
 
5
- A `pytest-orm-boundaries` is a pytest plugin that reports ORM queries crossing your DDD aggregate boundaries.
5
+ `pytest-orm-boundaries` is a pytest plugin that reports ORM queries crossing your DDD aggregate boundaries.
6
6
 
7
- Currently works with Django ORM.
7
+ Currently works with Django ORM, SQLAlchemy is in roadmap.
8
8
 
9
9
  In domain-driven design, an aggregate is a consistency boundary: code in one
10
10
  aggregate should not reach into the internals of another. Django's `__` relation
@@ -15,8 +15,8 @@ lookups make it easy to cross those boundaries silently:
15
15
  Purchase.objects.get(client__name="John")
16
16
  ```
17
17
 
18
- `pytest-orm-boundaries` watches the queries your test suite executes and reports
19
- the ones that step outside their aggregate through `__` lookups,
18
+ `pytest-orm-boundaries` watches the ORM activity exercised by your test suite
19
+ and reports access that crosses a configured boundary - through `__` lookups,
20
20
  `select_related`, `prefetch_related`, subqueries, or hand-written `.raw()` SQL.
21
21
 
22
22
  ## Install
@@ -31,14 +31,19 @@ pytest discovers the plugin automatically.
31
31
 
32
32
  ## Configure
33
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):
34
+ Declare your aggregates and their Django models in `boundaries.toml` at the project
35
+ root (or point at the file with `--boundaries-config` / the
36
+ `boundaries_config` ini option):
36
37
 
37
38
  ```toml
38
- [aggregates]
39
- client = ["bookshop.Client"]
40
- book = ["bookshop.Book"]
41
- purchase = ["bookshop.Purchase", "bookshop.PurchaseLine"]
39
+ [aggregates.client]
40
+ models = ["bookshop.Client"]
41
+
42
+ [aggregates.book]
43
+ models = ["bookshop.Book"]
44
+
45
+ [aggregates.purchase]
46
+ models = ["bookshop.Purchase", "bookshop.PurchaseLine"]
42
47
  ```
43
48
 
44
49
  Models are written as `app_label.Model`. Models not listed in any aggregate are
@@ -46,8 +51,9 @@ not checked. Without a config file the plugin emits a warning and runs no checks
46
51
 
47
52
  ## What it catches
48
53
 
49
- The plugin flags queries that read across an aggregate boundary. Each example below couples
50
- the `purchase` and `client` aggregates:
54
+ The plugin flags executed ORM access that spans more than one configured group.
55
+ In the DDD example below, each crossing couples the `purchase` and `client`
56
+ aggregates:
51
57
 
52
58
  - `__` relation lookups:
53
59
 
@@ -85,7 +91,7 @@ the `purchase` and `client` aggregates:
85
91
  Purchase.objects.prefetch_related("client")
86
92
  ```
87
93
 
88
- Queries that don't actually join across the boundary are **not** flagged for
94
+ Queries that don't actually join across the boundary are **not** flagged - for
89
95
  example a foreign-key lookup by id, which Django resolves without a join:
90
96
 
91
97
  ```python
@@ -114,11 +120,22 @@ Each entry names the aggregates the query crossed and the models it joined.
114
120
  Places are ordered by how many tests they affect. Pass `-v` to see every affected test
115
121
  (otherwise the list is capped at 5 per place).
116
122
 
117
- ## Ignoring files
123
+ ## Allow and ignore
118
124
 
119
- Add exceptions so that known offenders keep passing while you fix them one file at a time:
125
+ CQRS read models may cross boundaries intentionally, also existing application code may contain crossings you want to fix over time. [allow] and [ignore] let you tell the plugin which is which:
126
+
127
+ - `[allow]` - the crossing is **intentional**. Use it for code that is meant to
128
+ span aggregates, such as CQRS read models or cross-aggregate reports. An
129
+ allowed crossing is suppressed and never reported.
130
+ - `[ignore]` - the crossing is **known debt** you plan to fix. It is suppressed
131
+ for now, and the plugin reminds you when an entry is no longer needed.
120
132
 
121
133
  ```toml
134
+ [allow]
135
+ files = [
136
+ "app/reports/sales_summary.py",
137
+ ]
138
+
122
139
  [ignore]
123
140
  files = [
124
141
  "app/billing.py",
@@ -126,14 +143,14 @@ files = [
126
143
  ]
127
144
  ```
128
145
 
129
- Each entry is a glob ([`fnmatch`](https://docs.python.org/3/library/fnmatch.html),
146
+ Each entry is a glob ([`fnmatch`](https://docs.python.org/3/library/fnmatch.html)),
130
147
  resolved relative to pytest's root directory and matched against either:
131
148
 
132
149
  - the file that issues the query, or
133
150
  - the test file.
134
151
 
135
- If an ignored file runs queries through the whole suite without ever crossing a
136
- boundary, the plugin says so at the end:
152
+ An `[ignore]` whose file runs through the whole suite without ever crossing a
153
+ boundary is stale — it is clean now, so the plugin lists it for removal:
137
154
 
138
155
  ```
139
156
  ======================= orm-boundaries: stale ignores ========================
@@ -142,6 +159,9 @@ Remove them from boundaries.toml:
142
159
  - app/billing.py
143
160
  ```
144
161
 
162
+ `[allow]` entries are never reported this way. If a file sits in both sections,
163
+ the allow wins and its `[ignore]` entry shows up as stale to remove.
164
+
145
165
  ## A note on Django internals
146
166
 
147
167
  Catching `prefetch_related` relies on Django internals that come with no stability
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "pytest-orm-boundaries"
7
- version = "0.5.0"
7
+ version = "0.7.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"
@@ -0,0 +1,26 @@
1
+ """Per-file allows: files whose boundary crossings are intentional.
2
+
3
+ Unlike ``[ignore]`` (known debt, tracked for staleness), an ``[allow]`` marks
4
+ architecture that is meant to span aggregates - CQRS read models, cross-aggregate
5
+ reports - so a matching crossing is suppressed and never surfaces as stale.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Iterable
11
+ from fnmatch import fnmatch
12
+
13
+
14
+ class AllowList:
15
+ """Decides whether a crossing's call place is intentional and so allowed.
16
+ """
17
+
18
+ def __init__(self, *, patterns: Iterable[str]) -> None:
19
+ self._patterns = list(dict.fromkeys(patterns)) # ordered, de-duped
20
+
21
+ @property
22
+ def is_active(self) -> bool:
23
+ return bool(self._patterns)
24
+
25
+ def has_allow_for(self, *, file_paths: Iterable[str]) -> bool:
26
+ return any(fnmatch(f, p) for f in file_paths for p in self._patterns)
@@ -19,7 +19,8 @@ class BoundariesConfig:
19
19
  """Parsed and validated content of a ``boundaries.toml``."""
20
20
 
21
21
  aggregates_by_model: dict[str, str] # {"app.model" lower-cased: aggregate_name}
22
- ignored_files: list[str] # glob patterns
22
+ ignored_files: list[str] # globs for known debt, tracked for staleness
23
+ allowed_files: list[str] # globs for intentional crossings, never stale
23
24
 
24
25
 
25
26
  def discover_config_path(*, explicit: str | None, rootpath: Path) -> Path | None:
@@ -42,7 +43,8 @@ def load_config(*, path: Path) -> BoundariesConfig:
42
43
  data = _read_config(path=path)
43
44
  return BoundariesConfig(
44
45
  aggregates_by_model=_parse_aggregates(data=data, path=path),
45
- ignored_files=_parse_ignored_files(data=data, path=path),
46
+ ignored_files=_parse_file_globs(data=data, path=path, section_name="ignore"),
47
+ allowed_files=_parse_file_globs(data=data, path=path, section_name="allow"),
46
48
  )
47
49
 
48
50
 
@@ -60,13 +62,41 @@ def _parse_aggregates(*, data: dict[str, Any], path: Path) -> dict[str, str]:
60
62
  Model labels are lower-cased ("app_label.modelname") for case-insensitive
61
63
  matching.
62
64
  """
65
+ aggregate_definitions = data.get("aggregates", {})
66
+ if not isinstance(aggregate_definitions, dict):
67
+ raise BoundariesConfigError(
68
+ f"{path}: define aggregates as named sections, for example "
69
+ "[aggregates.order] with models = [...]"
70
+ )
71
+
63
72
  aggregates_by_model: dict[str, str] = {}
64
- for aggregate, members in data.get("aggregates", {}).items():
65
- if isinstance(members, dict):
66
- members = members.get("models", [])
73
+ for aggregate, definition in aggregate_definitions.items():
74
+ if not isinstance(definition, dict):
75
+ raise BoundariesConfigError(
76
+ f"{path}: aggregate '{aggregate}' must define models in its own "
77
+ "section: "
78
+ f"[aggregates.{aggregate}] with models = [...]"
79
+ )
80
+
81
+ unknown_fields = set(definition) - {"models"}
82
+ if unknown_fields:
83
+ fields = ", ".join(sorted(unknown_fields))
84
+ raise BoundariesConfigError(
85
+ f"{path}: aggregate '{aggregate}' has unknown field(s): {fields}"
86
+ )
87
+
88
+ if "models" not in definition:
89
+ raise BoundariesConfigError(
90
+ f"{path}: aggregate '{aggregate}' is missing required 'models'"
91
+ )
92
+ members = definition["models"]
67
93
  if not isinstance(members, list):
68
94
  raise BoundariesConfigError(
69
- f"{path}: aggregate '{aggregate}' must be a list of model labels"
95
+ f"{path}: aggregate '{aggregate}' models must be a list"
96
+ )
97
+ if not members:
98
+ raise BoundariesConfigError(
99
+ f"{path}: aggregate '{aggregate}' models must not be empty"
70
100
  )
71
101
  for label in members:
72
102
  if not isinstance(label, str):
@@ -82,23 +112,27 @@ def _parse_aggregates(*, data: dict[str, Any], path: Path) -> dict[str, str]:
82
112
  return aggregates_by_model
83
113
 
84
114
 
85
- def _parse_ignored_files(*, data: dict[str, Any], path: Path) -> list[str]:
86
- """Read ``[ignore] files`` into a list of glob patterns."""
87
- ignore = data.get("ignore", {})
88
- if not isinstance(ignore, dict):
89
- raise BoundariesConfigError(f"{path}: [ignore] must be a table")
115
+ def _parse_file_globs(
116
+ *, data: dict[str, Any], path: Path, section_name: str
117
+ ) -> list[str]:
118
+ """Read ``[allow]`` and ``[ignore] files`` into a list of glob patterns."""
119
+ section_data = data.get(section_name, {})
120
+ if not isinstance(section_data, dict):
121
+ raise BoundariesConfigError(
122
+ f"{path}: [{section_name}] must be a section with a 'files' list"
123
+ )
90
124
 
91
- files = ignore.get("files", [])
125
+ files = section_data.get("files", [])
92
126
  if not isinstance(files, list):
93
127
  raise BoundariesConfigError(
94
- f"{path}: [ignore] files must be a list of glob patterns"
128
+ f"{path}: [{section_name}] files must be a list of glob patterns"
95
129
  )
96
130
 
97
131
  patterns: list[str] = []
98
132
  for entry in files:
99
133
  if not isinstance(entry, str):
100
134
  raise BoundariesConfigError(
101
- f"{path}: [ignore] files has a non-string entry: {entry!r}"
135
+ f"{path}: [{section_name}] files has a non-string entry: {entry!r}"
102
136
  )
103
137
  patterns.append(entry)
104
138
  return patterns
@@ -11,6 +11,7 @@ from pytest_orm_boundaries.callstack import find_frames_inside_project
11
11
  if TYPE_CHECKING:
12
12
  from collections.abc import Iterable
13
13
 
14
+ from pytest_orm_boundaries.allows import AllowList
14
15
  from pytest_orm_boundaries.ignores import IgnoreTracker
15
16
 
16
17
 
@@ -27,16 +28,19 @@ class CrossingRecord:
27
28
 
28
29
  class CrossingTracker:
29
30
  """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
+ skipping allowed and ignored files and naming each crossing's call place and
32
+ test."""
31
33
 
32
34
  def __init__(
33
35
  self,
34
36
  *,
35
37
  aggregates_config: dict[str, str],
38
+ allow_list: AllowList,
36
39
  ignore_tracker: IgnoreTracker,
37
40
  root: Path,
38
41
  ) -> None:
39
42
  self._aggregates_config = aggregates_config
43
+ self._allow_list = allow_list
40
44
  self._ignore_tracker = ignore_tracker
41
45
  self._root_path = Path(root)
42
46
  self._current_test: str | None = None
@@ -48,13 +52,16 @@ class CrossingTracker:
48
52
 
49
53
  def check(self, *, label_sets: Iterable[Iterable[str]]) -> None:
50
54
  """Record a crossing for each label set that spans aggregates, unless an
51
- active ignore covers the call place.
55
+ allow or an ignore covers the call place.
56
+
57
+ Allow wins over ignore: an allowed crossing is suppressed without marking
58
+ the ignore used, so an ignore that only overlaps an allow surfaces as
59
+ stale and can be removed as redundant.
52
60
  """
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.
61
+ # ``frames`` is the in-project part of the call stack
55
62
  frames: list[tuple[str, int]] | None = None
56
63
  file_paths: set[str] | None = None
57
- if self._ignore_tracker.is_active:
64
+ if self._allow_list.is_active or self._ignore_tracker.is_active:
58
65
  frames = find_frames_inside_project(root=self._root_path)
59
66
  file_paths = {path for path, _ in frames}
60
67
  self._ignore_tracker.mark_seen(file_paths=file_paths)
@@ -63,11 +70,12 @@ class CrossingTracker:
63
70
  crossing = self.find_crossing(labels=labels)
64
71
  if crossing is None:
65
72
  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
73
+ if file_paths is not None:
74
+ if self._allow_list.has_allow_for(file_paths=file_paths):
75
+ continue
76
+ if self._ignore_tracker.has_ignore_for(file_paths=file_paths):
77
+ self._ignore_tracker.mark_used(file_paths=file_paths)
78
+ continue
71
79
  if frames is None:
72
80
  frames = find_frames_inside_project(root=self._root_path)
73
81
  call_place = frames[0] if frames else None
@@ -20,6 +20,7 @@ from pytest_orm_boundaries.sql_parsing import extract_table_names, looks_like_da
20
20
  if TYPE_CHECKING:
21
21
  from django.db.backends.base.base import BaseDatabaseWrapper
22
22
 
23
+ from pytest_orm_boundaries.allows import AllowList
23
24
  from pytest_orm_boundaries.crossings import CrossingRecord
24
25
  from pytest_orm_boundaries.ignores import IgnoreTracker
25
26
 
@@ -45,11 +46,13 @@ class BoundaryGuard:
45
46
  self,
46
47
  *,
47
48
  aggregates_config: dict[str, str],
49
+ allow_list: AllowList,
48
50
  ignore_tracker: IgnoreTracker,
49
51
  root: Path,
50
52
  ) -> None:
51
53
  self._tracker = CrossingTracker(
52
54
  aggregates_config=aggregates_config,
55
+ allow_list=allow_list,
53
56
  ignore_tracker=ignore_tracker,
54
57
  root=root,
55
58
  )
@@ -14,6 +14,7 @@ from pytest_orm_boundaries.config import (
14
14
  discover_config_path,
15
15
  load_config,
16
16
  )
17
+ from pytest_orm_boundaries.allows import AllowList
17
18
  from pytest_orm_boundaries.guard import BoundaryGuard
18
19
  from pytest_orm_boundaries.ignores import IgnoreTracker
19
20
 
@@ -56,10 +57,12 @@ def _build_guard(*, rootpath: Path, config_path: Path) -> BoundaryGuard | None:
56
57
  if not config.aggregates_by_model:
57
58
  return None
58
59
 
59
- tracker = IgnoreTracker(patterns=config.ignored_files)
60
+ allow_list = AllowList(patterns=config.allowed_files)
61
+ ignore_tracker = IgnoreTracker(patterns=config.ignored_files)
60
62
  return BoundaryGuard(
61
63
  aggregates_config=config.aggregates_by_model,
62
- ignore_tracker=tracker,
64
+ allow_list=allow_list,
65
+ ignore_tracker=ignore_tracker,
63
66
  root=rootpath,
64
67
  )
65
68
 
@@ -88,8 +88,8 @@ def report_stale_ignores(
88
88
  return
89
89
  terminalreporter.section("orm-boundaries: stale ignores", yellow=True, bold=True)
90
90
  terminalreporter.write_line(
91
- "These [ignore] entries no longer suppress any boundary crossing - "
92
- f"their files are clean now. Remove them from {CONFIG_FILE_NAME}:",
91
+ "These [ignore] entries matched files that ran without crossing a "
92
+ f"boundary. Remove them from {CONFIG_FILE_NAME}:",
93
93
  yellow=True,
94
94
  )
95
95
  for pattern in stale: