smelt-cli 0.1.0__tar.gz → 0.1.2__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.
- smelt_cli-0.1.2/PKG-INFO +274 -0
- smelt_cli-0.1.2/README.md +259 -0
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/pyproject.toml +8 -3
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/pyproject.toml.orig +8 -3
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/analysis/__init__.py +2 -10
- smelt_cli-0.1.2/smelt/analysis/context.py +56 -0
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/analysis/files.py +4 -1
- smelt_cli-0.1.2/smelt/analysis/syntax.py +66 -0
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/cli/app.py +96 -23
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/cli/commands/__init__.py +1 -4
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/cli/commands/adopt.py +52 -10
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/cli/commands/discover.py +20 -14
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/cli/commands/info.py +22 -15
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/cli/support.py +19 -2
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/config/loader.py +15 -1
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/config/models.py +156 -203
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/config/validation.py +25 -49
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/diagnostics/debt.py +48 -10
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/diagnostics/dedupe.py +3 -4
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/diagnostics/render/machine.py +28 -0
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/diagnostics/render/text.py +68 -4
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/diagnostics/report.py +2 -0
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/diagnostics/violation.py +4 -7
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/docs.py +1 -1
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/engine/briefing.py +160 -103
- smelt_cli-0.1.2/smelt/engine/changes.py +57 -0
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/engine/check.py +133 -61
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/engine/inference.py +242 -134
- smelt_cli-0.1.2/smelt/engine/mirror_inference.py +95 -0
- smelt_cli-0.1.2/smelt/engine/paths.py +101 -0
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/model/architecture.py +30 -33
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/rules/base.py +2 -0
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/rules/common.py +29 -38
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/rules/dependencies/composition_root.py +5 -50
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/rules/dependencies/cycles.py +34 -9
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/rules/dependencies/features.py +28 -11
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/rules/dependencies/layers.py +14 -23
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/rules/meta.py +3 -3
- smelt_cli-0.1.2/smelt/rules/registry.py +32 -0
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/rules/structure/layout.py +39 -51
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/rules/structure/naming.py +2 -2
- smelt_cli-0.1.2/smelt/rules/testing/location.py +388 -0
- smelt_cli-0.1.0/PKG-INFO +0 -199
- smelt_cli-0.1.0/README.md +0 -187
- smelt_cli-0.1.0/smelt/analysis/context.py +0 -106
- smelt_cli-0.1.0/smelt/analysis/roles.py +0 -122
- smelt_cli-0.1.0/smelt/analysis/syntax.py +0 -246
- smelt_cli-0.1.0/smelt/analysis/types.py +0 -181
- smelt_cli-0.1.0/smelt/cli/commands/verify.py +0 -60
- smelt_cli-0.1.0/smelt/engine/architecture_map.py +0 -125
- smelt_cli-0.1.0/smelt/engine/changes.py +0 -81
- smelt_cli-0.1.0/smelt/engine/verify.py +0 -77
- smelt_cli-0.1.0/smelt/rules/code/common.py +0 -119
- smelt_cli-0.1.0/smelt/rules/code/construction.py +0 -172
- smelt_cli-0.1.0/smelt/rules/code/container.py +0 -104
- smelt_cli-0.1.0/smelt/rules/code/inheritance.py +0 -99
- smelt_cli-0.1.0/smelt/rules/code/roles.py +0 -100
- smelt_cli-0.1.0/smelt/rules/code/self_reference.py +0 -104
- smelt_cli-0.1.0/smelt/rules/registry.py +0 -135
- smelt_cli-0.1.0/smelt/rules/structure/roles.py +0 -63
- smelt_cli-0.1.0/smelt/rules/testing/__init__.py +0 -0
- smelt_cli-0.1.0/smelt/rules/testing/api.py +0 -115
- smelt_cli-0.1.0/smelt/rules/testing/bloat.py +0 -84
- smelt_cli-0.1.0/smelt/rules/testing/common.py +0 -238
- smelt_cli-0.1.0/smelt/rules/testing/location.py +0 -253
- smelt_cli-0.1.0/smelt/rules/testing/mocks.py +0 -294
- smelt_cli-0.1.0/smelt/rules/testing/patching.py +0 -152
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/__init__.py +0 -0
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/__main__.py +0 -0
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/analysis/graphs.py +0 -0
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/analysis/imports.py +0 -0
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/analysis/parsing.py +0 -0
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/cli/__init__.py +0 -0
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/cli/commands/check.py +0 -0
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/config/__init__.py +0 -0
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/config/discovery.py +0 -0
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/config/errors.py +0 -0
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/config/patterns.py +0 -0
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/config/schema.py +0 -0
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/diagnostics/__init__.py +0 -0
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/diagnostics/render/__init__.py +0 -0
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/diagnostics/suppressions.py +0 -0
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/engine/__init__.py +0 -0
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/model/__init__.py +0 -0
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/py.typed +0 -0
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/rules/__init__.py +0 -0
- {smelt_cli-0.1.0/smelt/rules/code → smelt_cli-0.1.2/smelt/rules/dependencies}/__init__.py +0 -0
- {smelt_cli-0.1.0 → smelt_cli-0.1.2}/smelt/rules/dependencies/third_party.py +0 -0
- {smelt_cli-0.1.0/smelt/rules/dependencies → smelt_cli-0.1.2/smelt/rules/structure}/__init__.py +0 -0
- {smelt_cli-0.1.0/smelt/rules/structure → smelt_cli-0.1.2/smelt/rules/testing}/__init__.py +0 -0
smelt_cli-0.1.2/PKG-INFO
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: smelt-cli
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Static guardrails for Python codebases maintained by humans and coding agents.
|
|
5
|
+
Author: Mathis Arends
|
|
6
|
+
Author-email: Mathis Arends <mathisarends27@gmail.com>
|
|
7
|
+
Requires-Dist: grimp>=3.17
|
|
8
|
+
Requires-Dist: pydantic>=2.13.5
|
|
9
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Project-URL: Repository, https://github.com/mathisarends/smelt
|
|
12
|
+
Project-URL: Issues, https://github.com/mathisarends/smelt/issues
|
|
13
|
+
Project-URL: Documentation, https://github.com/mathisarends/smelt#readme
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# smelt
|
|
17
|
+
|
|
18
|
+
Architecture guardrails for Python, built for reviewing what coding agents change. Describe
|
|
19
|
+
your features and layers in `smelt.yaml`; `smelt check` reports two kinds of findings, each
|
|
20
|
+
with the exact location, what is allowed instead and how to fix it:
|
|
21
|
+
|
|
22
|
+
- **Boundaries:** an import that crosses a layer, a feature or the composition root.
|
|
23
|
+
- **Test mirroring:** a test file that does not sit at the mirrored path of a source module.
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
Requires Python 3.12 or newer. Until this project has a PyPI release under its own
|
|
28
|
+
distribution name, install from this repository:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
uv tool install git+https://github.com/mathisarends/smelt.git
|
|
32
|
+
smelt --version
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
From a local checkout, use `uv tool install .`. The PyPI package named `smelt` belongs
|
|
36
|
+
to a different project; `pip install smelt` and bare `uvx smelt` install that project.
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
uv tool install smelt-cli
|
|
42
|
+
# or install into your project's environment:
|
|
43
|
+
pip install smelt-cli
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The PyPI package is named `smelt-cli`; the CLI command and Python package are both
|
|
47
|
+
named `smelt`. To run without installing, use `uvx --from smelt-cli smelt check`.
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
smelt init # draft smelt.yaml from the code (also uv workspaces)
|
|
53
|
+
smelt check # whole project
|
|
54
|
+
smelt check --changed # only what the working tree introduced since HEAD
|
|
55
|
+
smelt check --changed --base origin/main --format json
|
|
56
|
+
smelt context user # what applies to a feature, module or path
|
|
57
|
+
smelt explain SMT101 # rationale, examples and config keys of a rule
|
|
58
|
+
smelt rules # all rules with their default severity
|
|
59
|
+
smelt debt # record today's violations as known debt
|
|
60
|
+
smelt debt --prune # drop debt entries that were fixed
|
|
61
|
+
smelt config show # the resolved config
|
|
62
|
+
smelt config schema # the JSON schema of smelt.yaml
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Exit codes: `0` clean, `1` violations at or above `--fail-on`, `2` config or usage error.
|
|
66
|
+
A config entry that points at nothing (a mistyped package, a missing test root) is a
|
|
67
|
+
config error, not a silently disabled rule.
|
|
68
|
+
Explicit check paths must exist and contain analyzed source or test files; unknown
|
|
69
|
+
`--select`/`--ignore` prefixes are usage errors. JSON includes the reporting scope, file
|
|
70
|
+
count and active rule codes. `--config PATH` works before or after a subcommand.
|
|
71
|
+
With `--format json`, an exit `2` prints a JSON document as well, so an agent needs no
|
|
72
|
+
text parser: `{"schema_version": 1, "status": "error", "error": {...}}` with `kind`
|
|
73
|
+
(`usage`, `config` or `analysis`), `message`, the rejected `input` (`option`, `value`)
|
|
74
|
+
and, for config errors, the `config` file with each issue's YAML `location`. That covers
|
|
75
|
+
argument errors (`--fail-on bogus`, an unknown flag) whenever `--format json` is on the
|
|
76
|
+
command line; an invalid `--format` value itself is reported as argparse text.
|
|
77
|
+
|
|
78
|
+
`--changed` checks the base commit (HEAD, or the merge-base with `--base`) with today's
|
|
79
|
+
config and reports only violations that are new, wherever they show up. Old violations in
|
|
80
|
+
an edited file stay quiet, a new import cycle does not.
|
|
81
|
+
Changing an import's comment, formatting or alias does not make its existing boundary
|
|
82
|
+
violation new; an additional identical violating import still counts as new.
|
|
83
|
+
|
|
84
|
+
## A DDD layout
|
|
85
|
+
|
|
86
|
+
```yaml
|
|
87
|
+
version: 1
|
|
88
|
+
project:
|
|
89
|
+
root_packages: [backend, agent]
|
|
90
|
+
source_roots: [backend/src, libs/agent/src]
|
|
91
|
+
test_roots: [backend/tests, libs/agent/tests]
|
|
92
|
+
|
|
93
|
+
architecture:
|
|
94
|
+
features: {root: backend.features} # every child package is a feature
|
|
95
|
+
shared: [backend.shared, backend.env] # importable everywhere, imports no feature
|
|
96
|
+
composition_root: [backend.main, backend.app]
|
|
97
|
+
wiring: [backend.features.*.infrastructure.di]
|
|
98
|
+
modules: # packages outside the features
|
|
99
|
+
backend.platform: infrastructure
|
|
100
|
+
agent: infrastructure
|
|
101
|
+
layers:
|
|
102
|
+
domain: {path: domain}
|
|
103
|
+
application: {path: application, may_depend_on: [domain]}
|
|
104
|
+
infrastructure: {path: infrastructure, may_depend_on: [domain, application]}
|
|
105
|
+
presentation: {path: presentation, may_depend_on: [application, domain]}
|
|
106
|
+
cross_feature:
|
|
107
|
+
default: deny
|
|
108
|
+
allow:
|
|
109
|
+
- "application -> application" # between all features
|
|
110
|
+
- {from: session.presentation, to: auth.presentation}
|
|
111
|
+
|
|
112
|
+
tests:
|
|
113
|
+
layout: mirror
|
|
114
|
+
mirror: "{root}/{path}/test_{module}.py"
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
- **Layers** hold inside every feature: `domain` must not import `infrastructure`.
|
|
118
|
+
- **Features** may only import each other through `cross_feature.allow`. The string form
|
|
119
|
+
allows a layer pair for all features, the object form one direction between two.
|
|
120
|
+
- **Central packages** such as a platform or a workspace library get a layer under
|
|
121
|
+
`modules`. The layer rules then apply between them and the features (a feature's domain
|
|
122
|
+
must not import `backend.platform`), and they must not import features.
|
|
123
|
+
- **Wiring** modules (whole-segment `*` allowed) keep their feature and layer and may cross
|
|
124
|
+
layer boundaries. Feature wiring may also cross feature boundaries. Composition roots,
|
|
125
|
+
other wiring modules and their package facades may import declared wiring. Central or
|
|
126
|
+
shared wiring keeps the prohibition on importing features.
|
|
127
|
+
- Every module should belong somewhere: an unclassified module is exempt from all
|
|
128
|
+
layer and feature boundary checks, so SMT305 warns about it. Composition-root/wiring
|
|
129
|
+
import restrictions and configured cycle checks still apply.
|
|
130
|
+
|
|
131
|
+
`smelt init` infers most of this: features, layers, shared and settings modules, the
|
|
132
|
+
composition root including an app factory, wiring patterns, central packages by name and
|
|
133
|
+
the mirror pattern the existing tests follow. Review it before adopting its findings.
|
|
134
|
+
Its starter policy explicitly allows third-party packages and checks direct imports.
|
|
135
|
+
Review these decisions: to keep frameworks out of the core, set e.g.
|
|
136
|
+
`domain.third_party: {default: deny, allow: [pydantic]}` under `architecture.layers`.
|
|
137
|
+
Set `architecture.imports.transitive: true` to also detect layer dependencies through
|
|
138
|
+
re-exports, such as a domain importing an infrastructure provider from a feature's
|
|
139
|
+
`__init__.py`. Review the suggested `application -> application` pair too: it permits
|
|
140
|
+
that dependency between **every** feature.
|
|
141
|
+
|
|
142
|
+
`smelt context` describes the policy that applies to its target, including wiring and
|
|
143
|
+
composition-root exceptions, third-party defaults and direct/transitive coverage. It
|
|
144
|
+
accepts feature names, dotted module names and source paths, including planned `.py`
|
|
145
|
+
files under existing source packages. If a syntax error prevents analysis, the briefing
|
|
146
|
+
is still available and its violation counts are marked unavailable (`violations: null`
|
|
147
|
+
and `analysis_error` in JSON). Central and wiring modules have explicit JSON flags.
|
|
148
|
+
|
|
149
|
+
## Test mirroring
|
|
150
|
+
|
|
151
|
+
With `tests.layout: mirror` the path of a test decides: `tests/billing/test_invoice.py`
|
|
152
|
+
needs `app/billing/invoice.py`, and a package test `tests/billing/test_billing.py` needs
|
|
153
|
+
`app/billing/`. Not every module needs a test, but a test whose source is missing or
|
|
154
|
+
elsewhere is an error. The message names the fix where it can: the right directory, the
|
|
155
|
+
right file name (`test_session_infrastructure_repository.py` should be named
|
|
156
|
+
`test_repository.py`), a neighbouring module with a similar name, or a missing `{root}` in
|
|
157
|
+
the pattern.
|
|
158
|
+
|
|
159
|
+
`tests.mirror` sets the convention relative to the test root: the default
|
|
160
|
+
`{path}/test_{module}.py` drops the root package, `{root}/{path}/test_{module}.py` keeps it,
|
|
161
|
+
and `unit/{path}/{module}_test.py` puts tests under `tests/unit/` with a suffix. In a uv
|
|
162
|
+
workspace each test root mirrors the package of its own member. Deliberately unmirrored
|
|
163
|
+
tests go in `tests.unmirrored` (e.g. `["tests/e2e/**"]`); `tests.mirror_suffixes: true`
|
|
164
|
+
also allows `test_invoice_<topic>.py`.
|
|
165
|
+
|
|
166
|
+
Mirroring repeats file names: `auth/presentation/test_router.py` and
|
|
167
|
+
`user/presentation/test_router.py`. pytest's default import mode only tells them apart
|
|
168
|
+
inside packages, so without `__init__.py` in the test directories it stops with "import
|
|
169
|
+
file mismatch". Prefixing the names (`test_auth_presentation_router.py`) works around that
|
|
170
|
+
but breaks the mirror; set the import mode instead, which `smelt init` points out:
|
|
171
|
+
|
|
172
|
+
```toml
|
|
173
|
+
[tool.pytest.ini_options]
|
|
174
|
+
addopts = ["--import-mode=importlib"]
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Adopting it in an existing project
|
|
178
|
+
|
|
179
|
+
A first check often reports dozens of findings that come down to a few decisions. Each
|
|
180
|
+
import finding carries an `edge` in JSON (`"billing.application -> voice.domain"`; for
|
|
181
|
+
cycles, the cycle), and the text output ends with the edges behind several findings.
|
|
182
|
+
Settle each edge once: fix the code, or record an intended dependency as policy, e.g. a
|
|
183
|
+
`cross_feature.allow` entry. Accept what remains with a baseline:
|
|
184
|
+
|
|
185
|
+
`smelt debt` records today's violations in `.smelt/debt.json` and sets `debt:` in
|
|
186
|
+
`smelt.yaml`. `smelt check` then fails only on new violations, and SMT903 reports entries
|
|
187
|
+
that were fixed and can leave the file (`smelt debt --prune`).
|
|
188
|
+
Existing debt files remain readable. Run `smelt debt --prune` once before editing imports
|
|
189
|
+
covered by an older baseline to upgrade their fingerprints; pruning accepts no new debt.
|
|
190
|
+
New import fingerprints survive comments, aliases and formatting changes.
|
|
191
|
+
|
|
192
|
+
Override severities by rule name or code, e.g. `rules: {unclassified-module: off}`.
|
|
193
|
+
|
|
194
|
+
Silence a single finding inline, always with a reason:
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
from gateway.infra.sql import Repo # smelt: ignore[SMT101] -- migration tracked in #123
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Every rule has a page under [docs/rules](docs/rules/), and `smelt.schema.json` gives editors
|
|
201
|
+
autocompletion and a description for every key of `smelt.yaml`.
|
|
202
|
+
|
|
203
|
+
## Python versions
|
|
204
|
+
|
|
205
|
+
Smelt runs on Python 3.12 to 3.14 and parses your code with the Python it runs on. Code
|
|
206
|
+
that uses newer syntax (3.14's `except A, B:` or t-strings, 3.13's type parameter defaults)
|
|
207
|
+
needs smelt on that version, e.g. `uvx -p 3.14 --from smelt-cli smelt check`; the syntax error says so when
|
|
208
|
+
`requires-python` or `.python-version` targets a newer Python.
|
|
209
|
+
|
|
210
|
+
## Using Smelt with coding agents
|
|
211
|
+
|
|
212
|
+
Add this to your `AGENTS.md` or `CLAUDE.md`:
|
|
213
|
+
|
|
214
|
+
```md
|
|
215
|
+
Before implementing, run `smelt context <feature>` to see where code belongs.
|
|
216
|
+
After every change, run `smelt check --changed --format json`.
|
|
217
|
+
Do not finish while errors remain. Use `smelt explain <code>` when unsure.
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Suggested loop: `smelt context <feature>` → edit → `smelt check --changed` → fix → tests →
|
|
221
|
+
pre-commit → CI (full check). Prefer the cheapest verification that gives sufficient
|
|
222
|
+
confidence: a rename needs smelt plus a type checker, new behavior needs one focused
|
|
223
|
+
regression test.
|
|
224
|
+
|
|
225
|
+
## pre-commit
|
|
226
|
+
|
|
227
|
+
```yaml
|
|
228
|
+
repos:
|
|
229
|
+
- repo: https://github.com/mathisarends/smelt
|
|
230
|
+
rev: v0.1.2
|
|
231
|
+
hooks:
|
|
232
|
+
- id: smelt
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
The hook checks the whole project when Python files or `smelt.yaml` / `smelt.yml`
|
|
236
|
+
change. Checking only changed filenames can miss violations in modules that import them.
|
|
237
|
+
|
|
238
|
+
## GitHub Actions
|
|
239
|
+
|
|
240
|
+
CI checks the whole repository; `--changed` is for the agent loop.
|
|
241
|
+
|
|
242
|
+
```yaml
|
|
243
|
+
- uses: astral-sh/setup-uv@v6
|
|
244
|
+
- run: uvx --from smelt-cli smelt check --format github
|
|
245
|
+
# optional: code scanning
|
|
246
|
+
- run: uvx --from smelt-cli smelt check --format sarif > smelt.sarif || true
|
|
247
|
+
- uses: github/codeql-action/upload-sarif@v3
|
|
248
|
+
with:
|
|
249
|
+
sarif_file: smelt.sarif
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## Development
|
|
253
|
+
|
|
254
|
+
Requires [uv](https://docs.astral.sh/uv/).
|
|
255
|
+
|
|
256
|
+
```bash
|
|
257
|
+
uv sync # create .venv and install dev dependencies
|
|
258
|
+
uv run pre-commit install
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Common commands:
|
|
262
|
+
|
|
263
|
+
```bash
|
|
264
|
+
uv run pytest # run tests
|
|
265
|
+
uv run pytest --cov # run tests with coverage
|
|
266
|
+
uv run ruff check --fix . # lint
|
|
267
|
+
uv run ruff format . # format
|
|
268
|
+
uv run mypy # type-check
|
|
269
|
+
uv run pre-commit run --all-files # run all hooks
|
|
270
|
+
uv run smelt check # smelt checks itself
|
|
271
|
+
uv run python scripts/generate.py # refresh smelt.schema.json and docs/rules/
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/).
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# smelt
|
|
2
|
+
|
|
3
|
+
Architecture guardrails for Python, built for reviewing what coding agents change. Describe
|
|
4
|
+
your features and layers in `smelt.yaml`; `smelt check` reports two kinds of findings, each
|
|
5
|
+
with the exact location, what is allowed instead and how to fix it:
|
|
6
|
+
|
|
7
|
+
- **Boundaries:** an import that crosses a layer, a feature or the composition root.
|
|
8
|
+
- **Test mirroring:** a test file that does not sit at the mirrored path of a source module.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
Requires Python 3.12 or newer. Until this project has a PyPI release under its own
|
|
13
|
+
distribution name, install from this repository:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
uv tool install git+https://github.com/mathisarends/smelt.git
|
|
17
|
+
smelt --version
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
From a local checkout, use `uv tool install .`. The PyPI package named `smelt` belongs
|
|
21
|
+
to a different project; `pip install smelt` and bare `uvx smelt` install that project.
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
uv tool install smelt-cli
|
|
27
|
+
# or install into your project's environment:
|
|
28
|
+
pip install smelt-cli
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The PyPI package is named `smelt-cli`; the CLI command and Python package are both
|
|
32
|
+
named `smelt`. To run without installing, use `uvx --from smelt-cli smelt check`.
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
smelt init # draft smelt.yaml from the code (also uv workspaces)
|
|
38
|
+
smelt check # whole project
|
|
39
|
+
smelt check --changed # only what the working tree introduced since HEAD
|
|
40
|
+
smelt check --changed --base origin/main --format json
|
|
41
|
+
smelt context user # what applies to a feature, module or path
|
|
42
|
+
smelt explain SMT101 # rationale, examples and config keys of a rule
|
|
43
|
+
smelt rules # all rules with their default severity
|
|
44
|
+
smelt debt # record today's violations as known debt
|
|
45
|
+
smelt debt --prune # drop debt entries that were fixed
|
|
46
|
+
smelt config show # the resolved config
|
|
47
|
+
smelt config schema # the JSON schema of smelt.yaml
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Exit codes: `0` clean, `1` violations at or above `--fail-on`, `2` config or usage error.
|
|
51
|
+
A config entry that points at nothing (a mistyped package, a missing test root) is a
|
|
52
|
+
config error, not a silently disabled rule.
|
|
53
|
+
Explicit check paths must exist and contain analyzed source or test files; unknown
|
|
54
|
+
`--select`/`--ignore` prefixes are usage errors. JSON includes the reporting scope, file
|
|
55
|
+
count and active rule codes. `--config PATH` works before or after a subcommand.
|
|
56
|
+
With `--format json`, an exit `2` prints a JSON document as well, so an agent needs no
|
|
57
|
+
text parser: `{"schema_version": 1, "status": "error", "error": {...}}` with `kind`
|
|
58
|
+
(`usage`, `config` or `analysis`), `message`, the rejected `input` (`option`, `value`)
|
|
59
|
+
and, for config errors, the `config` file with each issue's YAML `location`. That covers
|
|
60
|
+
argument errors (`--fail-on bogus`, an unknown flag) whenever `--format json` is on the
|
|
61
|
+
command line; an invalid `--format` value itself is reported as argparse text.
|
|
62
|
+
|
|
63
|
+
`--changed` checks the base commit (HEAD, or the merge-base with `--base`) with today's
|
|
64
|
+
config and reports only violations that are new, wherever they show up. Old violations in
|
|
65
|
+
an edited file stay quiet, a new import cycle does not.
|
|
66
|
+
Changing an import's comment, formatting or alias does not make its existing boundary
|
|
67
|
+
violation new; an additional identical violating import still counts as new.
|
|
68
|
+
|
|
69
|
+
## A DDD layout
|
|
70
|
+
|
|
71
|
+
```yaml
|
|
72
|
+
version: 1
|
|
73
|
+
project:
|
|
74
|
+
root_packages: [backend, agent]
|
|
75
|
+
source_roots: [backend/src, libs/agent/src]
|
|
76
|
+
test_roots: [backend/tests, libs/agent/tests]
|
|
77
|
+
|
|
78
|
+
architecture:
|
|
79
|
+
features: {root: backend.features} # every child package is a feature
|
|
80
|
+
shared: [backend.shared, backend.env] # importable everywhere, imports no feature
|
|
81
|
+
composition_root: [backend.main, backend.app]
|
|
82
|
+
wiring: [backend.features.*.infrastructure.di]
|
|
83
|
+
modules: # packages outside the features
|
|
84
|
+
backend.platform: infrastructure
|
|
85
|
+
agent: infrastructure
|
|
86
|
+
layers:
|
|
87
|
+
domain: {path: domain}
|
|
88
|
+
application: {path: application, may_depend_on: [domain]}
|
|
89
|
+
infrastructure: {path: infrastructure, may_depend_on: [domain, application]}
|
|
90
|
+
presentation: {path: presentation, may_depend_on: [application, domain]}
|
|
91
|
+
cross_feature:
|
|
92
|
+
default: deny
|
|
93
|
+
allow:
|
|
94
|
+
- "application -> application" # between all features
|
|
95
|
+
- {from: session.presentation, to: auth.presentation}
|
|
96
|
+
|
|
97
|
+
tests:
|
|
98
|
+
layout: mirror
|
|
99
|
+
mirror: "{root}/{path}/test_{module}.py"
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
- **Layers** hold inside every feature: `domain` must not import `infrastructure`.
|
|
103
|
+
- **Features** may only import each other through `cross_feature.allow`. The string form
|
|
104
|
+
allows a layer pair for all features, the object form one direction between two.
|
|
105
|
+
- **Central packages** such as a platform or a workspace library get a layer under
|
|
106
|
+
`modules`. The layer rules then apply between them and the features (a feature's domain
|
|
107
|
+
must not import `backend.platform`), and they must not import features.
|
|
108
|
+
- **Wiring** modules (whole-segment `*` allowed) keep their feature and layer and may cross
|
|
109
|
+
layer boundaries. Feature wiring may also cross feature boundaries. Composition roots,
|
|
110
|
+
other wiring modules and their package facades may import declared wiring. Central or
|
|
111
|
+
shared wiring keeps the prohibition on importing features.
|
|
112
|
+
- Every module should belong somewhere: an unclassified module is exempt from all
|
|
113
|
+
layer and feature boundary checks, so SMT305 warns about it. Composition-root/wiring
|
|
114
|
+
import restrictions and configured cycle checks still apply.
|
|
115
|
+
|
|
116
|
+
`smelt init` infers most of this: features, layers, shared and settings modules, the
|
|
117
|
+
composition root including an app factory, wiring patterns, central packages by name and
|
|
118
|
+
the mirror pattern the existing tests follow. Review it before adopting its findings.
|
|
119
|
+
Its starter policy explicitly allows third-party packages and checks direct imports.
|
|
120
|
+
Review these decisions: to keep frameworks out of the core, set e.g.
|
|
121
|
+
`domain.third_party: {default: deny, allow: [pydantic]}` under `architecture.layers`.
|
|
122
|
+
Set `architecture.imports.transitive: true` to also detect layer dependencies through
|
|
123
|
+
re-exports, such as a domain importing an infrastructure provider from a feature's
|
|
124
|
+
`__init__.py`. Review the suggested `application -> application` pair too: it permits
|
|
125
|
+
that dependency between **every** feature.
|
|
126
|
+
|
|
127
|
+
`smelt context` describes the policy that applies to its target, including wiring and
|
|
128
|
+
composition-root exceptions, third-party defaults and direct/transitive coverage. It
|
|
129
|
+
accepts feature names, dotted module names and source paths, including planned `.py`
|
|
130
|
+
files under existing source packages. If a syntax error prevents analysis, the briefing
|
|
131
|
+
is still available and its violation counts are marked unavailable (`violations: null`
|
|
132
|
+
and `analysis_error` in JSON). Central and wiring modules have explicit JSON flags.
|
|
133
|
+
|
|
134
|
+
## Test mirroring
|
|
135
|
+
|
|
136
|
+
With `tests.layout: mirror` the path of a test decides: `tests/billing/test_invoice.py`
|
|
137
|
+
needs `app/billing/invoice.py`, and a package test `tests/billing/test_billing.py` needs
|
|
138
|
+
`app/billing/`. Not every module needs a test, but a test whose source is missing or
|
|
139
|
+
elsewhere is an error. The message names the fix where it can: the right directory, the
|
|
140
|
+
right file name (`test_session_infrastructure_repository.py` should be named
|
|
141
|
+
`test_repository.py`), a neighbouring module with a similar name, or a missing `{root}` in
|
|
142
|
+
the pattern.
|
|
143
|
+
|
|
144
|
+
`tests.mirror` sets the convention relative to the test root: the default
|
|
145
|
+
`{path}/test_{module}.py` drops the root package, `{root}/{path}/test_{module}.py` keeps it,
|
|
146
|
+
and `unit/{path}/{module}_test.py` puts tests under `tests/unit/` with a suffix. In a uv
|
|
147
|
+
workspace each test root mirrors the package of its own member. Deliberately unmirrored
|
|
148
|
+
tests go in `tests.unmirrored` (e.g. `["tests/e2e/**"]`); `tests.mirror_suffixes: true`
|
|
149
|
+
also allows `test_invoice_<topic>.py`.
|
|
150
|
+
|
|
151
|
+
Mirroring repeats file names: `auth/presentation/test_router.py` and
|
|
152
|
+
`user/presentation/test_router.py`. pytest's default import mode only tells them apart
|
|
153
|
+
inside packages, so without `__init__.py` in the test directories it stops with "import
|
|
154
|
+
file mismatch". Prefixing the names (`test_auth_presentation_router.py`) works around that
|
|
155
|
+
but breaks the mirror; set the import mode instead, which `smelt init` points out:
|
|
156
|
+
|
|
157
|
+
```toml
|
|
158
|
+
[tool.pytest.ini_options]
|
|
159
|
+
addopts = ["--import-mode=importlib"]
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Adopting it in an existing project
|
|
163
|
+
|
|
164
|
+
A first check often reports dozens of findings that come down to a few decisions. Each
|
|
165
|
+
import finding carries an `edge` in JSON (`"billing.application -> voice.domain"`; for
|
|
166
|
+
cycles, the cycle), and the text output ends with the edges behind several findings.
|
|
167
|
+
Settle each edge once: fix the code, or record an intended dependency as policy, e.g. a
|
|
168
|
+
`cross_feature.allow` entry. Accept what remains with a baseline:
|
|
169
|
+
|
|
170
|
+
`smelt debt` records today's violations in `.smelt/debt.json` and sets `debt:` in
|
|
171
|
+
`smelt.yaml`. `smelt check` then fails only on new violations, and SMT903 reports entries
|
|
172
|
+
that were fixed and can leave the file (`smelt debt --prune`).
|
|
173
|
+
Existing debt files remain readable. Run `smelt debt --prune` once before editing imports
|
|
174
|
+
covered by an older baseline to upgrade their fingerprints; pruning accepts no new debt.
|
|
175
|
+
New import fingerprints survive comments, aliases and formatting changes.
|
|
176
|
+
|
|
177
|
+
Override severities by rule name or code, e.g. `rules: {unclassified-module: off}`.
|
|
178
|
+
|
|
179
|
+
Silence a single finding inline, always with a reason:
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
from gateway.infra.sql import Repo # smelt: ignore[SMT101] -- migration tracked in #123
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Every rule has a page under [docs/rules](docs/rules/), and `smelt.schema.json` gives editors
|
|
186
|
+
autocompletion and a description for every key of `smelt.yaml`.
|
|
187
|
+
|
|
188
|
+
## Python versions
|
|
189
|
+
|
|
190
|
+
Smelt runs on Python 3.12 to 3.14 and parses your code with the Python it runs on. Code
|
|
191
|
+
that uses newer syntax (3.14's `except A, B:` or t-strings, 3.13's type parameter defaults)
|
|
192
|
+
needs smelt on that version, e.g. `uvx -p 3.14 --from smelt-cli smelt check`; the syntax error says so when
|
|
193
|
+
`requires-python` or `.python-version` targets a newer Python.
|
|
194
|
+
|
|
195
|
+
## Using Smelt with coding agents
|
|
196
|
+
|
|
197
|
+
Add this to your `AGENTS.md` or `CLAUDE.md`:
|
|
198
|
+
|
|
199
|
+
```md
|
|
200
|
+
Before implementing, run `smelt context <feature>` to see where code belongs.
|
|
201
|
+
After every change, run `smelt check --changed --format json`.
|
|
202
|
+
Do not finish while errors remain. Use `smelt explain <code>` when unsure.
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Suggested loop: `smelt context <feature>` → edit → `smelt check --changed` → fix → tests →
|
|
206
|
+
pre-commit → CI (full check). Prefer the cheapest verification that gives sufficient
|
|
207
|
+
confidence: a rename needs smelt plus a type checker, new behavior needs one focused
|
|
208
|
+
regression test.
|
|
209
|
+
|
|
210
|
+
## pre-commit
|
|
211
|
+
|
|
212
|
+
```yaml
|
|
213
|
+
repos:
|
|
214
|
+
- repo: https://github.com/mathisarends/smelt
|
|
215
|
+
rev: v0.1.2
|
|
216
|
+
hooks:
|
|
217
|
+
- id: smelt
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
The hook checks the whole project when Python files or `smelt.yaml` / `smelt.yml`
|
|
221
|
+
change. Checking only changed filenames can miss violations in modules that import them.
|
|
222
|
+
|
|
223
|
+
## GitHub Actions
|
|
224
|
+
|
|
225
|
+
CI checks the whole repository; `--changed` is for the agent loop.
|
|
226
|
+
|
|
227
|
+
```yaml
|
|
228
|
+
- uses: astral-sh/setup-uv@v6
|
|
229
|
+
- run: uvx --from smelt-cli smelt check --format github
|
|
230
|
+
# optional: code scanning
|
|
231
|
+
- run: uvx --from smelt-cli smelt check --format sarif > smelt.sarif || true
|
|
232
|
+
- uses: github/codeql-action/upload-sarif@v3
|
|
233
|
+
with:
|
|
234
|
+
sarif_file: smelt.sarif
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## Development
|
|
238
|
+
|
|
239
|
+
Requires [uv](https://docs.astral.sh/uv/).
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
uv sync # create .venv and install dev dependencies
|
|
243
|
+
uv run pre-commit install
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Common commands:
|
|
247
|
+
|
|
248
|
+
```bash
|
|
249
|
+
uv run pytest # run tests
|
|
250
|
+
uv run pytest --cov # run tests with coverage
|
|
251
|
+
uv run ruff check --fix . # lint
|
|
252
|
+
uv run ruff format . # format
|
|
253
|
+
uv run mypy # type-check
|
|
254
|
+
uv run pre-commit run --all-files # run all hooks
|
|
255
|
+
uv run smelt check # smelt checks itself
|
|
256
|
+
uv run python scripts/generate.py # refresh smelt.schema.json and docs/rules/
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "smelt-cli"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.2"
|
|
4
4
|
description = "Static guardrails for Python codebases maintained by humans and coding agents."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -14,11 +14,16 @@ dependencies = [
|
|
|
14
14
|
name = "Mathis Arends"
|
|
15
15
|
email = "mathisarends27@gmail.com"
|
|
16
16
|
|
|
17
|
+
[project.urls]
|
|
18
|
+
Repository = "https://github.com/mathisarends/smelt"
|
|
19
|
+
Issues = "https://github.com/mathisarends/smelt/issues"
|
|
20
|
+
Documentation = "https://github.com/mathisarends/smelt#readme"
|
|
21
|
+
|
|
17
22
|
[project.scripts]
|
|
18
23
|
smelt = "smelt.cli:main"
|
|
19
24
|
|
|
20
25
|
[build-system]
|
|
21
|
-
requires = ["uv_build>=0.12.
|
|
26
|
+
requires = ["uv_build>=0.12.19,<0.13"]
|
|
22
27
|
build-backend = "uv_build"
|
|
23
28
|
|
|
24
29
|
[tool.uv.build-backend]
|
|
@@ -112,6 +117,6 @@ dev = [
|
|
|
112
117
|
"pre-commit>=4.6",
|
|
113
118
|
"pytest>=9.1",
|
|
114
119
|
"pytest-cov>=7.1",
|
|
115
|
-
"ruff>=0.16.
|
|
120
|
+
"ruff>=0.16.9",
|
|
116
121
|
"types-pyyaml>=6.0.12.20260906",
|
|
117
122
|
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "smelt-cli"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.2"
|
|
4
4
|
description = "Static guardrails for Python codebases maintained by humans and coding agents."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [{ name = "Mathis Arends", email = "mathisarends27@gmail.com" }]
|
|
@@ -11,11 +11,16 @@ dependencies = [
|
|
|
11
11
|
"pyyaml>=6.0.3",
|
|
12
12
|
]
|
|
13
13
|
|
|
14
|
+
[project.urls]
|
|
15
|
+
Repository = "https://github.com/mathisarends/smelt"
|
|
16
|
+
Issues = "https://github.com/mathisarends/smelt/issues"
|
|
17
|
+
Documentation = "https://github.com/mathisarends/smelt#readme"
|
|
18
|
+
|
|
14
19
|
[project.scripts]
|
|
15
20
|
smelt = "smelt.cli:main"
|
|
16
21
|
|
|
17
22
|
[build-system]
|
|
18
|
-
requires = ["uv_build>=0.12.
|
|
23
|
+
requires = ["uv_build>=0.12.19,<0.13"]
|
|
19
24
|
build-backend = "uv_build"
|
|
20
25
|
|
|
21
26
|
[tool.uv.build-backend]
|
|
@@ -28,7 +33,7 @@ dev = [
|
|
|
28
33
|
"pre-commit>=4.6",
|
|
29
34
|
"pytest>=9.1",
|
|
30
35
|
"pytest-cov>=7.1",
|
|
31
|
-
"ruff>=0.16.
|
|
36
|
+
"ruff>=0.16.9",
|
|
32
37
|
"types-pyyaml>=6.0.12.20260906",
|
|
33
38
|
]
|
|
34
39
|
|
|
@@ -1,27 +1,19 @@
|
|
|
1
|
-
from smelt.analysis.context import AnalysisContext,
|
|
1
|
+
from smelt.analysis.context import AnalysisContext, Index
|
|
2
2
|
from smelt.analysis.files import FileIndex, SourceFile, TestFile
|
|
3
3
|
from smelt.analysis.imports import ImportDetail, ImportIndex, is_stdlib
|
|
4
4
|
from smelt.analysis.parsing import AnalysisError
|
|
5
|
-
from smelt.analysis.
|
|
6
|
-
from smelt.analysis.syntax import ClassInfo, ModuleSyntax, SyntaxIndex
|
|
7
|
-
from smelt.analysis.types import PyrightTypes, TypeIndex
|
|
5
|
+
from smelt.analysis.syntax import ModuleSyntax, SyntaxIndex
|
|
8
6
|
|
|
9
7
|
__all__ = [
|
|
10
8
|
"AnalysisContext",
|
|
11
9
|
"AnalysisError",
|
|
12
|
-
"ChangeSet",
|
|
13
|
-
"ClassInfo",
|
|
14
|
-
"FileChange",
|
|
15
10
|
"FileIndex",
|
|
16
11
|
"ImportDetail",
|
|
17
12
|
"ImportIndex",
|
|
18
13
|
"Index",
|
|
19
14
|
"ModuleSyntax",
|
|
20
|
-
"PyrightTypes",
|
|
21
|
-
"RoleIndex",
|
|
22
15
|
"SourceFile",
|
|
23
16
|
"SyntaxIndex",
|
|
24
17
|
"TestFile",
|
|
25
|
-
"TypeIndex",
|
|
26
18
|
"is_stdlib",
|
|
27
19
|
]
|