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.
- bsa_linter-0.1.0b2/.gitignore +8 -0
- bsa_linter-0.1.0b2/LICENSE +21 -0
- bsa_linter-0.1.0b2/Makefile +25 -0
- bsa_linter-0.1.0b2/PKG-INFO +601 -0
- bsa_linter-0.1.0b2/README.md +582 -0
- bsa_linter-0.1.0b2/pyproject.toml +49 -0
- bsa_linter-0.1.0b2/pyrightconfig.json +6 -0
- bsa_linter-0.1.0b2/src/bsa_linter/__init__.py +1 -0
- bsa_linter-0.1.0b2/src/bsa_linter/architecture.py +672 -0
- bsa_linter-0.1.0b2/src/bsa_linter/cli.py +71 -0
- bsa_linter-0.1.0b2/src/bsa_linter/extract.py +356 -0
- bsa_linter-0.1.0b2/src/bsa_linter/py.typed +0 -0
- bsa_linter-0.1.0b2/src/bsa_linter/roles.py +63 -0
- bsa_linter-0.1.0b2/tests/__init__.py +0 -0
- bsa_linter-0.1.0b2/tests/mutations/check_rules.py +126 -0
- bsa_linter-0.1.0b2/tests/packaging/check_package.py +121 -0
- bsa_linter-0.1.0b2/tests/support.py +44 -0
- bsa_linter-0.1.0b2/tests/test_boundary_refactors.py +233 -0
- bsa_linter-0.1.0b2/tests/test_cli.py +23 -0
- bsa_linter-0.1.0b2/tests/test_composition.py +114 -0
- bsa_linter-0.1.0b2/tests/test_concept_collaborators.py +180 -0
- bsa_linter-0.1.0b2/tests/test_cycles.py +25 -0
- bsa_linter-0.1.0b2/tests/test_direction.py +126 -0
- bsa_linter-0.1.0b2/tests/test_extraction.py +24 -0
- bsa_linter-0.1.0b2/tests/test_layout.py +121 -0
- bsa_linter-0.1.0b2/tests/test_ownership.py +53 -0
- bsa_linter-0.1.0b2/tests/test_ownership_tree_establishment.py +2561 -0
- bsa_linter-0.1.0b2/tests/test_private_access.py +163 -0
- bsa_linter-0.1.0b2/tests/test_role_independence.py +80 -0
- bsa_linter-0.1.0b2/tests/test_rule_regressions.py +122 -0
- bsa_linter-0.1.0b2/tests/test_structural_invariants.py +269 -0
- bsa_linter-0.1.0b2/tests/test_surfaces.py +19 -0
- bsa_linter-0.1.0b2/uv.lock +371 -0
|
@@ -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.
|