bsa-linter 0.1.0b2__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 (33) hide show
  1. bsa_linter-0.1.0b2/.gitignore +8 -0
  2. bsa_linter-0.1.0b2/LICENSE +21 -0
  3. bsa_linter-0.1.0b2/Makefile +25 -0
  4. bsa_linter-0.1.0b2/PKG-INFO +601 -0
  5. bsa_linter-0.1.0b2/README.md +582 -0
  6. bsa_linter-0.1.0b2/pyproject.toml +49 -0
  7. bsa_linter-0.1.0b2/pyrightconfig.json +6 -0
  8. bsa_linter-0.1.0b2/src/bsa_linter/__init__.py +1 -0
  9. bsa_linter-0.1.0b2/src/bsa_linter/architecture.py +672 -0
  10. bsa_linter-0.1.0b2/src/bsa_linter/cli.py +71 -0
  11. bsa_linter-0.1.0b2/src/bsa_linter/extract.py +356 -0
  12. bsa_linter-0.1.0b2/src/bsa_linter/py.typed +0 -0
  13. bsa_linter-0.1.0b2/src/bsa_linter/roles.py +63 -0
  14. bsa_linter-0.1.0b2/tests/__init__.py +0 -0
  15. bsa_linter-0.1.0b2/tests/mutations/check_rules.py +126 -0
  16. bsa_linter-0.1.0b2/tests/packaging/check_package.py +121 -0
  17. bsa_linter-0.1.0b2/tests/support.py +44 -0
  18. bsa_linter-0.1.0b2/tests/test_boundary_refactors.py +233 -0
  19. bsa_linter-0.1.0b2/tests/test_cli.py +23 -0
  20. bsa_linter-0.1.0b2/tests/test_composition.py +114 -0
  21. bsa_linter-0.1.0b2/tests/test_concept_collaborators.py +180 -0
  22. bsa_linter-0.1.0b2/tests/test_cycles.py +25 -0
  23. bsa_linter-0.1.0b2/tests/test_direction.py +126 -0
  24. bsa_linter-0.1.0b2/tests/test_extraction.py +24 -0
  25. bsa_linter-0.1.0b2/tests/test_layout.py +121 -0
  26. bsa_linter-0.1.0b2/tests/test_ownership.py +53 -0
  27. bsa_linter-0.1.0b2/tests/test_ownership_tree_establishment.py +2561 -0
  28. bsa_linter-0.1.0b2/tests/test_private_access.py +163 -0
  29. bsa_linter-0.1.0b2/tests/test_role_independence.py +80 -0
  30. bsa_linter-0.1.0b2/tests/test_rule_regressions.py +122 -0
  31. bsa_linter-0.1.0b2/tests/test_structural_invariants.py +269 -0
  32. bsa_linter-0.1.0b2/tests/test_surfaces.py +19 -0
  33. bsa_linter-0.1.0b2/uv.lock +371 -0
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
7
+ *.egg-info/
8
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bounded Software Architecture contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,25 @@
1
+ UV ?= uv
2
+ export UV
3
+
4
+ .PHONY: check test lint sample check-package check-mutations
5
+
6
+ check: lint test
7
+
8
+ lint:
9
+ $(UV) run --frozen pyright
10
+ $(UV) run --frozen ruff check .
11
+ $(UV) run --frozen ruff format --check .
12
+
13
+ test:
14
+ $(UV) run --frozen pytest
15
+
16
+ check-mutations:
17
+ $(UV) run --frozen python tests/mutations/check_rules.py
18
+
19
+ sample:
20
+ $(UV) run --frozen pytest ../test_linter_samples.py
21
+ $(UV) run --frozen bsa-lint --root ../samples/bsa-python
22
+ $(UV) run --frozen bsa-lint --root ../samples/meridian-python
23
+
24
+ check-package:
25
+ $(UV) run --frozen python tests/packaging/check_package.py
@@ -0,0 +1,601 @@
1
+ Metadata-Version: 2.5
2
+ Name: bsa-linter
3
+ Version: 0.1.0b2
4
+ Summary: BSA architecture linter for Python applications
5
+ Project-URL: Homepage, https://boundedsoftwarearchitecture.com
6
+ Project-URL: Repository, https://github.com/defrag/bsa-python-linter
7
+ Project-URL: Issues, https://github.com/defrag/bsa-python-linter/issues
8
+ Project-URL: Documentation, https://github.com/defrag/bsa-python-linter#readme
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Topic :: Software Development :: Quality Assurance
16
+ Requires-Python: <3.14,>=3.12
17
+ Requires-Dist: tach==0.35.1
18
+ Description-Content-Type: text/markdown
19
+
20
+ # BSA Python architecture linter
21
+
22
+ The shared Python linter checks source organization, ownership, and allowed
23
+ imports. It infers roles from the source layout, combines Tach's file dependency
24
+ map with Python AST inspection, and checks the resulting graph without importing
25
+ or executing the application. Its rules contain no sample-specific business names.
26
+
27
+ This document describes the **implemented checks**. The
28
+ [architecture guide](https://boundedsoftwarearchitecture.com/guide/) and Python kit
29
+ also cover design and runtime requirements that this linter does not enforce;
30
+ see [Limits](#limits). This linter is MIT licensed, including commercial use. It ships inline type
31
+ annotations and a `py.typed` marker for Python API consumers.
32
+
33
+ ## Run it
34
+
35
+ Install the published beta from PyPI:
36
+
37
+ ```sh
38
+ uv add --dev bsa-linter==0.1.0b2
39
+ uv run bsa-lint --root .
40
+ ```
41
+
42
+ The beta's rules and public APIs may change before a stable release. Pin a version
43
+ and review diagnostics when upgrading. [PyPI release](https://pypi.org/project/bsa-linter/0.1.0b2/).
44
+
45
+ The distribution was previously named `bsa-architecture-linter`. If installed,
46
+ remove that dependency before adding `bsa-linter`; both provide the same
47
+ `bsa_linter` Python module and `bsa-lint` command.
48
+
49
+ Alternatively, install from the matching source release:
50
+
51
+ ```sh
52
+ uv add --dev 'bsa-linter @ git+https://github.com/defrag/bsa-python-linter@v0.1.0b2'
53
+ uv run bsa-lint --root .
54
+ ```
55
+
56
+ From a checkout of this package:
57
+
58
+ ```sh
59
+ uv sync --frozen
60
+ uv run --frozen bsa-lint --root /path/to/application --json
61
+ uv run --frozen bsa-lint --root /path/to/application --ownership-tree
62
+ uv run --frozen bsa-lint --root /path/to/application --ownership-tree --json
63
+ ```
64
+
65
+ `--ownership-tree` only displays the established tree. All ownership, import,
66
+ and cycle checks run by default, including with `--json`. Add the flag when
67
+ you want to inspect the tree; it does not enable additional checks.
68
+
69
+ `--root` is the source directory containing the application package and optional `tests/`,
70
+ not the application package itself. For example, point it at the directory
71
+ containing `folio/__init__.py`. There must be exactly one application root package
72
+ and at least one business capability package. A flat collection of files, or an
73
+ application with no capability boundaries, cannot obtain a vacuous pass.
74
+
75
+ | Exit | Meaning |
76
+ | --- | --- |
77
+ | `0` | The ownership tree and imports conform to this profile. Text output ends with `OK`. |
78
+ | `1` | Violations were found. Text output includes locations, rule names, and reasons. |
79
+ | `2` | The invocation or extraction failed, for example an invalid root, ambiguous application namespace, or failed Tach command. |
80
+
81
+ JSON output contains `pass` and `violations`. Each violation has `rule`, `src`,
82
+ `tgt`, `reason`, `file`, and `line`. `--ownership-tree --json` additionally includes
83
+ `ownership_tree` on success. A failed tree is never presented as established.
84
+ Tool errors use stderr rather than a successful JSON report. One import can
85
+ violate several independent rules and therefore produce several findings.
86
+
87
+ ## Structural rules and Python conventions
88
+
89
+ A library can establish a valid ownership tree with ordinary concept packages,
90
+ public roots, and private implementation alone. It does not need handlers,
91
+ commands, factories, HTTP, application scopes, or test adapters.
92
+
93
+ The structural rules are:
94
+
95
+ - Every source module has a direct owner. Private branches retain the nearest
96
+ boundary's identity; a public child boundary has its own owner.
97
+ - Crossing an ownership boundary requires a supported public surface. Being a
98
+ parent, sibling, or descendant does not grant access to private implementation.
99
+ - Re-exporting a name does not erase the import that brought it into the owner.
100
+ - Containment and imports are different graphs. A parent implementation may use
101
+ a child surface while that child uses parent vocabulary, provided the actual
102
+ files and sibling-owner graphs remain acyclic.
103
+ - File dependencies and collapsed sibling-owner dependencies are acyclic at every
104
+ scope. Technical roles cannot remove an edge from those structural checks.
105
+ - Unreadable or unsupported source cannot silently produce a passing tree.
106
+
107
+ The Python profile adds reserved paths, audience restrictions, anchor rules,
108
+ technical-bucket naming, transport restrictions, and test-entry conventions.
109
+ Those rules constrain a structural connection or designate an extra surface;
110
+ they do not replace direct ownership, privacy, or cycle checks. Tests may use
111
+ public application surfaces, but have no access to another owner's private code.
112
+
113
+ Both public checking APIs still apply the complete Python profile. There is no
114
+ role-free CLI switch or weaker validation mode. The separation is between the
115
+ structural invariants and the extra conventions we verify, not two verdicts a
116
+ caller can choose between.
117
+
118
+ ## How source roles are inferred
119
+
120
+ Roles come from paths, not class names, `__all__`, or a role configuration in
121
+ `pyproject.toml`. Optional roles do not require creating otherwise unnecessary
122
+ files. In the following examples, `folio` is an illustrative application name.
123
+
124
+ | Location | Inferred role and responsibility |
125
+ | --- | --- |
126
+ | `folio/__init__.py` | Application namespace. It cannot import its own descendants to aggregate the application. |
127
+ | `folio/catalog/__init__.py` | Business boundary and public vocabulary root. It selects concepts owned by that boundary. |
128
+ | `folio/catalog/book.py` | Direct concept module, called an **anchor** by the checker. It stays independent of operations, composition, and child boundaries. |
129
+ | `folio/catalog/editions/__init__.py` | A child business boundary. Its parent does not own its implementation. |
130
+ | `folio/catalog/_commands/register.py` | Private implementation belonging to `catalog`; underscore branches do not establish new owners. |
131
+ | `folio/catalog/commands.py` | Public operation surface. The guide uses it for command values. |
132
+ | `folio/catalog/deps.py` | The direct owner's factory surface. It may assemble that owner's implementation and consume supported foreign factories. |
133
+ | `folio/catalog/queries.py` | Ordinary non-anchor implementation. It is not a public cross-owner surface. |
134
+ | `folio/catalog/api_routes.py` | Capability HTTP surface. Only the application API entry may import it. |
135
+ | `folio/api.py` | API entry; may assemble capability HTTP surfaces. |
136
+ | `folio/cli.py`, `worker.py`, `main.py`, `application.py` | Other application entries; may assemble public factories, but do not gain HTTP permissions. |
137
+ | `folio/scope.py`, `folio/bootstrap.py` | Application composition: resource definitions/binding and setup/teardown respectively. Packages with these names also carry the role through their subtrees. |
138
+ | `folio/foundation`, `shared`, `settings`, `errors`, `database` | Independent support, as files or packages immediately below the application root. `settings` additionally has a restricted import audience. |
139
+ | `folio/api_support` | Shared HTTP support, as a file or package immediately below the application root. |
140
+ | `tests/` | Test entry modules. They may use supported production surfaces and collaborate with test helpers. Production cannot import them. |
141
+
142
+ An anchor is a business boundary root or a public module directly inside that
143
+ boundary, excluding `commands`, `deps`, `queries`, and the recognized HTTP route
144
+ surface. This is a source-location rule; the checker does not inspect whether a
145
+ class is an entity, repository, command, or handler to assign the role.
146
+
147
+ The nearest recognized package or support/composition boundary is the direct
148
+ owner. Private underscore branches preserve that owner even when they contain
149
+ `__init__.py` files and nested directories. Public child packages create new
150
+ owners. Entries and test modules are treated as their own entry owners.
151
+
152
+ Application roles apply only at their designated locations. For example,
153
+ `folio/catalog/scope.py` is an ordinary business module, and
154
+ `folio/catalog/_commands/deps.py` is not `catalog`'s factory. Renaming a file to a
155
+ technical role inside another branch cannot grant it extra permissions.
156
+
157
+ ### Value objects and errors stay with their concepts
158
+
159
+ There is no `values` support role. `BookId` and `Title` belong with `Book` in
160
+ `catalog/book.py`; `LoanId` and `Borrower` belong with `Loan` in `loans/loan.py`.
161
+ Generic `values.py`, `values/`, and `*_values.py` containers are rejected as
162
+ technical buckets, including below shared support.
163
+
164
+ An optional root `errors.py` does not collect all application errors. Keep
165
+ `BookNotFound` and `BookExists` with the book concept, and `LoanNotFound` and
166
+ `BookOnLoan` with the loan concept. Export these through the owning capability's
167
+ root when consumers need them. Shared support can define a base exception and
168
+ generic categories such as invalid input, missing resource, or conflict. It must
169
+ not maintain a catalog of every capability's error codes. HTTP adapters translate
170
+ errors to status codes and wire responses.
171
+
172
+ The checker enforces support independence and source ownership, but cannot infer
173
+ that a literal error-code list is secretly business vocabulary. A
174
+ design review must cover that semantic distinction; a passing import graph alone
175
+ does not establish it.
176
+
177
+ ## Rule reference
178
+
179
+ Every connection must satisfy all applicable rules. “Allowed” below means the
180
+ illustrated import is permitted, provided the rest of the layout, direction, and
181
+ cycle checks also pass. The snippets are alternatives, not a complete application.
182
+
183
+ ### `python-layout`: packages and concept-oriented names
184
+
185
+ Directories containing Python source must have `__init__.py`, including private
186
+ branches. Production source cannot split concepts into technical buckets. The
187
+ current bucket names are:
188
+
189
+ ```text
190
+ model models repositories services contracts types values schemas domain entities
191
+ dto capabilities assembly stores
192
+ ```
193
+
194
+ The check applies to each dotted path segment: an exact bucket name, a name
195
+ ending in `_<bucket>`, or a name starting with `in_memory_` is rejected. Thus
196
+ `models.py`, `book_dto.py`, `_stores/`, and `in_memory_books.py` fail. A concept
197
+ module such as `book.py` may contain its value types, repository contract, and
198
+ implementation together. A class named `InMemoryBooks` is not itself a violation.
199
+ Test modules are exempt from these naming rules. The exact shared HTTP module
200
+ `folio/api_support/api_models.py` is also exempt; this is not a general exemption
201
+ for every module under HTTP support.
202
+
203
+ These obsolete role names are rejected at their role locations:
204
+
205
+ | Rejected location/name | Current name |
206
+ | --- | --- |
207
+ | Application-root `_ui` or `http_support` | `api_support` |
208
+ | Application-root `composition` | `scope` for binding; `bootstrap` for setup/teardown |
209
+ | Business boundary's `routes` or `http_routes` | `api_routes` |
210
+
211
+ ### `ownership`: every module belongs somewhere
212
+
213
+ The application must have a namespace and owned capabilities. Ordinary business
214
+ files cannot sit directly under the application namespace: they belong inside a
215
+ capability. Root-level entries, recognized support/composition, and the root
216
+ factory location are handled by their respective roles.
217
+
218
+ Production source cannot import `tests`, even through an entry or composition
219
+ module. Tests do not gain access to production implementation merely because they
220
+ are tests. Unreadable or syntactically invalid source and source symlinks also
221
+ produce ownership findings; see [Source extraction](#source-extraction).
222
+
223
+ ### `public-surfaces`: cross-owner imports use supported surfaces
224
+
225
+ For a foreign owner, the supported target locations are its root, `commands`,
226
+ and `deps`, subject to their audiences. API entries additionally use capability
227
+ `api_routes`. A concept leaf such as `catalog.book` is implementation relative
228
+ to foreign owners, even if its classes have public names. `queries.py` is not an
229
+ additional public surface.
230
+
231
+ ```python
232
+ # folio/lending/loan.py
233
+ from folio.catalog import BookId # allowed: foreign public vocabulary
234
+ from folio.catalog.book import BookId # forbidden: bypasses catalog's root
235
+
236
+ # folio/catalog/deps.py
237
+ from folio.catalog.book import Books # allowed: own concept implementation
238
+
239
+ # folio/lending/deps.py
240
+ from folio.catalog.deps import get_books # allowed: factory to factory
241
+ ```
242
+
243
+ Reusable concept behavior follows the same connection rule. Its concrete type can
244
+ be selected by its owner's root; construction stays on the factory surface:
245
+
246
+ ```python
247
+ # workshop/pricing/__init__.py
248
+ from .price import DiscountPolicy # owns the pricing decision, not a handler alias
249
+
250
+ # workshop/orders/_commands/place.py
251
+ from workshop.pricing import DiscountPolicy # allowed: injected concept type
252
+ # from workshop.pricing.deps import DiscountPolicy # forbidden, even for annotations
253
+
254
+ # workshop/orders/deps.py
255
+ from workshop.pricing.deps import get_policy # allowed: construct then inject
256
+ ```
257
+
258
+ This does not authorize handler re-exports, root-level operation functions, or
259
+ wrappers that delegate to another handler. The checker rejects a root or concept
260
+ anchor importing private handlers. It cannot infer every callable's purpose:
261
+ same-owner composition can inject another handler's bound method through legal
262
+ imports. The guide forbids that reuse, and semantic review must inspect it.
263
+ [Concept collaborator regressions](tests/test_concept_collaborators.py) cover
264
+ both forms of rejected imports and this explicit structural-check limitation,
265
+ plus a valid plain library without application roles or test adapters.
266
+
267
+ Foreign owners include parents and children, not only siblings. A child can use
268
+ its parent's public vocabulary, but cannot bypass it to reach the parent's
269
+ concept file or private implementation. Parent factories likewise cannot reach
270
+ into a child's private files.
271
+
272
+ API entries and capability routes may import public modules within shared
273
+ `api_support`, such as `api_support.api_models`. This exception does not permit
274
+ private modules or private symbols. Other callers, including tests, still use
275
+ that support owner's public surfaces.
276
+
277
+ ### `private-access`: privacy follows ownership
278
+
279
+ A foreign owner cannot import a module through an underscore path segment or an
280
+ underscore-prefixed symbol from a local module. Renaming the imported symbol does
281
+ not make it public. This applies to business owners, support, parents, children,
282
+ entries, and tests.
283
+
284
+ ```python
285
+ # folio/lending/_commands/borrow.py
286
+ from folio.catalog._storage import cache # forbidden: foreign private module
287
+ from folio.catalog import _cache as cache # forbidden: foreign private symbol
288
+
289
+ # folio/catalog/deps.py
290
+ from folio.catalog._commands.register import RegisterHandler # allowed: own code
291
+ ```
292
+
293
+ Same-owner implementation can collaborate across private descendants and sibling
294
+ branches, provided imports remain acyclic and satisfy role restrictions. There
295
+ is a stricter rule for `_commands`: the owner's roots, anchors, and HTTP routes
296
+ cannot import it directly. Its own `commands`, `deps`, and non-anchor
297
+ implementation can. Routes use the public command and factory surfaces instead.
298
+ Test helpers may import one another's private test implementation; that exception
299
+ does not extend to private production source.
300
+
301
+ **This import permission does not enforce the guide's ban on handler reuse.**
302
+ The checker can allow an acyclic same-owner `_commands` import without knowing
303
+ whether it imports a command value or another handler. See [Limits](#limits).
304
+
305
+ ### `direction`: roots, anchors, support, and entries
306
+
307
+ The checker rejects these import directions independently of public/private access:
308
+
309
+ | Source | Forbidden target or direction |
310
+ | --- | --- |
311
+ | Application root | Its own descendants; the root remains a namespace. |
312
+ | Business boundary root | Any different local owner; a root selects its own concept vocabulary rather than forwarding another owner's exports. |
313
+ | Parent anchor | A child boundary, even through that child's public root. |
314
+ | Any anchor | `commands`, `deps`, or a capability HTTP route surface. |
315
+ | Independent support, including nested support | A business owner or application composition. |
316
+ | Application `scope`, including its subtree | Business owners, factory surfaces, or `bootstrap`; resource definitions/binding stay independent of business initialization. |
317
+ | Shared HTTP support | A business owner. |
318
+ | Non-HTTP source | Shared HTTP support. |
319
+ | Business implementation | An entry adapter. |
320
+ | Production entry | Another entry adapter. |
321
+ | Composition or shared HTTP support | Any entry adapter; entries assemble these helpers, not the reverse. |
322
+ | Any caller except the application API entry | A capability's `api_routes`. This includes tests importing the route module directly. |
323
+
324
+ A concept module can use another owner's public vocabulary when it does not
325
+ violate the parent/child rule. The boundary root itself cannot aggregate that
326
+ foreign vocabulary:
327
+
328
+ ```python
329
+ # folio/lending/loan.py
330
+ from folio.catalog import BookId # allowed: lending concept uses catalog vocabulary
331
+
332
+ # folio/lending/__init__.py
333
+ from .loan import Loan # allowed: selects its own concept
334
+ from folio.catalog import BookId # forbidden: root forwards a foreign owner
335
+ from .history import LoanEvent # forbidden when history is a child boundary
336
+ ```
337
+
338
+ A child may depend on its parent's public concept anchor through the parent root;
339
+ the parent anchor stays independent of its children. Support remains independent
340
+ of business even when the proposed target is a public root. Dependencies between
341
+ support owners still use public surfaces and remain acyclic.
342
+
343
+ ### `composition`: wiring and transport have restricted audiences
344
+
345
+ Factory, settings, and application-composition imports are permitted for edge
346
+ modules and direct-owner factories. Edge modules are application entries, tests,
347
+ capability HTTP routes, shared HTTP support, and application composition. Other
348
+ rules still apply: for example, shared HTTP support cannot use a business factory
349
+ because it cannot depend on business owners.
350
+
351
+ Ordinary business implementation, queries, and anchors cannot import `deps`,
352
+ application `settings`, `scope`, or `bootstrap`, including from their own owner
353
+ where applicable. Being inside `_commands` does not grant factory permissions.
354
+ Modules inside a settings package may import its own implementation; ordinary
355
+ ownership, public-surface, private-access, and cycle checks still apply there.
356
+
357
+ ```python
358
+ # folio/lending/deps.py
359
+ from folio.scope import current_scope # allowed: factory reads application binding
360
+ from folio.catalog.deps import get_books # allowed: supported foreign factory
361
+
362
+ # folio/lending/_commands/borrow.py
363
+ from folio.lending.deps import get_loans # forbidden: business imports wiring
364
+ from folio.catalog.deps import Books # forbidden even when used only as a type
365
+ from folio.settings import Settings # forbidden: business imports settings
366
+ ```
367
+
368
+ Imports from `fastapi`, `flask`, and `starlette` (including submodules) are allowed
369
+ only for HTTP callers: the API entry, capability HTTP routes, shared HTTP support,
370
+ and tests. CLI/worker entries, factories, application composition, ordinary
371
+ support, and business modules cannot import those frameworks.
372
+
373
+ The linter checks these imports, not what constructors or factories do. It does
374
+ not prove that a factory is synchronous, parameterless, free of I/O, or returns
375
+ an appropriate type.
376
+
377
+ ### `cycles` and `owner-dependency-cycle`: check both graphs
378
+
379
+ `cycles` rejects cycles between source modules, including private branches and
380
+ support modules. `owner-dependency-cycle` additionally rejects cycles between
381
+ sibling owners at every depth of the ownership tree.
382
+
383
+ For example, these two imports can form an owner cycle without a file cycle:
384
+
385
+ ```text
386
+ folio.lending.loan -> folio.catalog
387
+ folio.catalog.search -> folio.lending
388
+
389
+ collapsed owners: folio.lending -> folio.catalog -> folio.lending
390
+ ```
391
+
392
+ Imports collapse to the direct child boundary visible in each scope. Dependencies
393
+ between descendants of `folio.lending.borrowing` and `folio.lending.history` become
394
+ an edge between those children of `folio.lending`. The same check applies within
395
+ nested support packages and between technical owners at the application root.
396
+ Support, composition, HTTP support, and entries participate in both structural
397
+ graphs. Only the separate business dependency summary excludes them.
398
+
399
+ Duplicate edges and same-owner edges collapse away in owner graphs. Edges describe
400
+ direct observed imports, not their transitive closure. Forbidden imports remain
401
+ in the graphs: reporting another violation does not hide a dependency or cycle.
402
+
403
+ ### `explicit-imports`: dependencies must be statically visible
404
+
405
+ Absolute and resolvable relative imports are supported, as are explicit aliases.
406
+ All imports must be directly at module scope. Wildcards and imports inside
407
+ functions, classes, `try` blocks, or conditionals (including `if TYPE_CHECKING`)
408
+ produce findings. An import used only for an annotation still has to obey the
409
+ same ownership and audience rules.
410
+
411
+ ```python
412
+ from folio.catalog import BookId as CatalogBookId # supported explicit alias
413
+
414
+ from folio.catalog import * # forbidden: symbols are hidden
415
+
416
+
417
+ def load_books():
418
+ from folio.catalog import BookId # forbidden: import is not at module scope
419
+ ```
420
+
421
+ The extractor also rejects references to `importlib.*`, `sys.modules`, `sys.path`,
422
+ and the dynamic mechanisms `__import__`, `exec`, and `eval`, including their
423
+ `builtins` forms. Taking a reference to a loader is enough to produce a finding;
424
+ it need not be called. Qualified references and simple assignment aliases are
425
+ tracked, so `import folio.catalog as catalog; catalog._cache` cannot bypass
426
+ private-symbol checks. Unresolvable relative imports also produce findings.
427
+
428
+ ## Source extraction
429
+
430
+ The extractor scans `.py` files below `--root`, including tests. It skips paths
431
+ containing `.venv`, `.git`, `__pycache__`, `.pytest_cache`, `.ruff_cache`,
432
+ `.uv-cache`, or `.lake`. Python source files and source-directory symlinks are
433
+ rejected, including directory symlinks that would otherwise hide a source tree.
434
+ Source read/parse failures are retained as findings rather than ignored.
435
+
436
+ Tach supplies file dependencies; the AST supplies explicit imports, referenced
437
+ symbols, aliases, and source locations. A Tach-only local edge needs supporting
438
+ AST import evidence, so a literal module name in a source-checking tool does not
439
+ become an invented dependency. Tach failures or malformed dependency-map output
440
+ cannot produce a passing result. The linter writes no graph or temporary policy
441
+ file into the checked application.
442
+
443
+ The shared engine infers ownership from source, not from the application's Tach
444
+ boundary declarations. `tach check` and `tach check-external` are separate checks
445
+ for project-declared direction, visibility, and external dependencies. Running
446
+ `bsa-lint` does not replace them.
447
+
448
+ ## Ownership tree and Python API
449
+
450
+ Containment and imports are separate relationships: `children` identifies owned
451
+ boundaries; `dependencies` identifies imports between siblings at that scope.
452
+ For example:
453
+
454
+ ```text
455
+ Ownership tree establishment:
456
+ folio
457
+ imports: folio.lending -> folio.catalog
458
+ folio.catalog
459
+ folio.lending
460
+ imports: folio.lending.borrowing -> folio.lending.history
461
+ folio.lending.borrowing
462
+ folio.lending.history
463
+ OK
464
+ ```
465
+
466
+ Owners and dependencies are sorted for stable output. The
467
+ [ownership tree tests](tests/test_ownership_tree_establishment.py) contain a larger
468
+ self-contained Folio fixture with expected trees, nested business/support owners,
469
+ allowed and forbidden imports, extraction failures, and text/JSON assertions.
470
+ That fixture is separate from the runnable samples.
471
+
472
+ The API is defined in [architecture.py](src/bsa_linter/architecture.py) and
473
+ [extract.py](src/bsa_linter/extract.py):
474
+
475
+ ```python
476
+ from pathlib import Path
477
+
478
+ from bsa_linter.architecture import OwnershipTree, establish_ownership_tree
479
+ from bsa_linter.extract import extract
480
+
481
+ architecture = extract(Path("/path/to/application")) # tach must be on PATH
482
+ result = establish_ownership_tree(architecture)
483
+ if isinstance(result, OwnershipTree):
484
+ print(result.to_dict())
485
+ else:
486
+ for violation in result:
487
+ print(violation.to_dict())
488
+ ```
489
+
490
+ `extract(root, tach=...)` returns an `Architecture`; `tach` can select an executable.
491
+ `lint_architecture(architecture)` returns `WellFormed | list[Violation]`.
492
+ `WellFormed()` is a payload-free success marker; the caller retains the original
493
+ `Architecture` input. Violations carry the failure details.
494
+ `establish_ownership_tree(architecture)` runs the same checks and returns
495
+ `OwnershipTree | list[Violation]`. Extraction issues prevent either success result.
496
+ `tree.to_dict()` serializes each node's `owner`, `children`, and `dependencies`.
497
+
498
+ `architecture.owner_dependencies` exposes top-level business dependencies.
499
+ `architecture.dependency_graphs_by_owner` exposes the complete sibling-owner
500
+ dependencies at each scope with child boundaries, plus the application root.
501
+ The root graph and returned tree include technical support, composition, HTTP
502
+ support, and entry connections as well as business dependencies. The separate
503
+ `owner_dependencies` business summary omits technical roles; it is not the graph
504
+ used for cycle validation. Empty sibling graphs are retained where applicable.
505
+ These observed relationships remain inspectable when the architecture has
506
+ violations.
507
+
508
+ ## Limits
509
+
510
+ A passing result means the observed source structure fits this profile. It does
511
+ not establish that business boundaries are appropriate or that the program works.
512
+ In particular, the current linter does not enforce:
513
+
514
+ | Requirement | Where it needs verification |
515
+ | --- | --- |
516
+ | No handler calls, constructs, or injects another handler, including callbacks/wrappers | The guide and design review, or an explicit project check. Forbidden imports catch some cases; same-owner imports and runtime call relationships are not a general handler-reuse check. |
517
+ | Domain results retain `LoanId`, `BookId`, or other declared value objects instead of primitives | The guide, type declarations, Pyright, and domain tests. The import graph cannot choose field types. |
518
+ | `commands.py` exports only command values; `deps.py` exposes only intended factories/types | Surface review and project checks. The linter recognizes locations and privacy, not the semantic kind of every exported object. |
519
+ | Repository/resource construction happens only at the intended seam | Project-specific source checks and review. Class-name prefixes do not determine implementation roles or constructor restrictions. |
520
+ | Factories have the required signature; scopes bind correctly; resources close; operations are transactional | Type, lifecycle, and behavior tests. |
521
+ | Test adapters convert losslessly and contain no business decisions | Acceptance tests, mutation tests, and review. |
522
+ | A new one-way dependency is intended, or a third-party dependency is allowed | Project `tach.toml` contracts and architecture checks. An acyclic observed connection is not an approved product dependency. |
523
+ | Imported modules/symbols exist, exports resolve at runtime, and annotations are valid | Pyright and runtime import/startup tests. This is not a complete Python name resolver. |
524
+
525
+ Alias tracking is deliberately limited to static references and simple assignments;
526
+ it is not whole-program data-flow analysis or a security sandbox. The supported
527
+ import profile rejects known dynamic mechanisms instead of executing code to
528
+ resolve them. Other transport libraries are not automatically covered by the
529
+ explicit FastAPI/Flask/Starlette restriction.
530
+
531
+ ## Development and verification
532
+
533
+ ```sh
534
+ uv sync --frozen
535
+ make check # strict Pyright, Ruff, and self-contained regression tests
536
+ make check-mutations # disable each rule in a disposable copy; its test must fail
537
+ make check-package # build/install the wheel in a temporary external consumer
538
+ ```
539
+
540
+ Standalone development uses this package's `uv.lock` and `.venv`. In the source
541
+ monorepo, uv uses the parent tooling workspace's `../uv.lock` and `../.venv`.
542
+ The package retains its own dependencies and Python 3.12–3.13 support. Distribution
543
+ checks under [tests/packaging](tests/packaging/) install only the wheel and declared
544
+ dependencies, exercise the installed command, and reject a private import. They
545
+ require registry access and run separately from ordinary checks.
546
+
547
+ Focused regression tests cover [layout](tests/test_layout.py),
548
+ [ownership](tests/test_ownership.py), [surfaces](tests/test_surfaces.py),
549
+ [private access](tests/test_private_access.py), [direction](tests/test_direction.py),
550
+ [composition](tests/test_composition.py), [cycles](tests/test_cycles.py), and
551
+ [extraction](tests/test_extraction.py). Role inference is in
552
+ [roles.py](src/bsa_linter/roles.py), and command output is in
553
+ [cli.py](src/bsa_linter/cli.py).
554
+
555
+ [Break-and-repair tests](tests/test_rule_regressions.py) start with a conforming
556
+ source tree, introduce a fault for each diagnostic category, assert the same
557
+ findings from both checking APIs and the JSON CLI, then restore the source and
558
+ require a passing tree. A combined case repairs multiple independent faults one
559
+ at a time, checking that remaining violations cannot disappear prematurely.
560
+
561
+ [Boundary refactor examples](tests/test_boundary_refactors.py) compare flat
562
+ modules, private folders, and public child boundaries. They check selective
563
+ re-exports, illegal imports behind re-export chains, missing package initializers,
564
+ and cycles after moving files into folders. Private folders keep their owner's
565
+ identity; public children introduce separate owners. Disjoint file imports can
566
+ therefore form a cycle between child owners even without a file cycle, while
567
+ acyclic collaboration inside one owner's private branches remains allowed.
568
+
569
+ [Structural invariant tests](tests/test_structural_invariants.py) use a plain
570
+ library with no application roles or application test files. They exercise
571
+ ownership, private access, re-exports, return edges, and renaming. An independent
572
+ topological-elimination oracle checks every directed four-node graph without
573
+ self edges (4,096 graphs), and every three-owner graph at three depths (192
574
+ graphs). These bounded checks do not prove all possible source programs correct.
575
+ [Role independence tests](tests/test_role_independence.py) verify that assigning
576
+ technical roles never hides a sibling-owner cycle, even when role direction
577
+ also forbids the connection.
578
+
579
+ `make check-mutations` runs a separate
580
+ [mutation check](tests/mutations/check_rules.py). It first verifies the original
581
+ tests, then disables each of the nine diagnostic categories in a temporary source
582
+ copy. Each corresponding regression must fail with an assertion; collection or
583
+ execution errors do not count as detection. The repository's checker is never
584
+ modified. This verifies resistance to those specific rule removals, not exhaustive
585
+ coverage of every possible implementation bug or architecture.
586
+
587
+ Project-specific contracts stay with their application. The source monorepo also
588
+ runs `make sample` against its authored examples and a deliberately invalid sample.
589
+ Those integration tests remain in the monorepo; the public package's tests and CI
590
+ are self-contained and need no private repository or sample implementation.
591
+
592
+ ## Releases and license
593
+
594
+ The public repository is a one-way subtree export of the linter package. Changes
595
+ are developed in the source monorepo and exported together with this package's
596
+ tests and workflows. The website, evals, and customer kit have separate releases.
597
+ See [RELEASING.md](RELEASING.md) for the export and publishing procedure.
598
+
599
+ The linter source, documentation, and tests are available under the [MIT license](LICENSE).
600
+ Dependencies retain their own licenses. This license applies to this package;
601
+ the customer kit has its own terms.