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.
Files changed (70) hide show
  1. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/PKG-INFO +43 -16
  2. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/README.md +42 -15
  3. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/pyproject.toml +1 -1
  4. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/pyproject.toml.orig +1 -1
  5. smelt_cli-0.1.3/smelt/analysis/exports.py +166 -0
  6. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/analysis/syntax.py +17 -0
  7. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/cli/app.py +16 -4
  8. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/cli/commands/adopt.py +74 -16
  9. smelt_cli-0.1.3/smelt/config/discovery.py +79 -0
  10. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/config/models.py +24 -0
  11. smelt_cli-0.1.3/smelt/diagnostics/groups.py +108 -0
  12. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/diagnostics/render/machine.py +16 -0
  13. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/diagnostics/render/text.py +40 -1
  14. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/diagnostics/report.py +5 -1
  15. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/diagnostics/violation.py +16 -0
  16. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/engine/briefing.py +22 -1
  17. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/engine/changes.py +34 -8
  18. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/engine/check.py +10 -6
  19. smelt_cli-0.1.3/smelt/engine/coverage.py +108 -0
  20. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/engine/inference.py +313 -65
  21. smelt_cli-0.1.3/smelt/engine/workspace.py +106 -0
  22. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/rules/dependencies/cycles.py +89 -4
  23. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/rules/testing/location.py +193 -47
  24. smelt_cli-0.1.2/smelt/config/discovery.py +0 -49
  25. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/__init__.py +0 -0
  26. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/__main__.py +0 -0
  27. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/analysis/__init__.py +0 -0
  28. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/analysis/context.py +0 -0
  29. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/analysis/files.py +0 -0
  30. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/analysis/graphs.py +0 -0
  31. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/analysis/imports.py +0 -0
  32. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/analysis/parsing.py +0 -0
  33. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/cli/__init__.py +0 -0
  34. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/cli/commands/__init__.py +0 -0
  35. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/cli/commands/check.py +0 -0
  36. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/cli/commands/discover.py +0 -0
  37. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/cli/commands/info.py +0 -0
  38. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/cli/support.py +0 -0
  39. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/config/__init__.py +0 -0
  40. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/config/errors.py +0 -0
  41. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/config/loader.py +0 -0
  42. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/config/patterns.py +0 -0
  43. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/config/schema.py +0 -0
  44. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/config/validation.py +0 -0
  45. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/diagnostics/__init__.py +0 -0
  46. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/diagnostics/debt.py +0 -0
  47. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/diagnostics/dedupe.py +0 -0
  48. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/diagnostics/render/__init__.py +0 -0
  49. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/diagnostics/suppressions.py +0 -0
  50. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/docs.py +0 -0
  51. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/engine/__init__.py +0 -0
  52. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/engine/mirror_inference.py +0 -0
  53. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/engine/paths.py +0 -0
  54. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/model/__init__.py +0 -0
  55. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/model/architecture.py +0 -0
  56. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/py.typed +0 -0
  57. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/rules/__init__.py +0 -0
  58. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/rules/base.py +0 -0
  59. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/rules/common.py +0 -0
  60. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/rules/dependencies/__init__.py +0 -0
  61. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/rules/dependencies/composition_root.py +0 -0
  62. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/rules/dependencies/features.py +0 -0
  63. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/rules/dependencies/layers.py +0 -0
  64. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/rules/dependencies/third_party.py +0 -0
  65. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/rules/meta.py +0 -0
  66. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/rules/registry.py +0 -0
  67. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/rules/structure/__init__.py +0 -0
  68. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/rules/structure/layout.py +0 -0
  69. {smelt_cli-0.1.2 → smelt_cli-0.1.3}/smelt/rules/structure/naming.py +0 -0
  70. {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.2
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. Until this project has a PyPI release under its own
28
- distribution name, install from this repository:
29
-
30
- ```bash
31
- uv tool install git+https://github.com/mathisarends/smelt.git
32
- smelt --version
33
- ```
34
-
35
- From a local checkout, use `uv tool install .`. The PyPI package named `smelt` belongs
36
- to a different project; `pip install smelt` and bare `uvx smelt` install that project.
37
-
38
- ## Installation
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, wiring patterns, central packages by name and
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. Accept what remains with a baseline:
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.2
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. Until this project has a PyPI release under its own
13
- distribution name, install from this repository:
14
-
15
- ```bash
16
- uv tool install git+https://github.com/mathisarends/smelt.git
17
- smelt --version
18
- ```
19
-
20
- From a local checkout, use `uv tool install .`. The PyPI package named `smelt` belongs
21
- to a different project; `pip install smelt` and bare `uvx smelt` install that project.
22
-
23
- ## Installation
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, wiring patterns, central packages by name and
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. Accept what remains with a baseline:
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.2
242
+ rev: v0.1.3
216
243
  hooks:
217
244
  - id: smelt
218
245
  ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "smelt-cli"
3
- version = "0.1.2"
3
+ version = "0.1.3"
4
4
  description = "Static guardrails for Python codebases maintained by humans and coding agents."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "smelt-cli"
3
- version = "0.1.2"
3
+ version = "0.1.3"
4
4
  description = "Static guardrails for Python codebases maintained by humans and coding agents."
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Mathis Arends", email = "mathisarends27@gmail.com" }]
@@ -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.add_argument(
65
- "--config", metavar="PATH", default=argparse.SUPPRESS, help="path to smelt.yaml"
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("--force", action="store_true", help="overwrite an existing file")
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
- existing = [cwd / name for name in CONFIG_FILENAMES if (cwd / name).exists()]
22
- if existing and not args.force:
23
- msg = f"{existing[0].name} already exists (use --force to overwrite)"
24
- raise CliError(msg)
25
- inferred = infer_config(cwd)
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 {CONFIG_FILENAME}")
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
- if inferred.composition_root:
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 {CONFIG_FILENAME}, then run "
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