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.
Files changed (99) hide show
  1. smelt_cli-0.1.4/PKG-INFO +846 -0
  2. smelt_cli-0.1.4/README.md +830 -0
  3. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/pyproject.toml +3 -2
  4. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/pyproject.toml.orig +3 -2
  5. smelt_cli-0.1.4/smelt/analysis/annotations.py +105 -0
  6. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/analysis/context.py +30 -0
  7. smelt_cli-0.1.4/smelt/analysis/contracts.py +221 -0
  8. smelt_cli-0.1.4/smelt/analysis/coverage.py +358 -0
  9. smelt_cli-0.1.4/smelt/analysis/definition_blocks.py +560 -0
  10. smelt_cli-0.1.4/smelt/analysis/errors.py +2 -0
  11. smelt_cli-0.1.4/smelt/analysis/exports.py +166 -0
  12. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/analysis/files.py +76 -32
  13. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/analysis/imports.py +70 -45
  14. smelt_cli-0.1.4/smelt/analysis/ordering.py +377 -0
  15. smelt_cli-0.1.4/smelt/analysis/ordering_rewrite.py +232 -0
  16. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/analysis/parsing.py +10 -4
  17. smelt_cli-0.1.4/smelt/analysis/python_version.py +162 -0
  18. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/analysis/syntax.py +17 -0
  19. smelt_cli-0.1.4/smelt/analysis/test_syntax.py +482 -0
  20. smelt_cli-0.1.4/smelt/analysis/type_dispatch.py +193 -0
  21. smelt_cli-0.1.4/smelt/analysis/workspace.py +137 -0
  22. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/cli/app.py +45 -7
  23. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/cli/commands/adopt.py +95 -20
  24. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/cli/commands/check.py +18 -0
  25. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/cli/commands/info.py +4 -6
  26. smelt_cli-0.1.4/smelt/config/discovery.py +79 -0
  27. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/config/loader.py +39 -1
  28. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/config/models.py +317 -2
  29. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/config/validation.py +11 -0
  30. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/diagnostics/debt.py +68 -9
  31. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/diagnostics/dedupe.py +1 -0
  32. smelt_cli-0.1.4/smelt/diagnostics/groups.py +108 -0
  33. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/diagnostics/render/machine.py +39 -6
  34. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/diagnostics/render/text.py +62 -9
  35. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/diagnostics/report.py +14 -1
  36. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/diagnostics/violation.py +16 -0
  37. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/docs.py +7 -10
  38. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/engine/briefing.py +208 -2
  39. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/engine/changes.py +34 -8
  40. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/engine/check.py +103 -15
  41. smelt_cli-0.1.4/smelt/engine/coverage.py +138 -0
  42. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/engine/inference.py +333 -69
  43. smelt_cli-0.1.4/smelt/engine/ordering_fixes.py +132 -0
  44. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/engine/paths.py +57 -0
  45. smelt_cli-0.1.4/smelt/engine/workspace.py +5 -0
  46. smelt_cli-0.1.4/smelt/rules/activation.py +17 -0
  47. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/rules/base.py +15 -32
  48. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/rules/common.py +76 -3
  49. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/rules/dependencies/composition_root.py +10 -1
  50. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/rules/dependencies/cycles.py +102 -5
  51. smelt_cli-0.1.4/smelt/rules/dependencies/fanout.py +87 -0
  52. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/rules/dependencies/features.py +14 -3
  53. smelt_cli-0.1.4/smelt/rules/dependencies/interfaces.py +90 -0
  54. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/rules/dependencies/layers.py +16 -6
  55. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/rules/dependencies/third_party.py +8 -0
  56. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/rules/meta.py +1 -0
  57. smelt_cli-0.1.4/smelt/rules/registry.py +69 -0
  58. smelt_cli-0.1.4/smelt/rules/structure/assignments.py +126 -0
  59. smelt_cli-0.1.4/smelt/rules/structure/contracts.py +77 -0
  60. smelt_cli-0.1.4/smelt/rules/structure/coverage.py +102 -0
  61. smelt_cli-0.1.4/smelt/rules/structure/headers.py +158 -0
  62. smelt_cli-0.1.4/smelt/rules/structure/import_style.py +151 -0
  63. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/rules/structure/layout.py +5 -4
  64. smelt_cli-0.1.4/smelt/rules/structure/naming.py +141 -0
  65. smelt_cli-0.1.4/smelt/rules/structure/ordering.py +70 -0
  66. smelt_cli-0.1.4/smelt/rules/structure/type_dispatch.py +85 -0
  67. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/rules/testing/location.py +197 -47
  68. smelt_cli-0.1.4/smelt/rules/testing/mocking.py +138 -0
  69. smelt_cli-0.1.4/smelt/rules/testing/outcomes.py +53 -0
  70. smelt_cli-0.1.4/smelt/rules/testing/private_access.py +66 -0
  71. smelt_cli-0.1.2/PKG-INFO +0 -274
  72. smelt_cli-0.1.2/README.md +0 -259
  73. smelt_cli-0.1.2/smelt/config/discovery.py +0 -49
  74. smelt_cli-0.1.2/smelt/rules/registry.py +0 -32
  75. smelt_cli-0.1.2/smelt/rules/structure/naming.py +0 -63
  76. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/__init__.py +0 -0
  77. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/__main__.py +0 -0
  78. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/analysis/__init__.py +0 -0
  79. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/analysis/graphs.py +0 -0
  80. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/cli/__init__.py +0 -0
  81. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/cli/commands/__init__.py +0 -0
  82. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/cli/commands/discover.py +0 -0
  83. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/cli/support.py +0 -0
  84. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/config/__init__.py +0 -0
  85. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/config/errors.py +0 -0
  86. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/config/patterns.py +0 -0
  87. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/config/schema.py +0 -0
  88. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/diagnostics/__init__.py +0 -0
  89. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/diagnostics/render/__init__.py +0 -0
  90. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/diagnostics/suppressions.py +0 -0
  91. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/engine/__init__.py +0 -0
  92. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/engine/mirror_inference.py +0 -0
  93. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/model/__init__.py +0 -0
  94. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/model/architecture.py +0 -0
  95. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/py.typed +0 -0
  96. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/rules/__init__.py +0 -0
  97. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/rules/dependencies/__init__.py +0 -0
  98. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/rules/structure/__init__.py +0 -0
  99. {smelt_cli-0.1.2 → smelt_cli-0.1.4}/smelt/rules/testing/__init__.py +0 -0
@@ -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/).