smelt-cli 0.1.2__tar.gz → 0.1.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/PKG-INFO +43 -16
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/README.md +42 -15
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/pyproject.toml +1 -1
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/pyproject.toml.orig +1 -1
- smelt_cli-0.1.3/smelt/analysis/exports.py +166 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/analysis/syntax.py +17 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/cli/app.py +16 -4
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/cli/commands/adopt.py +74 -16
- smelt_cli-0.1.3/smelt/config/discovery.py +79 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/config/models.py +24 -0
- smelt_cli-0.1.3/smelt/diagnostics/groups.py +108 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/diagnostics/render/machine.py +16 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/diagnostics/render/text.py +40 -1
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/diagnostics/report.py +5 -1
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/diagnostics/violation.py +16 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/engine/briefing.py +22 -1
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/engine/changes.py +34 -8
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/engine/check.py +10 -6
- smelt_cli-0.1.3/smelt/engine/coverage.py +108 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/engine/inference.py +313 -65
- smelt_cli-0.1.3/smelt/engine/workspace.py +106 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/rules/dependencies/cycles.py +89 -4
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/rules/testing/location.py +193 -47
- smelt_cli-0.1.2/smelt/config/discovery.py +0 -49
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/__init__.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/__main__.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/analysis/__init__.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/analysis/context.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/analysis/files.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/analysis/graphs.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/analysis/imports.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/analysis/parsing.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/cli/__init__.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/cli/commands/__init__.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/cli/commands/check.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/cli/commands/discover.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/cli/commands/info.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/cli/support.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/config/__init__.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/config/errors.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/config/loader.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/config/patterns.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/config/schema.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/config/validation.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/diagnostics/__init__.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/diagnostics/debt.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/diagnostics/dedupe.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/diagnostics/render/__init__.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/diagnostics/suppressions.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/docs.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/engine/__init__.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/engine/mirror_inference.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/engine/paths.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/model/__init__.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/model/architecture.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/py.typed +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/rules/__init__.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/rules/base.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/rules/common.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/rules/dependencies/__init__.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/rules/dependencies/composition_root.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/rules/dependencies/features.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/rules/dependencies/layers.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/rules/dependencies/third_party.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/rules/meta.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/rules/registry.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/rules/structure/__init__.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/rules/structure/layout.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/rules/structure/naming.py +0 -0
- {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/rules/testing/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: smelt-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Static guardrails for Python codebases maintained by humans and coding agents.
|
|
5
5
|
Author: Mathis Arends
|
|
6
6
|
Author-email: Mathis Arends <mathisarends27@gmail.com>
|
|
@@ -24,18 +24,7 @@ with the exact location, what is allowed instead and how to fix it:
|
|
|
24
24
|
|
|
25
25
|
## Installation
|
|
26
26
|
|
|
27
|
-
Requires Python 3.12 or newer.
|
|
28
|
-
distribution name, install from this repository:
|
|
29
|
-
|
|
30
|
-
```bash
|
|
31
|
-
uv tool install git+https://github.com/mathisarends/smelt.git
|
|
32
|
-
smelt --version
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
From a local checkout, use `uv tool install .`. The PyPI package named `smelt` belongs
|
|
36
|
-
to a different project; `pip install smelt` and bare `uvx smelt` install that project.
|
|
37
|
-
|
|
38
|
-
## Installation
|
|
27
|
+
Requires Python 3.12 or newer.
|
|
39
28
|
|
|
40
29
|
```bash
|
|
41
30
|
uv tool install smelt-cli
|
|
@@ -45,11 +34,14 @@ pip install smelt-cli
|
|
|
45
34
|
|
|
46
35
|
The PyPI package is named `smelt-cli`; the CLI command and Python package are both
|
|
47
36
|
named `smelt`. To run without installing, use `uvx --from smelt-cli smelt check`.
|
|
37
|
+
From a local checkout, use `uv tool install .`. The PyPI package named `smelt` belongs
|
|
38
|
+
to a different project; `pip install smelt` and bare `uvx smelt` install that project.
|
|
48
39
|
|
|
49
40
|
## Usage
|
|
50
41
|
|
|
51
42
|
```bash
|
|
52
43
|
smelt init # draft smelt.yaml from the code (also uv workspaces)
|
|
44
|
+
smelt init --config other.yaml # write elsewhere; only that file is checked and overwritten
|
|
53
45
|
smelt check # whole project
|
|
54
46
|
smelt check --changed # only what the working tree introduced since HEAD
|
|
55
47
|
smelt check --changed --base origin/main --format json
|
|
@@ -68,6 +60,15 @@ config error, not a silently disabled rule.
|
|
|
68
60
|
Explicit check paths must exist and contain analyzed source or test files; unknown
|
|
69
61
|
`--select`/`--ignore` prefixes are usage errors. JSON includes the reporting scope, file
|
|
70
62
|
count and active rule codes. `--config PATH` works before or after a subcommand.
|
|
63
|
+
A stored JSON report explains itself: `scope.mode` is `full` or `changed`, and
|
|
64
|
+
`comparison` names the requested `base`, the resolved `merge_base`, the
|
|
65
|
+
`compared_revision` and `head`, so `passed` from `--changed` reads as "nothing new since
|
|
66
|
+
that commit", not as a clean inventory. `coverage` lists the source and test roots,
|
|
67
|
+
workspace members that are not analyzed, module counts per classification, the
|
|
68
|
+
composition-root and wiring exemptions, third-party policies per layer, the import
|
|
69
|
+
settings (`type_checking`, `transitive`, `cycles`), cross-feature allowances as fields,
|
|
70
|
+
and a `policy_hash` that is equal for reports checked under the same resolved config.
|
|
71
|
+
`smelt context --format json` carries the same import settings, allowances and hash.
|
|
71
72
|
With `--format json`, an exit `2` prints a JSON document as well, so an agent needs no
|
|
72
73
|
text parser: `{"schema_version": 1, "status": "error", "error": {...}}` with `kind`
|
|
73
74
|
(`usage`, `config` or `analysis`), `message`, the rejected `input` (`option`, `value`)
|
|
@@ -129,8 +130,18 @@ tests:
|
|
|
129
130
|
import restrictions and configured cycle checks still apply.
|
|
130
131
|
|
|
131
132
|
`smelt init` infers most of this: features, layers, shared and settings modules, the
|
|
132
|
-
composition root including an app factory
|
|
133
|
+
composition root including an app factory (imported or named as a `"backend.app:app"`
|
|
134
|
+
server target), wiring patterns, central packages by name and
|
|
133
135
|
the mirror pattern the existing tests follow. Review it before adopting its findings.
|
|
136
|
+
It follows the names a composition root imports through package facades into their
|
|
137
|
+
definitions (`FEATURES = (chat.feature, ...)`); a module whose definition is built from
|
|
138
|
+
providers, such as a `feature.py` assembling `TelegramProvider`, becomes wiring, and the
|
|
139
|
+
config comment shows the chain. Merely importing framework types changes nothing. An
|
|
140
|
+
unclassified package only the root uses is proposed as a commented-out candidate with its
|
|
141
|
+
chain and confidence, never applied silently.
|
|
142
|
+
In a uv workspace it lists every declared member with the packages it found or why it
|
|
143
|
+
skipped it (`tool.uv.workspace.exclude`, no Python package); namespace packages without
|
|
144
|
+
`__init__.py` count under `src/` and where `tool.uv.build-backend` declares them.
|
|
134
145
|
Its starter policy explicitly allows third-party packages and checks direct imports.
|
|
135
146
|
Review these decisions: to keep frameworks out of the core, set e.g.
|
|
136
147
|
`domain.third_party: {default: deny, allow: [pydantic]}` under `architecture.layers`.
|
|
@@ -155,6 +166,14 @@ elsewhere is an error. The message names the fix where it can: the right directo
|
|
|
155
166
|
right file name (`test_session_infrastructure_repository.py` should be named
|
|
156
167
|
`test_repository.py`), a neighbouring module with a similar name, or a missing `{root}` in
|
|
157
168
|
the pattern.
|
|
169
|
+
Imports through package facades count as imports of the module that defines the
|
|
170
|
+
name, so `from app.channels.application import ChannelCommands` points at
|
|
171
|
+
`application/commands/`. A move needs that evidence (or a test named exactly after its
|
|
172
|
+
subject, marked `"evidence": "name"`); a similar file name alone suggests nothing.
|
|
173
|
+
Otherwise the JSON lists `expected.candidates`: the imported modules, nearest first, with
|
|
174
|
+
the names imported from them, the facade they came through and their mirrored test path.
|
|
175
|
+
A test importing two modules of its name (`application/bootstrap.py` and
|
|
176
|
+
`infrastructure/bootstrap.py`) gets both as candidates and no move.
|
|
158
177
|
|
|
159
178
|
`tests.mirror` sets the convention relative to the test root: the default
|
|
160
179
|
`{path}/test_{module}.py` drops the root package, `{root}/{path}/test_{module}.py` keeps it,
|
|
@@ -180,7 +199,15 @@ A first check often reports dozens of findings that come down to a few decisions
|
|
|
180
199
|
import finding carries an `edge` in JSON (`"billing.application -> voice.domain"`; for
|
|
181
200
|
cycles, the cycle), and the text output ends with the edges behind several findings.
|
|
182
201
|
Settle each edge once: fix the code, or record an intended dependency as policy, e.g. a
|
|
183
|
-
`cross_feature.allow` entry.
|
|
202
|
+
`cross_feature.allow` entry.
|
|
203
|
+
JSON also lists `groups`, sorted by size: a `facade` group collects the indirect
|
|
204
|
+
findings one module relays (its count is what narrowing that facade's exports would
|
|
205
|
+
address), an `edge` group counts `direct` and `transitive` findings of one dependency, a
|
|
206
|
+
`cycle` group carries the witness import path, and a `test_move` group the SMT401 files
|
|
207
|
+
moving between the same directories. Groups cite findings by their `id`; every finding
|
|
208
|
+
stays in `violations`, so a group is one decision, not one more defect.
|
|
209
|
+
|
|
210
|
+
Accept what remains with a baseline:
|
|
184
211
|
|
|
185
212
|
`smelt debt` records today's violations in `.smelt/debt.json` and sets `debt:` in
|
|
186
213
|
`smelt.yaml`. `smelt check` then fails only on new violations, and SMT903 reports entries
|
|
@@ -227,7 +254,7 @@ regression test.
|
|
|
227
254
|
```yaml
|
|
228
255
|
repos:
|
|
229
256
|
- repo: https://github.com/mathisarends/smelt
|
|
230
|
-
rev: v0.1.
|
|
257
|
+
rev: v0.1.3
|
|
231
258
|
hooks:
|
|
232
259
|
- id: smelt
|
|
233
260
|
```
|
|
@@ -9,18 +9,7 @@ with the exact location, what is allowed instead and how to fix it:
|
|
|
9
9
|
|
|
10
10
|
## Installation
|
|
11
11
|
|
|
12
|
-
Requires Python 3.12 or newer.
|
|
13
|
-
distribution name, install from this repository:
|
|
14
|
-
|
|
15
|
-
```bash
|
|
16
|
-
uv tool install git+https://github.com/mathisarends/smelt.git
|
|
17
|
-
smelt --version
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
From a local checkout, use `uv tool install .`. The PyPI package named `smelt` belongs
|
|
21
|
-
to a different project; `pip install smelt` and bare `uvx smelt` install that project.
|
|
22
|
-
|
|
23
|
-
## Installation
|
|
12
|
+
Requires Python 3.12 or newer.
|
|
24
13
|
|
|
25
14
|
```bash
|
|
26
15
|
uv tool install smelt-cli
|
|
@@ -30,11 +19,14 @@ pip install smelt-cli
|
|
|
30
19
|
|
|
31
20
|
The PyPI package is named `smelt-cli`; the CLI command and Python package are both
|
|
32
21
|
named `smelt`. To run without installing, use `uvx --from smelt-cli smelt check`.
|
|
22
|
+
From a local checkout, use `uv tool install .`. The PyPI package named `smelt` belongs
|
|
23
|
+
to a different project; `pip install smelt` and bare `uvx smelt` install that project.
|
|
33
24
|
|
|
34
25
|
## Usage
|
|
35
26
|
|
|
36
27
|
```bash
|
|
37
28
|
smelt init # draft smelt.yaml from the code (also uv workspaces)
|
|
29
|
+
smelt init --config other.yaml # write elsewhere; only that file is checked and overwritten
|
|
38
30
|
smelt check # whole project
|
|
39
31
|
smelt check --changed # only what the working tree introduced since HEAD
|
|
40
32
|
smelt check --changed --base origin/main --format json
|
|
@@ -53,6 +45,15 @@ config error, not a silently disabled rule.
|
|
|
53
45
|
Explicit check paths must exist and contain analyzed source or test files; unknown
|
|
54
46
|
`--select`/`--ignore` prefixes are usage errors. JSON includes the reporting scope, file
|
|
55
47
|
count and active rule codes. `--config PATH` works before or after a subcommand.
|
|
48
|
+
A stored JSON report explains itself: `scope.mode` is `full` or `changed`, and
|
|
49
|
+
`comparison` names the requested `base`, the resolved `merge_base`, the
|
|
50
|
+
`compared_revision` and `head`, so `passed` from `--changed` reads as "nothing new since
|
|
51
|
+
that commit", not as a clean inventory. `coverage` lists the source and test roots,
|
|
52
|
+
workspace members that are not analyzed, module counts per classification, the
|
|
53
|
+
composition-root and wiring exemptions, third-party policies per layer, the import
|
|
54
|
+
settings (`type_checking`, `transitive`, `cycles`), cross-feature allowances as fields,
|
|
55
|
+
and a `policy_hash` that is equal for reports checked under the same resolved config.
|
|
56
|
+
`smelt context --format json` carries the same import settings, allowances and hash.
|
|
56
57
|
With `--format json`, an exit `2` prints a JSON document as well, so an agent needs no
|
|
57
58
|
text parser: `{"schema_version": 1, "status": "error", "error": {...}}` with `kind`
|
|
58
59
|
(`usage`, `config` or `analysis`), `message`, the rejected `input` (`option`, `value`)
|
|
@@ -114,8 +115,18 @@ tests:
|
|
|
114
115
|
import restrictions and configured cycle checks still apply.
|
|
115
116
|
|
|
116
117
|
`smelt init` infers most of this: features, layers, shared and settings modules, the
|
|
117
|
-
composition root including an app factory
|
|
118
|
+
composition root including an app factory (imported or named as a `"backend.app:app"`
|
|
119
|
+
server target), wiring patterns, central packages by name and
|
|
118
120
|
the mirror pattern the existing tests follow. Review it before adopting its findings.
|
|
121
|
+
It follows the names a composition root imports through package facades into their
|
|
122
|
+
definitions (`FEATURES = (chat.feature, ...)`); a module whose definition is built from
|
|
123
|
+
providers, such as a `feature.py` assembling `TelegramProvider`, becomes wiring, and the
|
|
124
|
+
config comment shows the chain. Merely importing framework types changes nothing. An
|
|
125
|
+
unclassified package only the root uses is proposed as a commented-out candidate with its
|
|
126
|
+
chain and confidence, never applied silently.
|
|
127
|
+
In a uv workspace it lists every declared member with the packages it found or why it
|
|
128
|
+
skipped it (`tool.uv.workspace.exclude`, no Python package); namespace packages without
|
|
129
|
+
`__init__.py` count under `src/` and where `tool.uv.build-backend` declares them.
|
|
119
130
|
Its starter policy explicitly allows third-party packages and checks direct imports.
|
|
120
131
|
Review these decisions: to keep frameworks out of the core, set e.g.
|
|
121
132
|
`domain.third_party: {default: deny, allow: [pydantic]}` under `architecture.layers`.
|
|
@@ -140,6 +151,14 @@ elsewhere is an error. The message names the fix where it can: the right directo
|
|
|
140
151
|
right file name (`test_session_infrastructure_repository.py` should be named
|
|
141
152
|
`test_repository.py`), a neighbouring module with a similar name, or a missing `{root}` in
|
|
142
153
|
the pattern.
|
|
154
|
+
Imports through package facades count as imports of the module that defines the
|
|
155
|
+
name, so `from app.channels.application import ChannelCommands` points at
|
|
156
|
+
`application/commands/`. A move needs that evidence (or a test named exactly after its
|
|
157
|
+
subject, marked `"evidence": "name"`); a similar file name alone suggests nothing.
|
|
158
|
+
Otherwise the JSON lists `expected.candidates`: the imported modules, nearest first, with
|
|
159
|
+
the names imported from them, the facade they came through and their mirrored test path.
|
|
160
|
+
A test importing two modules of its name (`application/bootstrap.py` and
|
|
161
|
+
`infrastructure/bootstrap.py`) gets both as candidates and no move.
|
|
143
162
|
|
|
144
163
|
`tests.mirror` sets the convention relative to the test root: the default
|
|
145
164
|
`{path}/test_{module}.py` drops the root package, `{root}/{path}/test_{module}.py` keeps it,
|
|
@@ -165,7 +184,15 @@ A first check often reports dozens of findings that come down to a few decisions
|
|
|
165
184
|
import finding carries an `edge` in JSON (`"billing.application -> voice.domain"`; for
|
|
166
185
|
cycles, the cycle), and the text output ends with the edges behind several findings.
|
|
167
186
|
Settle each edge once: fix the code, or record an intended dependency as policy, e.g. a
|
|
168
|
-
`cross_feature.allow` entry.
|
|
187
|
+
`cross_feature.allow` entry.
|
|
188
|
+
JSON also lists `groups`, sorted by size: a `facade` group collects the indirect
|
|
189
|
+
findings one module relays (its count is what narrowing that facade's exports would
|
|
190
|
+
address), an `edge` group counts `direct` and `transitive` findings of one dependency, a
|
|
191
|
+
`cycle` group carries the witness import path, and a `test_move` group the SMT401 files
|
|
192
|
+
moving between the same directories. Groups cite findings by their `id`; every finding
|
|
193
|
+
stays in `violations`, so a group is one decision, not one more defect.
|
|
194
|
+
|
|
195
|
+
Accept what remains with a baseline:
|
|
169
196
|
|
|
170
197
|
`smelt debt` records today's violations in `.smelt/debt.json` and sets `debt:` in
|
|
171
198
|
`smelt.yaml`. `smelt check` then fails only on new violations, and SMT903 reports entries
|
|
@@ -212,7 +239,7 @@ regression test.
|
|
|
212
239
|
```yaml
|
|
213
240
|
repos:
|
|
214
241
|
- repo: https://github.com/mathisarends/smelt
|
|
215
|
-
rev: v0.1.
|
|
242
|
+
rev: v0.1.3
|
|
216
243
|
hooks:
|
|
217
244
|
- id: smelt
|
|
218
245
|
```
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Where a name that a module exposes is defined, following package re-exports.
|
|
2
|
+
|
|
3
|
+
``from app.billing import Invoice`` names ``app.billing``, but ``Invoice`` lives in
|
|
4
|
+
``app.billing.models`` when the facade ``app/billing/__init__.py`` re-exports it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from smelt.analysis.parsing import resolve_relative
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Callable, Iterator
|
|
17
|
+
|
|
18
|
+
# Re-export chains are short; a longer one is more likely a cycle than a design.
|
|
19
|
+
MAX_HOPS = 8
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class Namespace:
|
|
24
|
+
"""The names a module binds at top level."""
|
|
25
|
+
|
|
26
|
+
module: str
|
|
27
|
+
# local name -> the absolute name it was imported as
|
|
28
|
+
imported: dict[str, str] = field(default_factory=dict)
|
|
29
|
+
defined: frozenset[str] = frozenset()
|
|
30
|
+
# defined name -> dotted names its value refers to: ``("chat", "feature")``
|
|
31
|
+
references: dict[str, tuple[tuple[str, ...], ...]] = field(default_factory=dict)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
type Loader = Callable[[str], "Namespace | None"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def namespace_of(tree: ast.Module, module: str, *, is_package: bool) -> Namespace:
|
|
38
|
+
imported: dict[str, str] = {}
|
|
39
|
+
defined: set[str] = set()
|
|
40
|
+
references: dict[str, tuple[tuple[str, ...], ...]] = {}
|
|
41
|
+
for node in _top_level(tree.body):
|
|
42
|
+
if isinstance(node, ast.Import):
|
|
43
|
+
for alias in node.names:
|
|
44
|
+
name = alias.asname or alias.name.split(".")[0]
|
|
45
|
+
imported[name] = alias.name if alias.asname else name
|
|
46
|
+
defined.discard(name)
|
|
47
|
+
references.pop(name, None)
|
|
48
|
+
elif isinstance(node, ast.ImportFrom):
|
|
49
|
+
base = resolve_relative(
|
|
50
|
+
module, is_package=is_package, level=node.level, target=node.module
|
|
51
|
+
)
|
|
52
|
+
for alias in node.names:
|
|
53
|
+
if alias.name != "*":
|
|
54
|
+
qualified = f"{base}.{alias.name}" if base else alias.name
|
|
55
|
+
name = alias.asname or alias.name
|
|
56
|
+
imported[name] = qualified
|
|
57
|
+
defined.discard(name)
|
|
58
|
+
references.pop(name, None)
|
|
59
|
+
else:
|
|
60
|
+
for name in _defined_names(node):
|
|
61
|
+
defined.add(name)
|
|
62
|
+
imported.pop(name, None)
|
|
63
|
+
references[name] = tuple(_dotted_names(node))
|
|
64
|
+
return Namespace(module, imported, frozenset(defined), references)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def resolve_export(
|
|
68
|
+
module: str, name: str, load: Loader
|
|
69
|
+
) -> tuple[str, str | None] | None:
|
|
70
|
+
"""The module defining ``module.name`` and its name there.
|
|
71
|
+
|
|
72
|
+
``(submodule, None)`` when the name is a module itself; None for names outside
|
|
73
|
+
the loader's modules, star imports, and chains that cycle or run too long.
|
|
74
|
+
"""
|
|
75
|
+
seen: set[tuple[str, str]] = set()
|
|
76
|
+
while len(seen) < MAX_HOPS and (module, name) not in seen:
|
|
77
|
+
seen.add((module, name))
|
|
78
|
+
namespace = load(module)
|
|
79
|
+
if namespace is None:
|
|
80
|
+
return None
|
|
81
|
+
if name in namespace.defined:
|
|
82
|
+
return module, name
|
|
83
|
+
target = namespace.imported.get(name) or f"{module}.{name}"
|
|
84
|
+
if load(target) is not None:
|
|
85
|
+
return target, None
|
|
86
|
+
if target == f"{module}.{name}":
|
|
87
|
+
return None
|
|
88
|
+
module, _, name = target.rpartition(".")
|
|
89
|
+
if not module:
|
|
90
|
+
return None
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def resolve_dotted(qualified: str, load: Loader) -> tuple[str, str | None] | None:
|
|
95
|
+
"""Like :func:`resolve_export` for a dotted name such as ``app.billing.Invoice``.
|
|
96
|
+
|
|
97
|
+
The longest module prefix short of the last segment is imported as a module;
|
|
98
|
+
the remaining segments are attributes, as in ``from app.billing import
|
|
99
|
+
Invoice``, so a facade binding wins over a submodule of the same name. Access
|
|
100
|
+
on anything but a module stops at that object, which is where it is defined.
|
|
101
|
+
"""
|
|
102
|
+
parts = qualified.split(".")
|
|
103
|
+
end = next(
|
|
104
|
+
(
|
|
105
|
+
end
|
|
106
|
+
for end in range(len(parts) - 1, 0, -1)
|
|
107
|
+
if load(".".join(parts[:end])) is not None
|
|
108
|
+
),
|
|
109
|
+
None,
|
|
110
|
+
)
|
|
111
|
+
if end is None:
|
|
112
|
+
return (qualified, None) if load(qualified) is not None else None
|
|
113
|
+
current: tuple[str, str | None] | None = (".".join(parts[:end]), None)
|
|
114
|
+
for attribute in parts[end:]:
|
|
115
|
+
if current is None or current[1] is not None:
|
|
116
|
+
break
|
|
117
|
+
current = resolve_export(current[0], attribute, load)
|
|
118
|
+
return current
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _top_level(body: list[ast.stmt]) -> Iterator[ast.stmt]:
|
|
122
|
+
"""Statements at module level, including those under ``if`` and ``try``."""
|
|
123
|
+
for node in body:
|
|
124
|
+
if isinstance(node, ast.If):
|
|
125
|
+
yield from _top_level(node.body)
|
|
126
|
+
yield from _top_level(node.orelse)
|
|
127
|
+
elif isinstance(node, ast.Try):
|
|
128
|
+
for block in (node.body, node.orelse, node.finalbody):
|
|
129
|
+
yield from _top_level(block)
|
|
130
|
+
for handler in node.handlers:
|
|
131
|
+
yield from _top_level(handler.body)
|
|
132
|
+
else:
|
|
133
|
+
yield node
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _defined_names(node: ast.stmt) -> list[str]:
|
|
137
|
+
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef):
|
|
138
|
+
return [node.name]
|
|
139
|
+
targets: list[ast.expr] = []
|
|
140
|
+
if isinstance(node, ast.Assign):
|
|
141
|
+
targets = node.targets
|
|
142
|
+
elif isinstance(node, ast.AnnAssign | ast.AugAssign):
|
|
143
|
+
targets = [node.target]
|
|
144
|
+
elif isinstance(node, ast.TypeAlias):
|
|
145
|
+
targets = [node.name]
|
|
146
|
+
return [
|
|
147
|
+
name.id
|
|
148
|
+
for target in targets
|
|
149
|
+
for name in ast.walk(target)
|
|
150
|
+
if isinstance(name, ast.Name)
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _dotted_names(node: ast.stmt) -> Iterator[tuple[str, ...]]:
|
|
155
|
+
"""``chat.feature`` -> ``("chat", "feature")`` for every name the value reads."""
|
|
156
|
+
for child in ast.walk(node):
|
|
157
|
+
if isinstance(child, ast.Attribute):
|
|
158
|
+
parts: list[str] = []
|
|
159
|
+
current: ast.expr = child
|
|
160
|
+
while isinstance(current, ast.Attribute):
|
|
161
|
+
parts.append(current.attr)
|
|
162
|
+
current = current.value
|
|
163
|
+
if isinstance(current, ast.Name):
|
|
164
|
+
yield (current.id, *reversed(parts))
|
|
165
|
+
elif isinstance(child, ast.Name) and isinstance(child.ctx, ast.Load):
|
|
166
|
+
yield (child.id,)
|
|
@@ -4,6 +4,7 @@ import ast
|
|
|
4
4
|
from dataclasses import dataclass, field
|
|
5
5
|
from typing import TYPE_CHECKING
|
|
6
6
|
|
|
7
|
+
from smelt.analysis.exports import Namespace, namespace_of
|
|
7
8
|
from smelt.analysis.parsing import AstCache, resolve_relative
|
|
8
9
|
|
|
9
10
|
if TYPE_CHECKING:
|
|
@@ -25,6 +26,7 @@ class SyntaxIndex:
|
|
|
25
26
|
self.files = files
|
|
26
27
|
self._asts = asts
|
|
27
28
|
self._by_path: dict[str, ModuleSyntax] = {}
|
|
29
|
+
self._namespaces: dict[str, Namespace | None] = {}
|
|
28
30
|
|
|
29
31
|
def for_path(self, path: str) -> ModuleSyntax | None:
|
|
30
32
|
cached = self._by_path.get(path)
|
|
@@ -43,6 +45,21 @@ class SyntaxIndex:
|
|
|
43
45
|
self._by_path[path] = syntax
|
|
44
46
|
return syntax
|
|
45
47
|
|
|
48
|
+
def namespace(self, module: str) -> Namespace | None:
|
|
49
|
+
"""The top-level names of a source module; a loader for ``analysis.exports``."""
|
|
50
|
+
if module not in self._namespaces:
|
|
51
|
+
source = self.files.sources.get(module)
|
|
52
|
+
self._namespaces[module] = (
|
|
53
|
+
None
|
|
54
|
+
if source is None
|
|
55
|
+
else namespace_of(
|
|
56
|
+
self._asts.parse(source.path),
|
|
57
|
+
module,
|
|
58
|
+
is_package=source.is_package,
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
return self._namespaces[module]
|
|
62
|
+
|
|
46
63
|
|
|
47
64
|
def _collect_bindings(syntax: ModuleSyntax) -> None:
|
|
48
65
|
for node in ast.walk(syntax.tree):
|
|
@@ -61,9 +61,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
61
61
|
|
|
62
62
|
|
|
63
63
|
def _allow_local_config(parser: argparse.ArgumentParser) -> None:
|
|
64
|
-
parser.
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
if "--config" not in parser._option_string_actions:
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
"--config",
|
|
67
|
+
metavar="PATH",
|
|
68
|
+
default=argparse.SUPPRESS,
|
|
69
|
+
help="path to smelt.yaml",
|
|
70
|
+
)
|
|
67
71
|
for action in parser._actions:
|
|
68
72
|
if isinstance(action, argparse._SubParsersAction):
|
|
69
73
|
for child in action.choices.values():
|
|
@@ -131,7 +135,15 @@ def _add_discovery(sub: Subparsers) -> None:
|
|
|
131
135
|
|
|
132
136
|
def _add_maintenance(sub: Subparsers) -> None:
|
|
133
137
|
init = sub.add_parser("init", help="write a starter smelt.yaml")
|
|
134
|
-
init.add_argument(
|
|
138
|
+
init.add_argument(
|
|
139
|
+
"--config",
|
|
140
|
+
metavar="PATH",
|
|
141
|
+
default=argparse.SUPPRESS,
|
|
142
|
+
help="write the config here (default: smelt.yaml); its directory is the project root",
|
|
143
|
+
)
|
|
144
|
+
init.add_argument(
|
|
145
|
+
"--force", action="store_true", help="overwrite the target if it exists"
|
|
146
|
+
)
|
|
135
147
|
init.set_defaults(handler=commands.init)
|
|
136
148
|
|
|
137
149
|
debt = sub.add_parser("debt", help="record current violations as known debt")
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from pathlib import Path
|
|
3
4
|
from typing import TYPE_CHECKING
|
|
4
5
|
|
|
5
6
|
from smelt.analysis.parsing import AnalysisError
|
|
@@ -12,37 +13,65 @@ from smelt.engine.inference import infer_config, render_config
|
|
|
12
13
|
|
|
13
14
|
if TYPE_CHECKING:
|
|
14
15
|
import argparse
|
|
15
|
-
from pathlib import Path
|
|
16
16
|
|
|
17
17
|
from smelt.engine.inference import InferredConfig
|
|
18
|
+
from smelt.engine.workspace import WorkspaceMember
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
def init(args: argparse.Namespace, console: Console, cwd: Path) -> int:
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
custom: str | None = args.config
|
|
23
|
+
target, shown = _init_target(custom, cwd)
|
|
24
|
+
if target.is_dir():
|
|
25
|
+
msg = f"{shown} is a directory; pass the path of the config file to write"
|
|
26
|
+
raise CliError(msg, option="--config", value=custom)
|
|
27
|
+
if target.exists() and not args.force:
|
|
28
|
+
msg = f"{shown} already exists (use --force to overwrite)"
|
|
29
|
+
raise CliError(msg, option="--config" if custom else None, value=custom)
|
|
30
|
+
root = target.parent
|
|
31
|
+
if not root.is_dir():
|
|
32
|
+
msg = (
|
|
33
|
+
f"directory {root} does not exist; it would be the project root of {shown}"
|
|
34
|
+
)
|
|
35
|
+
raise CliError(msg, option="--config", value=custom)
|
|
36
|
+
inferred = infer_config(root)
|
|
26
37
|
if inferred is None:
|
|
27
38
|
msg = (
|
|
28
39
|
"no Python package found in this directory, src/, or declared uv workspace members; "
|
|
29
40
|
"run `smelt init` from the project root or configure source_roots manually"
|
|
30
41
|
)
|
|
31
42
|
raise CliError(msg)
|
|
32
|
-
target = cwd / CONFIG_FILENAME
|
|
33
|
-
for path in existing:
|
|
34
|
-
if path != target:
|
|
35
|
-
path.unlink()
|
|
36
43
|
target.write_text(render_config(inferred), encoding="utf-8", newline="\n")
|
|
37
|
-
console.print(f"Wrote {
|
|
44
|
+
console.print(f"Wrote {shown}")
|
|
38
45
|
for line in _summary(inferred):
|
|
39
46
|
console.print(f" {line}")
|
|
40
47
|
console.print(_violation_summary(target))
|
|
48
|
+
if target.name not in CONFIG_FILENAMES:
|
|
49
|
+
console.print(
|
|
50
|
+
f"smelt only finds {' or '.join(CONFIG_FILENAMES)} on its own; "
|
|
51
|
+
f"pass --config {shown} to every command."
|
|
52
|
+
)
|
|
41
53
|
return EXIT_OK
|
|
42
54
|
|
|
43
55
|
|
|
56
|
+
def _init_target(custom: str | None, cwd: Path) -> tuple[Path, str]:
|
|
57
|
+
"""The one file ``init`` writes: ``--config``, else the default config here.
|
|
58
|
+
|
|
59
|
+
Only that file is checked and overwritten; a reviewed config elsewhere stays.
|
|
60
|
+
Without ``--config`` an existing ``smelt.yml`` is the target, not a second file.
|
|
61
|
+
"""
|
|
62
|
+
if custom is not None:
|
|
63
|
+
path = Path(custom)
|
|
64
|
+
return (path if path.is_absolute() else cwd / path), custom
|
|
65
|
+
existing = next((name for name in CONFIG_FILENAMES if (cwd / name).exists()), None)
|
|
66
|
+
name = existing or CONFIG_FILENAME
|
|
67
|
+
return cwd / name, name
|
|
68
|
+
|
|
69
|
+
|
|
44
70
|
def _summary(inferred: InferredConfig) -> list[str]:
|
|
45
71
|
lines = [f"root packages: {', '.join(inferred.root_packages)}"]
|
|
72
|
+
if inferred.members:
|
|
73
|
+
lines.append("workspace members:")
|
|
74
|
+
lines.extend(f" {_member_status(member)}" for member in inferred.members)
|
|
46
75
|
lines.append(
|
|
47
76
|
"boundary coverage: direct imports only; third-party packages allowed (review layers.*.third_party and imports.transitive)"
|
|
48
77
|
)
|
|
@@ -57,10 +86,7 @@ def _summary(inferred: InferredConfig) -> list[str]:
|
|
|
57
86
|
lines.append("layers: none detected (see the commented example)")
|
|
58
87
|
if inferred.shared:
|
|
59
88
|
lines.append(f"shared: {', '.join(inferred.shared)}")
|
|
60
|
-
|
|
61
|
-
lines.append(f"composition root: {', '.join(inferred.composition_root)}")
|
|
62
|
-
if inferred.wiring:
|
|
63
|
-
lines.append(f"wiring: {', '.join(inferred.wiring)}")
|
|
89
|
+
lines.extend(_assembly_summary(inferred))
|
|
64
90
|
if inferred.modules:
|
|
65
91
|
lines.append(
|
|
66
92
|
"central modules: "
|
|
@@ -91,6 +117,38 @@ def _summary(inferred: InferredConfig) -> list[str]:
|
|
|
91
117
|
return lines
|
|
92
118
|
|
|
93
119
|
|
|
120
|
+
def _assembly_summary(inferred: InferredConfig) -> list[str]:
|
|
121
|
+
lines: list[str] = []
|
|
122
|
+
if inferred.composition_root:
|
|
123
|
+
lines.append(f"composition root: {', '.join(inferred.composition_root)}")
|
|
124
|
+
if inferred.wiring:
|
|
125
|
+
lines.append(f"wiring: {', '.join(inferred.wiring)}")
|
|
126
|
+
lines.extend(
|
|
127
|
+
f" {module}: {why}"
|
|
128
|
+
for module, why in inferred.evidence.items()
|
|
129
|
+
if module in inferred.composition_root or module in inferred.wiring
|
|
130
|
+
)
|
|
131
|
+
if inferred.candidates:
|
|
132
|
+
lines.append(
|
|
133
|
+
"composition-root candidates (commented out in the config, review):"
|
|
134
|
+
)
|
|
135
|
+
lines.extend(
|
|
136
|
+
f" {c.module} ({c.confidence} confidence, {c.reason}): {' → '.join(c.chain)}"
|
|
137
|
+
for c in inferred.candidates
|
|
138
|
+
)
|
|
139
|
+
return lines
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _member_status(member: WorkspaceMember) -> str:
|
|
143
|
+
if member.skipped:
|
|
144
|
+
return f"{member.path}: skipped, {member.skipped}"
|
|
145
|
+
packages = ", ".join(
|
|
146
|
+
f"{name} (namespace package)" if name in member.namespace else name
|
|
147
|
+
for name in member.packages
|
|
148
|
+
)
|
|
149
|
+
return f"{member.path}: {packages} in {member.source_root}"
|
|
150
|
+
|
|
151
|
+
|
|
94
152
|
def _violation_summary(path: Path) -> str:
|
|
95
153
|
try:
|
|
96
154
|
outcome = run_check(load_config(path), CheckOptions(use_debt=False))
|
|
@@ -103,7 +161,7 @@ def _violation_summary(path: Path) -> str:
|
|
|
103
161
|
return "The inferred config yields no violations. Run `smelt check` any time."
|
|
104
162
|
return (
|
|
105
163
|
f"The inferred config yields {errors} error{'s' * (errors != 1)} and "
|
|
106
|
-
f"{warnings} warning{'s' * (warnings != 1)}. Review {
|
|
164
|
+
f"{warnings} warning{'s' * (warnings != 1)}. Review {path.name}, then run "
|
|
107
165
|
"`smelt check`, or `smelt debt` to adopt incrementally."
|
|
108
166
|
)
|
|
109
167
|
|