smelt-cli 0.1.2__tar.gz → 0.1.4__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.4/PKG-INFO +846 -0
- smelt_cli-0.1.4/README.md +830 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/pyproject.toml +3 -2
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/pyproject.toml.orig +3 -2
- smelt_cli-0.1.4/smelt/analysis/annotations.py +105 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/analysis/context.py +30 -0
- smelt_cli-0.1.4/smelt/analysis/contracts.py +221 -0
- smelt_cli-0.1.4/smelt/analysis/coverage.py +358 -0
- smelt_cli-0.1.4/smelt/analysis/definition_blocks.py +560 -0
- smelt_cli-0.1.4/smelt/analysis/errors.py +2 -0
- smelt_cli-0.1.4/smelt/analysis/exports.py +166 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/analysis/files.py +76 -32
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/analysis/imports.py +70 -45
- smelt_cli-0.1.4/smelt/analysis/ordering.py +377 -0
- smelt_cli-0.1.4/smelt/analysis/ordering_rewrite.py +232 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/analysis/parsing.py +10 -4
- smelt_cli-0.1.4/smelt/analysis/python_version.py +162 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/analysis/syntax.py +17 -0
- smelt_cli-0.1.4/smelt/analysis/test_syntax.py +482 -0
- smelt_cli-0.1.4/smelt/analysis/type_dispatch.py +193 -0
- smelt_cli-0.1.4/smelt/analysis/workspace.py +137 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/cli/app.py +45 -7
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/cli/commands/adopt.py +95 -20
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/cli/commands/check.py +18 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/cli/commands/info.py +4 -6
- smelt_cli-0.1.4/smelt/config/discovery.py +79 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/config/loader.py +39 -1
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/config/models.py +317 -2
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/config/validation.py +11 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/diagnostics/debt.py +68 -9
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/diagnostics/dedupe.py +1 -0
- smelt_cli-0.1.4/smelt/diagnostics/groups.py +108 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/diagnostics/render/machine.py +39 -6
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/diagnostics/render/text.py +62 -9
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/diagnostics/report.py +14 -1
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/diagnostics/violation.py +16 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/docs.py +7 -10
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/engine/briefing.py +208 -2
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/engine/changes.py +34 -8
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/engine/check.py +103 -15
- smelt_cli-0.1.4/smelt/engine/coverage.py +138 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/engine/inference.py +333 -69
- smelt_cli-0.1.4/smelt/engine/ordering_fixes.py +132 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/engine/paths.py +57 -0
- smelt_cli-0.1.4/smelt/engine/workspace.py +5 -0
- smelt_cli-0.1.4/smelt/rules/activation.py +17 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/rules/base.py +15 -32
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/rules/common.py +76 -3
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/rules/dependencies/composition_root.py +10 -1
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/rules/dependencies/cycles.py +102 -5
- smelt_cli-0.1.4/smelt/rules/dependencies/fanout.py +87 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/rules/dependencies/features.py +14 -3
- smelt_cli-0.1.4/smelt/rules/dependencies/interfaces.py +90 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/rules/dependencies/layers.py +16 -6
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/rules/dependencies/third_party.py +8 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/rules/meta.py +1 -0
- smelt_cli-0.1.4/smelt/rules/registry.py +69 -0
- smelt_cli-0.1.4/smelt/rules/structure/assignments.py +126 -0
- smelt_cli-0.1.4/smelt/rules/structure/contracts.py +77 -0
- smelt_cli-0.1.4/smelt/rules/structure/coverage.py +102 -0
- smelt_cli-0.1.4/smelt/rules/structure/headers.py +158 -0
- smelt_cli-0.1.4/smelt/rules/structure/import_style.py +151 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/rules/structure/layout.py +5 -4
- smelt_cli-0.1.4/smelt/rules/structure/naming.py +141 -0
- smelt_cli-0.1.4/smelt/rules/structure/ordering.py +70 -0
- smelt_cli-0.1.4/smelt/rules/structure/type_dispatch.py +85 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/rules/testing/location.py +197 -47
- smelt_cli-0.1.4/smelt/rules/testing/mocking.py +138 -0
- smelt_cli-0.1.4/smelt/rules/testing/outcomes.py +53 -0
- smelt_cli-0.1.4/smelt/rules/testing/private_access.py +66 -0
- smelt_cli-0.1.2/PKG-INFO +0 -274
- smelt_cli-0.1.2/README.md +0 -259
- smelt_cli-0.1.2/smelt/config/discovery.py +0 -49
- smelt_cli-0.1.2/smelt/rules/registry.py +0 -32
- smelt_cli-0.1.2/smelt/rules/structure/naming.py +0 -63
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/__init__.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/__main__.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/analysis/__init__.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/analysis/graphs.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/cli/__init__.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/cli/commands/__init__.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/cli/commands/discover.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/cli/support.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/config/__init__.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/config/errors.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/config/patterns.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/config/schema.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/diagnostics/__init__.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/diagnostics/render/__init__.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/diagnostics/suppressions.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/engine/__init__.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/engine/mirror_inference.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/model/__init__.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/model/architecture.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/py.typed +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/rules/__init__.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/rules/dependencies/__init__.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/rules/structure/__init__.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/rules/testing/__init__.py +0 -0
smelt_cli-0.1.4/PKG-INFO
ADDED
|
@@ -0,0 +1,846 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: smelt-cli
|
|
3
|
+
Version: 0.1.4
|
|
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: packaging>=24.2
|
|
9
|
+
Requires-Dist: pydantic>=2.13.5
|
|
10
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
11
|
+
Requires-Python: >=3.12
|
|
12
|
+
Project-URL: Repository, https://github.com/mathisarends/smelt
|
|
13
|
+
Project-URL: Issues, https://github.com/mathisarends/smelt/issues
|
|
14
|
+
Project-URL: Documentation, https://github.com/mathisarends/smelt#readme
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# smelt
|
|
18
|
+
|
|
19
|
+
Architecture guardrails for Python, built for reviewing what coding agents change. Describe
|
|
20
|
+
your features and layers in `smelt.yaml`; `smelt check` reports findings
|
|
21
|
+
with the exact location, what is allowed instead and how to fix it:
|
|
22
|
+
|
|
23
|
+
- **Boundaries:** an import that crosses a layer, a feature or the composition root.
|
|
24
|
+
- **Package contracts:** required modules and explicit public entry points.
|
|
25
|
+
- **Coverage:** production code without a declared architecture policy.
|
|
26
|
+
- **Test mirroring:** a test file that does not sit at the mirrored path of a source module.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
Requires Python 3.12 or newer.
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
uv tool install smelt-cli
|
|
34
|
+
# or install into your project's environment:
|
|
35
|
+
pip install smelt-cli
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The PyPI package is named `smelt-cli`; the CLI command and Python package are both
|
|
39
|
+
named `smelt`. To run without installing, use `uvx --from smelt-cli smelt check`.
|
|
40
|
+
From a local checkout, use `uv tool install .`. The PyPI package named `smelt` belongs
|
|
41
|
+
to a different project; `pip install smelt` and bare `uvx smelt` install that project.
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
Most rules are opt-in. Smelt enables declared architecture/package/test policies
|
|
46
|
+
without a second switch; only unknown-layer and suppression/debt diagnostics run
|
|
47
|
+
by default. Import cycles and unclassified-module hints require explicit selection.
|
|
48
|
+
See [rule activation and migration](docs/rule-selection.md) and the
|
|
49
|
+
[full catalog](docs/rules/README.md) for the decisions and precedence rules.
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
smelt init # draft smelt.yaml from the code (also uv workspaces)
|
|
53
|
+
smelt init --config other.yaml # write elsewhere; only that file is checked and overwritten
|
|
54
|
+
smelt check # whole project
|
|
55
|
+
smelt check --changed # only what the working tree introduced since HEAD
|
|
56
|
+
smelt check --changed --base origin/main --format json
|
|
57
|
+
smelt check --strict-coverage # fail on omitted or unclassified production code
|
|
58
|
+
smelt check --show-coverage --format json # include the per-file policy inventory
|
|
59
|
+
smelt context user # what applies to a feature, module or path
|
|
60
|
+
smelt explain SMT101 # rationale, examples and config keys of a rule
|
|
61
|
+
smelt rules # full catalog with activation and severity
|
|
62
|
+
smelt debt # record today's violations as known debt
|
|
63
|
+
smelt debt --prune # drop debt entries that were fixed
|
|
64
|
+
smelt config show # the resolved config
|
|
65
|
+
smelt config schema # the JSON schema of smelt.yaml
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Exit codes: `0` clean, `1` violations at or above `--fail-on`, `2` config or usage error.
|
|
69
|
+
A config entry that points at nothing (a mistyped package, a missing test root) is a
|
|
70
|
+
config error, not a silently disabled rule.
|
|
71
|
+
Explicit check paths must exist and contain analyzed source or test files; unknown
|
|
72
|
+
`--select`/`--ignore` prefixes are usage errors. JSON includes the reporting scope, file
|
|
73
|
+
count and active rule codes. `--config PATH` works before or after a subcommand.
|
|
74
|
+
A stored JSON report explains itself: `scope.mode` is `full` or `changed`, and
|
|
75
|
+
`comparison` names the requested `base`, the resolved `merge_base`, the
|
|
76
|
+
`compared_revision` and `head`, so `passed` from `--changed` reads as "nothing new since
|
|
77
|
+
that commit", not as a clean inventory. `coverage` lists the source and test roots,
|
|
78
|
+
workspace members that are not analyzed, module counts per classification, the
|
|
79
|
+
composition-root and wiring exemptions, third-party policies per layer, the import
|
|
80
|
+
settings (`type_checking`, `transitive`, `cycles`), cross-feature allowances as fields,
|
|
81
|
+
and a `policy_hash` that is equal for reports checked under the same resolved config.
|
|
82
|
+
The report's `project` names the config, project root and repository root. JSON
|
|
83
|
+
finding paths are project-relative; SARIF and GitHub annotation paths are rebased
|
|
84
|
+
to the repository root, including when using a nested `--config`.
|
|
85
|
+
`smelt context --format json` carries the same import settings, allowances and hash.
|
|
86
|
+
With `--format json`, an exit `2` prints a JSON document as well, so an agent needs no
|
|
87
|
+
text parser: `{"schema_version": 1, "status": "error", "error": {...}}` with `kind`
|
|
88
|
+
(`usage`, `config` or `analysis`), `message`, the rejected `input` (`option`, `value`)
|
|
89
|
+
and, for config errors, the `config` file with each issue's YAML `location`. That covers
|
|
90
|
+
argument errors (`--fail-on bogus`, an unknown flag) whenever `--format json` is on the
|
|
91
|
+
command line; an invalid `--format` value itself is reported as argparse text.
|
|
92
|
+
|
|
93
|
+
`--changed` checks the base commit (HEAD, or the merge-base with `--base`) with today's
|
|
94
|
+
config and reports only violations that are new, wherever they show up. Old violations in
|
|
95
|
+
an edited file stay quiet, a new import cycle does not.
|
|
96
|
+
Changing an import's comment, formatting or alias does not make its existing boundary
|
|
97
|
+
violation new; an additional identical violating import still counts as new.
|
|
98
|
+
|
|
99
|
+
## A DDD layout
|
|
100
|
+
|
|
101
|
+
```yaml
|
|
102
|
+
version: 1
|
|
103
|
+
project:
|
|
104
|
+
root_packages: [backend, agent]
|
|
105
|
+
source_roots: [backend/src, libs/agent/src]
|
|
106
|
+
test_roots: [backend/tests, libs/agent/tests]
|
|
107
|
+
|
|
108
|
+
architecture:
|
|
109
|
+
features: {root: backend.features} # every child package is a feature
|
|
110
|
+
shared: [backend.shared, backend.env] # importable everywhere, imports no feature
|
|
111
|
+
composition_root: [backend.main, backend.app]
|
|
112
|
+
wiring: [backend.features.*.infrastructure.di]
|
|
113
|
+
modules: # packages outside the features
|
|
114
|
+
backend.platform: infrastructure
|
|
115
|
+
agent: infrastructure
|
|
116
|
+
layers:
|
|
117
|
+
domain: {path: domain}
|
|
118
|
+
application: {path: application, may_depend_on: [domain]}
|
|
119
|
+
infrastructure: {path: infrastructure, may_depend_on: [domain, application]}
|
|
120
|
+
presentation: {path: presentation, may_depend_on: [application, domain]}
|
|
121
|
+
cross_feature:
|
|
122
|
+
default: deny
|
|
123
|
+
allow:
|
|
124
|
+
- "application -> application" # between all features
|
|
125
|
+
- {from: session.presentation, to: auth.presentation}
|
|
126
|
+
|
|
127
|
+
tests:
|
|
128
|
+
layout: mirror
|
|
129
|
+
mirror: "{root}/{path}/test_{module}.py"
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
- **Layers** hold inside every feature: `domain` must not import `infrastructure`.
|
|
133
|
+
- **Features** may only import each other through `cross_feature.allow`. The string form
|
|
134
|
+
allows a layer pair for all features, the object form one direction between two.
|
|
135
|
+
- **Central packages** such as a platform or a workspace library get a layer under
|
|
136
|
+
`modules`. The layer rules then apply between them and the features (a feature's domain
|
|
137
|
+
must not import `backend.platform`), and they must not import features.
|
|
138
|
+
- **Wiring** modules (whole-segment `*` allowed) keep their feature and layer and may cross
|
|
139
|
+
layer boundaries. Feature wiring may also cross feature boundaries. Composition roots,
|
|
140
|
+
other wiring modules and their package facades may import declared wiring. Central or
|
|
141
|
+
shared wiring keeps the prohibition on importing features.
|
|
142
|
+
- Every module should belong somewhere: an unclassified module is exempt from all
|
|
143
|
+
layer and feature boundary checks, so SMT305 warns about it. Composition-root/wiring
|
|
144
|
+
import restrictions and configured cycle checks still apply.
|
|
145
|
+
|
|
146
|
+
`smelt init` infers most of this: features, layers, shared and settings modules, the
|
|
147
|
+
composition root including an app factory (imported or named as a `"backend.app:app"`
|
|
148
|
+
server target), wiring patterns, central packages by name and
|
|
149
|
+
the mirror pattern the existing tests follow. Review it before adopting its findings.
|
|
150
|
+
It follows the names a composition root imports through package facades into their
|
|
151
|
+
definitions (`FEATURES = (chat.feature, ...)`); a module whose definition is built from
|
|
152
|
+
providers, such as a `feature.py` assembling `TelegramProvider`, becomes wiring, and the
|
|
153
|
+
config comment shows the chain. Merely importing framework types changes nothing. An
|
|
154
|
+
unclassified package only the root uses is proposed as a commented-out candidate with its
|
|
155
|
+
chain and confidence, never applied silently.
|
|
156
|
+
In a uv workspace it lists every declared member with the packages it found or why it
|
|
157
|
+
skipped it (`tool.uv.workspace.exclude`, no Python package); namespace packages without
|
|
158
|
+
`__init__.py` count under `src/` and where `tool.uv.build-backend` declares them.
|
|
159
|
+
Its starter policy explicitly allows third-party packages and checks direct imports.
|
|
160
|
+
Review these decisions: to keep frameworks out of the core, set e.g.
|
|
161
|
+
`domain.third_party: {default: deny, allow: [pydantic]}` under `architecture.layers`.
|
|
162
|
+
Set `architecture.imports.transitive: true` to also detect layer dependencies through
|
|
163
|
+
re-exports, such as a domain importing an infrastructure provider from a feature's
|
|
164
|
+
`__init__.py`. Named re-exports also retain their feature, shared and composition-root
|
|
165
|
+
restrictions. The starter leaves `cross_feature.allow` empty; declare intended
|
|
166
|
+
feature relationships explicitly. A global `application -> application` pair permits
|
|
167
|
+
that dependency between **every** feature.
|
|
168
|
+
|
|
169
|
+
`smelt context` describes the policy that applies to its target, including wiring and
|
|
170
|
+
composition-root exceptions, third-party defaults and direct/transitive coverage. It
|
|
171
|
+
accepts feature names, dotted module names and source paths, including planned `.py`
|
|
172
|
+
files under existing source packages. If a syntax error prevents analysis, the briefing
|
|
173
|
+
is still available and its violation counts are marked unavailable (`violations: null`
|
|
174
|
+
and `analysis_error` in JSON). Central and wiring modules have explicit JSON flags.
|
|
175
|
+
|
|
176
|
+
## Strict policy coverage
|
|
177
|
+
|
|
178
|
+
Enable `project.coverage: strict`, or use `smelt check --strict-coverage`, to fail
|
|
179
|
+
when production source escapes package selection or has no architecture role.
|
|
180
|
+
Folder names, layer names and frameworks come from your policy. Flat libraries,
|
|
181
|
+
feature-only architectures and custom layers work without adopting a DDD layout.
|
|
182
|
+
Outside uv workspaces, declare the relevant import roots in `source_roots`.
|
|
183
|
+
Unreadable or invalid workspace metadata is an analysis error in strict mode;
|
|
184
|
+
configured mode records discovery uncertainty and marks coverage incomplete.
|
|
185
|
+
The inventory discovers Python files independently of `root_packages`, under the
|
|
186
|
+
configured source roots and declared uv workspace members. Packaging exclusions
|
|
187
|
+
in `tool.uv.workspace.exclude` do not hide source from this inventory.
|
|
188
|
+
|
|
189
|
+
```yaml
|
|
190
|
+
project:
|
|
191
|
+
root_packages: [backend, client]
|
|
192
|
+
source_roots: [backend/src, libs/client/src]
|
|
193
|
+
coverage: strict
|
|
194
|
+
exclude:
|
|
195
|
+
- path: "libs/client/src/client/generated/**"
|
|
196
|
+
reason: "Generated SDK; regeneration and drift are checked separately."
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Each discovered source has one status:
|
|
200
|
+
|
|
201
|
+
- `governed`: a declared layer, feature-only policy, shared, central, wiring or
|
|
202
|
+
composition-root role applies.
|
|
203
|
+
- `facade`: a package initializer containing imports, documentation and package
|
|
204
|
+
metadata, including guarded imports and the standard package-version lookup.
|
|
205
|
+
Implementation functions and classes in an unclassified initializer are gaps.
|
|
206
|
+
- `unclassified`: analyzed source has no architecture role.
|
|
207
|
+
- `uncovered`: source exists outside the selected packages or is not an importable
|
|
208
|
+
module. The diagnostic names its package and import root where possible.
|
|
209
|
+
Loose scripts need a governed package or a reasoned tooling exclusion;
|
|
210
|
+
`root_packages` selects package directories rather than individual Python files.
|
|
211
|
+
- `excluded`: an explicit project exclusion applies. Strict mode requires a
|
|
212
|
+
`{path, reason}` entry with a nonempty reason; string globs remain supported in
|
|
213
|
+
the default `configured` mode.
|
|
214
|
+
|
|
215
|
+
`coverage.complete` means every discovered production source has a policy, is a
|
|
216
|
+
package facade, or has a reasoned exclusion. It describes policy assignment;
|
|
217
|
+
`scope.rules` and `coverage.imports` describe the checks actually run. Correct
|
|
218
|
+
behavior still needs type checking and tests. Configured test roots, workspace
|
|
219
|
+
`tests/` and `test/` directories, hidden directories and environment/cache
|
|
220
|
+
directories are outside production discovery. Independent runtimes outside the
|
|
221
|
+
declared roots/workspace need their own configuration and check.
|
|
222
|
+
|
|
223
|
+
The normal JSON report includes counts and scan roots. Add `--show-coverage` for
|
|
224
|
+
the per-file inventory, including feature, layer, source root, policy references
|
|
225
|
+
and exclusion reason. Text output groups related missing files into one decision
|
|
226
|
+
while JSON retains every path. The text summary also reports incomplete policy
|
|
227
|
+
assignment in `configured` mode, which preserves the existing exit behavior.
|
|
228
|
+
|
|
229
|
+
Strict coverage findings (`SMT306`) remain errors even in `--changed`, path-scoped
|
|
230
|
+
and `--select` runs. Inline/config ignores and debt cannot hide them; disabling or
|
|
231
|
+
demoting SMT306 in strict mode is rejected. Recording or pruning debt requires
|
|
232
|
+
complete coverage first. Resolve the placement or document a deliberate exclusion.
|
|
233
|
+
|
|
234
|
+
`smelt context <path> --format json` includes the file's coverage status and policy.
|
|
235
|
+
It also explains uncovered source discovered outside `root_packages`. A planned
|
|
236
|
+
path reports whether its placement has a role without claiming the file has been
|
|
237
|
+
analyzed. Use this before writing code, then run the strict check after editing.
|
|
238
|
+
|
|
239
|
+
## Package contracts
|
|
240
|
+
|
|
241
|
+
Declare a repeated structure or public interface explicitly. Contracts apply to
|
|
242
|
+
Python packages matched by a dotted pattern, independently of feature or layer
|
|
243
|
+
names. A whole-segment `*` in `match` selects one package segment.
|
|
244
|
+
|
|
245
|
+
```yaml
|
|
246
|
+
structure:
|
|
247
|
+
contracts:
|
|
248
|
+
feature-shape:
|
|
249
|
+
match: app.features.*
|
|
250
|
+
required: [application.api, domain]
|
|
251
|
+
public: [application.api]
|
|
252
|
+
library-api:
|
|
253
|
+
match: toolkit
|
|
254
|
+
required: [models]
|
|
255
|
+
public: ['.']
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
This requires every matching feature to contain an `application.api` module or
|
|
259
|
+
package and a `domain` module or package. Callers outside that feature may import
|
|
260
|
+
only `application.api`. Internal code may use its own implementation modules.
|
|
261
|
+
`toolkit` exposes its package facade; `toolkit.models` remains private to outside
|
|
262
|
+
callers, while the facade can deliberately re-export symbols from it.
|
|
263
|
+
|
|
264
|
+
- `required` contains exact relative dotted module names. Namespace packages and
|
|
265
|
+
split source roots work too. Excluded files do not fulfill a contract.
|
|
266
|
+
- `public` entries are relative module patterns. `'.'` means the matching package
|
|
267
|
+
itself; `api` exposes exactly that module/package, and `api.**` deliberately
|
|
268
|
+
exposes that subtree. Each pattern must match analyzed code. Omit `public` to
|
|
269
|
+
check structure only; `public: []` closes the package to outside imports.
|
|
270
|
+
- Structure and interface contracts are additive: an import must satisfy every applicable contract.
|
|
271
|
+
Wiring and composition roots follow the same interface rules. Existing layer
|
|
272
|
+
and feature restrictions still apply, and public entries do not classify code
|
|
273
|
+
into architecture roles or grant cross-feature access.
|
|
274
|
+
- SMT307 reports missing required modules or public entry points. A `match`
|
|
275
|
+
pattern selecting no analyzed package is a configuration error. SMT107 reports
|
|
276
|
+
private imports, including explicit module-valued aliases through facades;
|
|
277
|
+
intentional symbol re-exports through a public entry point are permitted.
|
|
278
|
+
- These are static module contracts. They do not check `__all__`, arbitrary
|
|
279
|
+
attribute access, dynamic loading, symbol-level visibility, or behavior. Module
|
|
280
|
+
presence alone does not establish a useful implementation; choose contracts
|
|
281
|
+
that reflect an intentional design, rather than creating empty template files.
|
|
282
|
+
|
|
283
|
+
`smelt context` includes applicable contracts, required/public modules, missing
|
|
284
|
+
entries and the visibility of an existing or planned module. Check JSON includes
|
|
285
|
+
resolved contracts under `coverage.contracts`. `smelt explain SMT107` and
|
|
286
|
+
`smelt explain SMT307` describe the rules. No contract is inferred automatically
|
|
287
|
+
or enabled by default for consumer projects.
|
|
288
|
+
|
|
289
|
+
### Module names without private prefixes
|
|
290
|
+
|
|
291
|
+
Require descriptive module/package names while keeping private helpers inside
|
|
292
|
+
them:
|
|
293
|
+
|
|
294
|
+
```yaml
|
|
295
|
+
structure:
|
|
296
|
+
contracts:
|
|
297
|
+
module-names:
|
|
298
|
+
match: app
|
|
299
|
+
private_modules: forbid
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
SMT312 (`no-private-modules`) rejects `_service.py`, `_internal/` and other
|
|
303
|
+
underscore-prefixed module/package names, including double-underscore names.
|
|
304
|
+
Python's special `__init__.py` and `__main__.py` files are allowed; arbitrary names
|
|
305
|
+
such as `__service__.py` are still checked. This exception applies to files, not
|
|
306
|
+
dunder-named package directories.
|
|
307
|
+
|
|
308
|
+
Use `service.py` with public entry functions and `_helper` functions instead.
|
|
309
|
+
The rule checks names independently of exports: a private-prefixed module is
|
|
310
|
+
still a finding when it declares `__all__` or its symbols are re-exported. Function,
|
|
311
|
+
method, class and attribute names are unaffected. This naming convention does
|
|
312
|
+
not change the public entry points declared by architecture API contracts.
|
|
313
|
+
|
|
314
|
+
Omitting `private_modules` adds no naming policy. A more specific package can set
|
|
315
|
+
`private_modules: allow`; contradictory policies on the same concrete package
|
|
316
|
+
are config errors. Existing reasoned `ignore` entries can cover individual legacy
|
|
317
|
+
modules. Excluded source and test directories follow the normal analysis scope.
|
|
318
|
+
Namespace packages and split import roots are supported, with one finding per
|
|
319
|
+
offending logical package rather than duplicate findings for its portions.
|
|
320
|
+
|
|
321
|
+
`context` exposes the effective convention as `naming_policy`, including planned
|
|
322
|
+
files. Check JSON includes it in `coverage.contracts`. Rename deliberately and
|
|
323
|
+
update imports and facade exports; Smelt never renames files or symbols itself.
|
|
324
|
+
|
|
325
|
+
### Separate instance attribute assignments
|
|
326
|
+
|
|
327
|
+
To keep field initialization readable, opt into `SMT314`:
|
|
328
|
+
|
|
329
|
+
```yaml
|
|
330
|
+
structure:
|
|
331
|
+
contracts:
|
|
332
|
+
instance-fields:
|
|
333
|
+
match: app
|
|
334
|
+
instance_assignments: separate
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
This reports `self.log, self.control = log, control` and chained assignments such
|
|
338
|
+
as `self.log = self.control = value`. Prefer:
|
|
339
|
+
|
|
340
|
+
```python
|
|
341
|
+
self.log = log
|
|
342
|
+
self.control = control
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
The rule checks assignments writing multiple direct attributes of an instance
|
|
346
|
+
method's first positional parameter, including differently named receivers and
|
|
347
|
+
async methods. Local unpacking stays valid. Mixed unpacking with only one direct
|
|
348
|
+
instance target is allowed; nested object attributes and assignments in nested
|
|
349
|
+
helper bodies are outside the check. Nested classes use their own receiver.
|
|
350
|
+
Recognized static/class methods are excluded; arbitrary decorator and assignment
|
|
351
|
+
aliases are not inferred. Only analyzed production modules are covered.
|
|
352
|
+
|
|
353
|
+
The closest explicit package policy wins; `instance_assignments: allow` exempts
|
|
354
|
+
a package. The effective policy appears in `context`, including planned modules.
|
|
355
|
+
This is an optional readability convention. There is no autofix: parallel
|
|
356
|
+
assignment, properties, swaps and unpacking failures can change behavior when
|
|
357
|
+
split. When unpacking a computed pair, consider unpacking into locals first and
|
|
358
|
+
then assigning each field while preserving evaluation timing.
|
|
359
|
+
|
|
360
|
+
### Type dispatch with match/case
|
|
361
|
+
|
|
362
|
+
`SMT315 prefer-match-type-dispatch` suggests reviewing longer positive
|
|
363
|
+
`isinstance`/`elif` chains over the same simple name:
|
|
364
|
+
|
|
365
|
+
```yaml
|
|
366
|
+
structure:
|
|
367
|
+
contracts:
|
|
368
|
+
event-dispatch:
|
|
369
|
+
match: app
|
|
370
|
+
type_dispatch:
|
|
371
|
+
style: match
|
|
372
|
+
min_branches: 3
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
The default threshold is three type-tested branches; it counts branches rather
|
|
376
|
+
than members of a type tuple/union. The closest policy wins. Use `style: allow`
|
|
377
|
+
for an exception and `rules: {SMT315: error}` if this convention must fail CI.
|
|
378
|
+
The default severity is a hint, since class-pattern equivalence needs review.
|
|
379
|
+
|
|
380
|
+
```python
|
|
381
|
+
match event:
|
|
382
|
+
case OutputValidated() | OutputValidationFailed():
|
|
383
|
+
if event.call_id is not None:
|
|
384
|
+
control_calls.add(event.call_id)
|
|
385
|
+
case ToolCalled() if event.call.id not in control_calls:
|
|
386
|
+
calls[event.call.id] = event.call
|
|
387
|
+
yield ToolStarted(event.call)
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
An independent preceding `if isinstance(event, SessionEvent): events.append(event)`
|
|
391
|
+
must stay independent: an event may need both observation and dispatch. An inner
|
|
392
|
+
body condition must not become a case guard without checking fallthrough: when
|
|
393
|
+
an OutputValidated event has no call ID, its original branch still consumes it.
|
|
394
|
+
Existing and-guards are recorded separately from body conditions.
|
|
395
|
+
|
|
396
|
+
Supported heads use the builtin isinstance or an unambiguous top-level builtins
|
|
397
|
+
import alias, a simple subject name and type names/dotted names/tuples/unions.
|
|
398
|
+
Additional and-guards are allowed. Independent if statements, mixed subjects,
|
|
399
|
+
negative/or tests, type factories, parameterized types, computed subjects and
|
|
400
|
+
unsupported branches are not combined. One hint describes the whole supported
|
|
401
|
+
chain; elif suffixes are not reported independently.
|
|
402
|
+
|
|
403
|
+
Checker names bound anywhere in a file are conservatively excluded unless they
|
|
404
|
+
are a single recognized top-level builtin import. A parameter in another function
|
|
405
|
+
can therefore suppress the same checker spelling for the file. Star imports and
|
|
406
|
+
recognized builtin-module mutations also disable detection. Assignment aliases,
|
|
407
|
+
dynamic namespace changes and runtime type identities are not inferred; a type
|
|
408
|
+
name may refer to something unsuitable for a class pattern.
|
|
409
|
+
|
|
410
|
+
There is no autofix. Match evaluates its subject once, and subclasses, custom
|
|
411
|
+
instance checks, guard effects and runtime type values need manual review. Declared
|
|
412
|
+
Python minima before 3.10 are excluded; unknown minima retain an explicit version
|
|
413
|
+
review requirement. JSON exposes the subject, branch types, guards and Python
|
|
414
|
+
target; context exposes the policy for existing and planned modules. Only analyzed
|
|
415
|
+
production sources are covered. Changed/debt comparisons track branch identities
|
|
416
|
+
and multiplicity, so an additional branch cannot hide behind an accepted chain.
|
|
417
|
+
See the Python reference for
|
|
418
|
+
[match semantics](https://docs.python.org/3/reference/compound_stmts.html#the-match-statement).
|
|
419
|
+
|
|
420
|
+
### Relative and absolute imports
|
|
421
|
+
|
|
422
|
+
Declare the style explicitly, globally for a root package or for selected
|
|
423
|
+
packages. This example uses absolute imports by default and relative imports
|
|
424
|
+
within infrastructure packages where at most one leading dot is needed:
|
|
425
|
+
|
|
426
|
+
```yaml
|
|
427
|
+
structure:
|
|
428
|
+
contracts:
|
|
429
|
+
default-style:
|
|
430
|
+
match: app
|
|
431
|
+
imports: {style: absolute}
|
|
432
|
+
adapter-style:
|
|
433
|
+
match: app.features.*.infrastructure
|
|
434
|
+
imports: {style: relative, max_relative_level: 1}
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
In the relative scope, `from .models import Record` is preferred over
|
|
438
|
+
`from app.features.users.infrastructure.models import Record`. An import requiring
|
|
439
|
+
`from ..domain import User` instead uses the absolute path. The limit counts
|
|
440
|
+
leading dots; set `max_relative_level: 2` to permit and prefer parent imports up
|
|
441
|
+
to two dots. Imports between different root packages and third-party/standard
|
|
442
|
+
library imports stay absolute. `style: absolute` requires absolute imports even
|
|
443
|
+
for siblings. Omitting `imports` preserves either form.
|
|
444
|
+
|
|
445
|
+
The most specific enclosing package with an `imports` policy wins. Conflicting
|
|
446
|
+
styles for the same concrete package are config errors. Initializers and namespace
|
|
447
|
+
packages use their logical package, independently of filesystem source roots.
|
|
448
|
+
SMT308 reports the expected form without rewriting bindings or aliases. As with
|
|
449
|
+
other import rules, `architecture.imports.type_checking` controls guarded imports.
|
|
450
|
+
Ruff's [TID252](https://docs.astral.sh/ruff/rules/relative-imports/) already covers
|
|
451
|
+
forbidding parent-relative imports; Smelt additionally checks the preferred form
|
|
452
|
+
under these package contracts.
|
|
453
|
+
|
|
454
|
+
### Suggestions to bundle implementation imports
|
|
455
|
+
|
|
456
|
+
Several imports from the same package can suggest a missing public facade:
|
|
457
|
+
|
|
458
|
+
```python
|
|
459
|
+
from app.features.users.application.get_user import GetUser
|
|
460
|
+
from app.features.users.application.update_user import UpdateUser
|
|
461
|
+
from app.features.users.application.delete_user import DeleteUser
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
Enable an advisory threshold on the package that would own the common API:
|
|
465
|
+
|
|
466
|
+
```yaml
|
|
467
|
+
structure:
|
|
468
|
+
contracts:
|
|
469
|
+
application-api:
|
|
470
|
+
match: app.features.*.application
|
|
471
|
+
bundle_imports_at: 3
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
SMT108 suggests the matching `application` package facade once one outside file
|
|
475
|
+
imports at least three distinct implementation modules there. Repeated imports,
|
|
476
|
+
multiple symbols from one module, and imports in separate callers do not inflate
|
|
477
|
+
the count. Imports from inside the package and symbols already exported by its
|
|
478
|
+
facade do not count. The threshold is configurable and disabled when omitted.
|
|
479
|
+
|
|
480
|
+
After deliberately defining the exports, the caller can use a combined import
|
|
481
|
+
such as `from app.features.users.application import DeleteUser, GetUser, UpdateUser`.
|
|
482
|
+
Review dependency direction and cycles before adding exports. This is a `hint`
|
|
483
|
+
by default; it does not fail the usual error gate. Choose `public: ['.']` instead
|
|
484
|
+
to require facade-only imports regardless of their count. No facade or import is
|
|
485
|
+
rewritten automatically.
|
|
486
|
+
|
|
487
|
+
### Module headers and Python-version-aware annotations
|
|
488
|
+
|
|
489
|
+
Module docstrings and future imports can be governed independently:
|
|
490
|
+
|
|
491
|
+
```yaml
|
|
492
|
+
# Optional override of the minimum supported version, when metadata is absent:
|
|
493
|
+
project:
|
|
494
|
+
python_version: "3.14"
|
|
495
|
+
|
|
496
|
+
structure:
|
|
497
|
+
contracts:
|
|
498
|
+
source-conventions:
|
|
499
|
+
match: app
|
|
500
|
+
module_docstrings: forbid
|
|
501
|
+
future_annotations: when-needed
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
`module_docstrings: forbid` enables SMT309 for the initial module docstring,
|
|
505
|
+
including package initializers. Class/function documentation and ordinary string
|
|
506
|
+
values are unaffected. Use `allow` for an exception in a more specific package;
|
|
507
|
+
omitting the field adds no policy.
|
|
508
|
+
|
|
509
|
+
`future_annotations` supports three modes:
|
|
510
|
+
|
|
511
|
+
- `allow`: preserve the import, including a deliberate runtime compatibility use.
|
|
512
|
+
- `forbid`: SMT310 rejects `from __future__ import annotations` at any version.
|
|
513
|
+
- `when-needed`: SMT310 rejects it for a declared minimum of Python 3.14 or newer.
|
|
514
|
+
With an earlier or unknown minimum, SMT311 gives a conservative review hint for
|
|
515
|
+
modules with no detected postponement need. It retains imports for unresolved
|
|
516
|
+
or quoted forward references, `TYPE_CHECKING`, runtime annotation inspection,
|
|
517
|
+
decorators, inherited models and unknown annotation expressions. Builtin
|
|
518
|
+
generics and unions are considered with their supported Python version.
|
|
519
|
+
|
|
520
|
+
Version detection uses the nearest project's `requires-python` in
|
|
521
|
+
`pyproject.toml`, with an explicit `project.python_version` taking precedence.
|
|
522
|
+
The configured version means the **minimum supported version**. The interpreter
|
|
523
|
+
running Smelt and `.python-version` are not evidence that older Python releases
|
|
524
|
+
are unsupported. Monorepo members keep their own declared requirements; an
|
|
525
|
+
independent member with missing/invalid metadata stays unknown. Lower bounds are
|
|
526
|
+
validated with Python packaging specifiers; exclusions alone never raise the
|
|
527
|
+
inferred minimum. For external source roots, discovery also checks their import
|
|
528
|
+
root's parent; declare an override when the project boundary is otherwise unknown.
|
|
529
|
+
|
|
530
|
+
The report exposes versions, metadata provenance and uncertainty under
|
|
531
|
+
`coverage.python_targets`; context JSON includes `python_target` and the effective
|
|
532
|
+
`header_policy`, and findings explain which version was used.
|
|
533
|
+
|
|
534
|
+
Python 3.14 [defers annotations by default](https://docs.python.org/3.14/whatsnew/3.14.html#deferred-evaluation-of-annotations),
|
|
535
|
+
but the future import still changes their runtime representation. These are
|
|
536
|
+
source conventions and advisory evidence, not proof of equivalent runtime
|
|
537
|
+
behavior. Smelt never removes imports or documentation automatically. Verify
|
|
538
|
+
annotation consumers and tests before removing an import, and use an explicit
|
|
539
|
+
package exception where its string-based behavior is intentional.
|
|
540
|
+
|
|
541
|
+
### Definition order with execution dependencies
|
|
542
|
+
|
|
543
|
+
Enable `SMT313 definition-order` to put the public interface first while keeping
|
|
544
|
+
prerequisites, binding versions and runtime boundaries intact:
|
|
545
|
+
|
|
546
|
+
```yaml
|
|
547
|
+
structure:
|
|
548
|
+
contracts:
|
|
549
|
+
definition-order:
|
|
550
|
+
match: app
|
|
551
|
+
ordering:
|
|
552
|
+
enabled: true
|
|
553
|
+
module: [docstring, future-imports, imports, dunder-assignments, type-vars, public, private, main-guard]
|
|
554
|
+
class: [class-attributes, init, dunders, public, private]
|
|
555
|
+
constants_exempt: false
|
|
556
|
+
fix: safe-only
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
The defaults are shown above. Each list must contain its categories exactly once;
|
|
560
|
+
module docstrings, future imports and imports form the fixed prefix, and the
|
|
561
|
+
main guard belongs last. The closest package policy wins; `enabled: false` can
|
|
562
|
+
exempt a package. This extends `smelt.yaml` package contracts rather than adding
|
|
563
|
+
a second `[tool.smelt.ordering]` configuration source.
|
|
564
|
+
|
|
565
|
+
The analyzer partitions module and directly nested class bodies into sections.
|
|
566
|
+
Within each section it computes a stable topological order: configured priority,
|
|
567
|
+
with prerequisites inheriting the priority of their dependents. Relative order
|
|
568
|
+
inside a category stays unchanged. Functions may refer to helpers in their bodies
|
|
569
|
+
before those helpers are defined. Decorators, defaults, class creation,
|
|
570
|
+
assignments and eagerly evaluated annotations have different timing; existing
|
|
571
|
+
reads and later rebindings are preserved. Unknown Python minima are treated
|
|
572
|
+
conservatively as eager; the future import or a declared Python 3.14+ minimum
|
|
573
|
+
defers annotations, while defaults remain eager.
|
|
574
|
+
|
|
575
|
+
Calls, unknown decorators, imports, control flow, `TYPE_CHECKING`, mutations,
|
|
576
|
+
operator/protocol evaluation and unsupported generic constructs are execution
|
|
577
|
+
barriers. Smelt keeps them in place. Literal construction through recognized
|
|
578
|
+
typing TypeVar/ParamSpec/TypeVarTuple factories has a dedicated category.
|
|
579
|
+
Consecutive overload/implementation and property getter/setter/deleter families
|
|
580
|
+
remain atomic. Noncontiguous property families are pinned. Function bodies,
|
|
581
|
+
including classes defined inside them, are outside this ordering analysis.
|
|
582
|
+
|
|
583
|
+
`constants_exempt: true` treats private uppercase module constants like public
|
|
584
|
+
definitions, allowing them to remain above the interface. Class attributes have
|
|
585
|
+
their own category. `__init__`, `__new__` and `__post_init__` share the initializer
|
|
586
|
+
category; other dunders precede ordinary public/private methods by default.
|
|
587
|
+
Names use the underscore convention; ordering does not infer semantic visibility
|
|
588
|
+
or rename symbols. If another tool changes visibility, run it before ordering.
|
|
589
|
+
|
|
590
|
+
Plain `smelt check` is always read-only. Request fixes explicitly:
|
|
591
|
+
|
|
592
|
+
```bash
|
|
593
|
+
smelt check --select SMT313 --format json
|
|
594
|
+
smelt check --select SMT313 --fix --format json
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
The fixer applies only visible, non-suppressed, non-debt sections within the
|
|
598
|
+
requested scope, then checks again. `fix: off` disables writes for a package.
|
|
599
|
+
Changed checks operate on affected reported sections, including any existing
|
|
600
|
+
ordering debt in a section that also contains a new inversion. Debt pruning and
|
|
601
|
+
changed comparisons track inversion identities, so adding a new inversion cannot
|
|
602
|
+
hide behind an existing aggregate finding.
|
|
603
|
+
|
|
604
|
+
Bounded fixes move source blocks, comments and their leading whitespace together;
|
|
605
|
+
they preserve UTF-8/BOM, newline convention and file permissions. Nested edits
|
|
606
|
+
are rendered together and the resulting AST must equal the approved permutations.
|
|
607
|
+
Use the project's formatter afterwards to normalize spacing. Symlinks, external
|
|
608
|
+
source roots, concurrent edits, formatter/suppression regions, tab indentation,
|
|
609
|
+
runtime introspection and uncertain class/decorator behavior require manual
|
|
610
|
+
review. A misplaced main guard or import is diagnosed but never moved across an
|
|
611
|
+
execution barrier automatically. These bounds do not prove arbitrary Python
|
|
612
|
+
reflection or hidden runtime effects equivalent; verify types and behavior.
|
|
613
|
+
|
|
614
|
+
JSON includes expected/original order, semantic inversions, fixability and reasons.
|
|
615
|
+
`coverage.ordering` identifies the analyzed files and barriers; `context` exposes
|
|
616
|
+
the effective `ordering_policy`, including planned placement. With `--fix`, JSON
|
|
617
|
+
also includes `fixes.ordering` with modified files, section counts and skipped
|
|
618
|
+
reasons. Every other rule keeps its existing behavior; no visibility or module
|
|
619
|
+
rename fixer is implied by this option.
|
|
620
|
+
|
|
621
|
+
## Test policies
|
|
622
|
+
|
|
623
|
+
### Optional test quality policies
|
|
624
|
+
|
|
625
|
+
Test mirroring and test quality are independent. Enable the policies that match
|
|
626
|
+
your unit-test conventions in the existing YAML configuration:
|
|
627
|
+
|
|
628
|
+
```yaml
|
|
629
|
+
tests:
|
|
630
|
+
layout: mirror
|
|
631
|
+
forbid_private_imports: true
|
|
632
|
+
forbid_mocks_in: ["**/tests/**/domain/**"]
|
|
633
|
+
forbid_string_patch_targets: true
|
|
634
|
+
max_patches_per_test: 2
|
|
635
|
+
require_assertions: true
|
|
636
|
+
assertion_helpers: [tests.assertions.assert_user]
|
|
637
|
+
quality_exclude: ["**/tests/integration/**", "**/tests/e2e/**"]
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
| Code | Checks |
|
|
641
|
+
| --- | --- |
|
|
642
|
+
| SMT420 | Explicit private production imports and import-qualified private attributes |
|
|
643
|
+
| SMT421 | Recognized mocking operations in the configured test paths, including conftest fixtures |
|
|
644
|
+
| SMT422 | Literal string targets of patch, patch.dict and patch.multiple |
|
|
645
|
+
| SMT423 | Recognized patch calls exceeding the per-test limit, including decorators and patch.object |
|
|
646
|
+
| SMT424 | No recognized explicit outcome assertion (warning by default) |
|
|
647
|
+
|
|
648
|
+
Every policy is opt-in; `0` is a valid patch limit. Globs are relative to the
|
|
649
|
+
configuration root, not each test root. `quality_exclude` does not disable mirror
|
|
650
|
+
checks or exclude production code. Rule selection, severity overrides, inline
|
|
651
|
+
suppressions and debt work normally. Plain checks remain read-only.
|
|
652
|
+
|
|
653
|
+
The shared analysis recognizes unittest.mock import aliases and the standard
|
|
654
|
+
pytest-mock fixtures (`mocker`, `class_mocker`, `module_mocker`, `package_mocker`,
|
|
655
|
+
`session_mocker`) on test/fixture functions. Explicit MockerFixture annotations
|
|
656
|
+
are also recognized. It accounts for local shadowing and common control-flow
|
|
657
|
+
bindings; unrelated functions named patch or Mock are not mocking evidence.
|
|
658
|
+
Custom fixture overrides with these standard names are outside type resolution.
|
|
659
|
+
|
|
660
|
+
SMT424 accepts assert other than literal `assert True`, recognized pytest
|
|
661
|
+
exception/warning checks, unittest.TestCase assertions and imported qualified
|
|
662
|
+
assertion helpers. Assertions inside a nested helper or fixture are not attributed
|
|
663
|
+
to a caller. Interaction-only tests are review candidates under this policy;
|
|
664
|
+
boundary-protocol tests and smoke tests can justify an exception. `assert result`
|
|
665
|
+
still counts: this rule detects missing explicit checks, not assertion quality,
|
|
666
|
+
reachability or correct behavior. Neither a literal threshold nor syntax proves
|
|
667
|
+
that a test protects a requirement.
|
|
668
|
+
|
|
669
|
+
The scope is the discovered `test_*.py`, `*_test.py` and conftest files. Test
|
|
670
|
+
functions use the usual test_ prefix; methods in Test-prefixed classes and direct
|
|
671
|
+
unittest.TestCase/IsolatedAsyncioTestCase subclasses are supported. Custom pytest
|
|
672
|
+
collection settings and inherited tests are not resolved. Helper modules outside
|
|
673
|
+
this inventory and transitive fixture/helper effects are not traversed. A fixture
|
|
674
|
+
in a permitted shared conftest can therefore introduce mocking into a domain test
|
|
675
|
+
without this path policy detecting that dependency. Unknown/dynamic call targets,
|
|
676
|
+
assignment aliases, reflection and instance types are not inferred. String target
|
|
677
|
+
variables and f-strings are outside SMT422's literal check.
|
|
678
|
+
|
|
679
|
+
Patch-count comparisons track individual syntactic calls, including multiplicity,
|
|
680
|
+
so accepted debt cannot hide an added or replaced patch. JSON includes policy,
|
|
681
|
+
operation locations and a test-quality coverage summary; `smelt context` exposes
|
|
682
|
+
the test policy before files are written. No automatic patch or test rewrites occur.
|
|
683
|
+
|
|
684
|
+
For complementary checks use Ruff's annotation and pytest-style rules: ANN201/
|
|
685
|
+
ANN202 for return annotations, ANN001 for parameters, N801 for class naming, and
|
|
686
|
+
PT011 for selected exception assertions missing a meaningful match/check. The
|
|
687
|
+
last rule can be configured for exception names that need these constraints.
|
|
688
|
+
Smelt does not enforce class grouping, one assertion/action, a universal ban on
|
|
689
|
+
control flow, assertion strength, fixture placement, duplicate test bodies,
|
|
690
|
+
network isolation or frozen clocks in this release. Those require separate policy
|
|
691
|
+
and evidence; multiple checks can legitimately describe one scenario.
|
|
692
|
+
|
|
693
|
+
### Mirrored source paths
|
|
694
|
+
|
|
695
|
+
With `tests.layout: mirror` the path of a test decides: `tests/billing/test_invoice.py`
|
|
696
|
+
needs `app/billing/invoice.py`, and a package test `tests/billing/test_billing.py` needs
|
|
697
|
+
`app/billing/`. Not every module needs a test, but a test whose source is missing or
|
|
698
|
+
elsewhere is an error. The message names the fix where it can: the right directory, the
|
|
699
|
+
right file name (`test_session_infrastructure_repository.py` should be named
|
|
700
|
+
`test_repository.py`), a neighbouring module with a similar name, or a missing `{root}` in
|
|
701
|
+
the pattern.
|
|
702
|
+
Imports through package facades count as imports of the module that defines the
|
|
703
|
+
name, so `from app.channels.application import ChannelCommands` points at
|
|
704
|
+
`application/commands/`. A move needs that evidence (or a test named exactly after its
|
|
705
|
+
subject, marked `"evidence": "name"`); a similar file name alone suggests nothing.
|
|
706
|
+
Otherwise the JSON lists `expected.candidates`: the imported modules, nearest first, with
|
|
707
|
+
the names imported from them, the facade they came through and their mirrored test path.
|
|
708
|
+
A test importing two modules of its name (`application/bootstrap.py` and
|
|
709
|
+
`infrastructure/bootstrap.py`) gets both as candidates and no move.
|
|
710
|
+
|
|
711
|
+
`tests.mirror` sets the convention relative to the test root: the default
|
|
712
|
+
`{path}/test_{module}.py` drops the root package, `{root}/{path}/test_{module}.py` keeps it,
|
|
713
|
+
and `unit/{path}/{module}_test.py` puts tests under `tests/unit/` with a suffix. In a uv
|
|
714
|
+
workspace each test root mirrors the package of its own member. Deliberately unmirrored
|
|
715
|
+
tests go in `tests.unmirrored` (e.g. `["tests/e2e/**"]`); `tests.mirror_suffixes: true`
|
|
716
|
+
also allows `test_invoice_<topic>.py`.
|
|
717
|
+
|
|
718
|
+
Mirroring repeats file names: `auth/presentation/test_router.py` and
|
|
719
|
+
`user/presentation/test_router.py`. pytest's default import mode only tells them apart
|
|
720
|
+
inside packages, so without `__init__.py` in the test directories it stops with "import
|
|
721
|
+
file mismatch". Prefixing the names (`test_auth_presentation_router.py`) works around that
|
|
722
|
+
but breaks the mirror; set the import mode instead, which `smelt init` points out:
|
|
723
|
+
|
|
724
|
+
```toml
|
|
725
|
+
[tool.pytest.ini_options]
|
|
726
|
+
addopts = ["--import-mode=importlib"]
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
## Adopting it in an existing project
|
|
730
|
+
|
|
731
|
+
A first check often reports dozens of findings that come down to a few decisions. Each
|
|
732
|
+
import finding carries an `edge` in JSON (`"billing.application -> voice.domain"`; for
|
|
733
|
+
cycles, the cycle), and the text output ends with the edges behind several findings.
|
|
734
|
+
Settle each edge once: fix the code, or record an intended dependency as policy, e.g. a
|
|
735
|
+
`cross_feature.allow` entry.
|
|
736
|
+
JSON also lists `groups`, sorted by size: a `facade` group collects the indirect
|
|
737
|
+
findings one module relays (its count is what narrowing that facade's exports would
|
|
738
|
+
address), an `edge` group counts `direct` and `transitive` findings of one dependency, a
|
|
739
|
+
`cycle` group carries the witness import path, and a `test_move` group the SMT401 files
|
|
740
|
+
moving between the same directories. Groups cite findings by their `id`; every finding
|
|
741
|
+
stays in `violations`, so a group is one decision, not one more defect.
|
|
742
|
+
|
|
743
|
+
Accept what remains with a baseline:
|
|
744
|
+
|
|
745
|
+
`smelt debt` records today's violations in `.smelt/debt.json` and sets `debt:` in
|
|
746
|
+
`smelt.yaml`. `smelt check` then fails only on new violations, and SMT903 reports entries
|
|
747
|
+
that were fixed and can leave the file (`smelt debt --prune`).
|
|
748
|
+
Existing debt files remain readable. Run `smelt debt --prune` once before editing imports
|
|
749
|
+
covered by an older baseline to upgrade their fingerprints; pruning accepts no new debt.
|
|
750
|
+
New import fingerprints survive comments, aliases and formatting changes.
|
|
751
|
+
Cycle debt records each cyclic architecture edge, so a new edge cannot hide behind
|
|
752
|
+
an existing shortest-cycle witness. Indirect import findings identify the final
|
|
753
|
+
forbidden target, rather than only the facade. Older cycle and indirect-import debt
|
|
754
|
+
may therefore reappear for review: the old fingerprints cannot establish the full
|
|
755
|
+
dependency they accepted. `smelt debt --prune` removes stale entries without accepting
|
|
756
|
+
these findings; review them before explicitly recording new debt.
|
|
757
|
+
|
|
758
|
+
Override severities by rule name or code, e.g. `rules: {unclassified-module: off}`.
|
|
759
|
+
|
|
760
|
+
Silence a single finding inline, always with a reason:
|
|
761
|
+
|
|
762
|
+
```python
|
|
763
|
+
from gateway.infra.sql import Repo # smelt: ignore[SMT101] -- migration tracked in #123
|
|
764
|
+
```
|
|
765
|
+
|
|
766
|
+
Every rule has a page under [docs/rules](docs/rules/), and `smelt.schema.json` gives editors
|
|
767
|
+
autocompletion and a description for every key of `smelt.yaml`.
|
|
768
|
+
|
|
769
|
+
## Python versions
|
|
770
|
+
|
|
771
|
+
Smelt runs on Python 3.12 to 3.14 and parses your code with the Python it runs on. Code
|
|
772
|
+
that uses newer syntax (3.14's `except A, B:` or t-strings, 3.13's type parameter defaults)
|
|
773
|
+
needs smelt on that version, e.g. `uvx -p 3.14 --from smelt-cli smelt check`; the syntax error says so when
|
|
774
|
+
`requires-python` or `.python-version` targets a newer Python.
|
|
775
|
+
|
|
776
|
+
## Using Smelt with coding agents
|
|
777
|
+
|
|
778
|
+
Add this to your `AGENTS.md` or `CLAUDE.md`:
|
|
779
|
+
|
|
780
|
+
```md
|
|
781
|
+
Before implementing, run `smelt context <feature>` to see where code belongs.
|
|
782
|
+
After every change, run `smelt check --changed --strict-coverage --format json`.
|
|
783
|
+
Do not finish while errors remain. Use `smelt explain <code>` when unsure.
|
|
784
|
+
Resolve placement problems; do not relax policy or record debt just to make the check pass.
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
Suggested loop: `smelt context <feature>` → edit → `smelt check --changed` → fix → tests →
|
|
788
|
+
pre-commit → CI (full check). Prefer the cheapest verification that gives sufficient
|
|
789
|
+
confidence: a rename needs smelt plus a type checker, new behavior needs one focused
|
|
790
|
+
regression test.
|
|
791
|
+
|
|
792
|
+
## pre-commit
|
|
793
|
+
|
|
794
|
+
```yaml
|
|
795
|
+
repos:
|
|
796
|
+
- repo: https://github.com/mathisarends/smelt
|
|
797
|
+
rev: v0.1.4
|
|
798
|
+
hooks:
|
|
799
|
+
- id: smelt
|
|
800
|
+
```
|
|
801
|
+
|
|
802
|
+
The hook checks the whole project when Python files or `smelt.yaml` / `smelt.yml`
|
|
803
|
+
change. Checking only changed filenames can miss violations in modules that import them.
|
|
804
|
+
|
|
805
|
+
## GitHub Actions
|
|
806
|
+
|
|
807
|
+
CI checks the whole repository; `--changed` is for the agent loop.
|
|
808
|
+
|
|
809
|
+
```yaml
|
|
810
|
+
- uses: astral-sh/setup-uv@v6
|
|
811
|
+
- run: uvx --from smelt-cli smelt check --format github
|
|
812
|
+
# optional: code scanning
|
|
813
|
+
- run: uvx --from smelt-cli smelt check --format sarif > smelt.sarif || true
|
|
814
|
+
- uses: github/codeql-action/upload-sarif@v3
|
|
815
|
+
with:
|
|
816
|
+
sarif_file: smelt.sarif
|
|
817
|
+
```
|
|
818
|
+
|
|
819
|
+
## Development
|
|
820
|
+
|
|
821
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for the rule extension workflow, code layout,
|
|
822
|
+
regression requirements and PR checklist. `smelt rules --format json` lists each
|
|
823
|
+
rule's analysis requirements and configuration paths; `smelt explain <code>` gives
|
|
824
|
+
the examples and rationale.
|
|
825
|
+
|
|
826
|
+
Requires [uv](https://docs.astral.sh/uv/).
|
|
827
|
+
|
|
828
|
+
```bash
|
|
829
|
+
uv sync # create .venv and install dev dependencies
|
|
830
|
+
uv run pre-commit install
|
|
831
|
+
```
|
|
832
|
+
|
|
833
|
+
Common commands:
|
|
834
|
+
|
|
835
|
+
```bash
|
|
836
|
+
uv run pytest # run tests
|
|
837
|
+
uv run pytest --cov # run tests with coverage
|
|
838
|
+
uv run ruff check --fix . # lint
|
|
839
|
+
uv run ruff format . # format
|
|
840
|
+
uv run mypy # type-check
|
|
841
|
+
uv run pre-commit run --all-files # run all hooks
|
|
842
|
+
uv run smelt check # smelt checks itself
|
|
843
|
+
uv run python scripts/generate.py # refresh smelt.schema.json and docs/rules/
|
|
844
|
+
```
|
|
845
|
+
|
|
846
|
+
Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/).
|