cleanarch 0.1.0__py3-none-any.whl
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.
- cleanarch-0.1.0.dist-info/METADATA +106 -0
- cleanarch-0.1.0.dist-info/RECORD +11 -0
- cleanarch-0.1.0.dist-info/WHEEL +4 -0
- cleanarch-0.1.0.dist-info/entry_points.txt +2 -0
- cleanarchitecture/__init__.py +11 -0
- cleanarchitecture/__main__.py +5 -0
- cleanarchitecture/_lint_runner.py +24 -0
- cleanarchitecture/cli.py +91 -0
- cleanarchitecture/config.py +142 -0
- cleanarchitecture/contracts.py +207 -0
- cleanarchitecture/overrides.py +64 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: cleanarch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: モジュラモノリス + DDD のアーキテクチャ契約を「設定ファイルではなく CLI」として配る検査ツール
|
|
5
|
+
Project-URL: Homepage, https://github.com/theindiehacker/clean-architecture
|
|
6
|
+
Project-URL: Repository, https://github.com/theindiehacker/clean-architecture
|
|
7
|
+
Author-email: taiyo tamura <gtaiyou24@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Keywords: architecture,ddd,import-linter,lint,modular-monolith
|
|
10
|
+
Requires-Python: >=3.14
|
|
11
|
+
Requires-Dist: import-linter==2.13
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# 🏗️ Clean Architecture
|
|
15
|
+
|
|
16
|
+
モジュラモノリス + DDD のアーキテクチャ契約を、**設定ファイルではなく CLI として配る**検査ツール。
|
|
17
|
+
|
|
18
|
+
## 何が違うか
|
|
19
|
+
|
|
20
|
+
通常 [import-linter](https://import-linter.readthedocs.io/) の契約は各リポジトリの `.importlinter` に書く。
|
|
21
|
+
モジュールが増えれば契約ファイルも増え、プロジェクトが増えれば同じ文面がコピーで増殖する。
|
|
22
|
+
そして中央で契約を 1 本足しても、どのリポジトリにも届かない。
|
|
23
|
+
|
|
24
|
+
`cleanarch` は契約を**このパッケージの中**に持つ。利用側が書くのは宣言 1 ブロックだけで、
|
|
25
|
+
生成された契約はテンポラリファイルに書かれてそのまま捨てられる(リポジトリにコミットさせない
|
|
26
|
+
= 手で編集される余地を残さない)。契約を足したいときはこのパッケージのバージョンを上げる。
|
|
27
|
+
|
|
28
|
+
## 生成される契約
|
|
29
|
+
|
|
30
|
+
| 契約 | 内容 |
|
|
31
|
+
|:--|:--|
|
|
32
|
+
| `{module}-layers` | ヘキサゴナルの依存方向(`port → application → domain`)を exhaustive で強制 |
|
|
33
|
+
| `{module}-inbound-adapters` | 入力アダプタから domain への直接依存を禁止(ユースケース境界の空洞化を防ぐ) |
|
|
34
|
+
| `{module}-encapsulation` | 他モジュールから内部層への参照を禁止。**source は「自分以外の全モジュール」から自動生成** |
|
|
35
|
+
| `{shared}-purity` | 共有カーネルから業務モジュールへの依存を禁止(逆流防止) |
|
|
36
|
+
|
|
37
|
+
`{module}-encapsulation` の source を自動生成しているのが効く。手書きの契約ファイルでは、
|
|
38
|
+
モジュールを 1 つ足したときに既存モジュール全部の `source_modules` へ追記する必要があり、
|
|
39
|
+
**漏れがそのまま境界の穴になる**(fastship.jp の `.importlinter` にもこの注意書きがある)。
|
|
40
|
+
生成ならこの事故が起こらない。
|
|
41
|
+
|
|
42
|
+
## 導入
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
uv add --dev cleanarch
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## 設定
|
|
49
|
+
|
|
50
|
+
```toml
|
|
51
|
+
[tool.cleanarch]
|
|
52
|
+
src = "src"
|
|
53
|
+
modules = ["authority", "tenant", "notify"] # 省略時は src/* を自動検出
|
|
54
|
+
shared = ["common"]
|
|
55
|
+
|
|
56
|
+
# 既定値を上書きしたいとき
|
|
57
|
+
layers = ["port", "application", "domain"]
|
|
58
|
+
layer_ignores = ["core", "middleware", "exception", "settings"]
|
|
59
|
+
internal_layers = ["application", "domain", "port.adapter.persistence", "port.adapter.service"]
|
|
60
|
+
inbound_adapters = ["port.adapter.resource", "port.adapter.messaging"]
|
|
61
|
+
|
|
62
|
+
# 既存違反は負債として明示する。新規違反だけが CI を落とす。
|
|
63
|
+
debt = ["authority.port.adapter.resource.oauth.scopes_resource -> authority.domain.model.scope"]
|
|
64
|
+
|
|
65
|
+
# プロジェクト固有の契約
|
|
66
|
+
[[tool.cleanarch.forbidden]]
|
|
67
|
+
name = "redis-direct-access"
|
|
68
|
+
description = "RedisRegistry 以外からの redis 直接 import を禁止"
|
|
69
|
+
source_modules = ["authority", "tenant"]
|
|
70
|
+
forbidden_modules = ["redis"]
|
|
71
|
+
|
|
72
|
+
# フレームワーク実装の上書き(期限付きの負債)
|
|
73
|
+
[[tool.cleanarch.overrides]]
|
|
74
|
+
target = "authority.application.identity.IdentityApplicationService"
|
|
75
|
+
reason = "Identity Platform への資格情報移管。CredentialService ポートが未提供"
|
|
76
|
+
upstream = "https://github.com/theindiehacker/clean-architecture/issues/128"
|
|
77
|
+
sunset = 2026-12-31
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## コマンド
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
cleanarch check # 契約 + 上書き宣言を検査する(CI で使う。違反 or 期限切れで非 0 終了)
|
|
84
|
+
cleanarch contracts # 生成される import-linter 契約を表示する(デバッグ用)
|
|
85
|
+
cleanarch overrides # 上書き宣言の一覧と期限を表示する
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## 負債と上書きを 1 箇所に集める
|
|
89
|
+
|
|
90
|
+
`debt` と `overrides` をプロジェクト全体で 1 箇所に集約しているのは、**総量を中央から観測する**ため。
|
|
91
|
+
契約ファイルが 9 個に散っていると数えられない。
|
|
92
|
+
|
|
93
|
+
とくに `overrides` の一覧は、そのまま「フレームワークに足りない拡張点のバックログ」になる。
|
|
94
|
+
同じ上書きが 2 案件で現れたら、拡張点の不足が確定した合図。
|
|
95
|
+
|
|
96
|
+
## 開発
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
task init # 依存インストール
|
|
100
|
+
task test # テスト
|
|
101
|
+
task style:check # ruff / mypy
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## ライセンス
|
|
105
|
+
|
|
106
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
cleanarchitecture/__init__.py,sha256=kHI_FejyrsJ-dO4j_AEsZYdbn4TGIqNTXEPaZjmdwjg,343
|
|
2
|
+
cleanarchitecture/__main__.py,sha256=qi-CC9AuFW8sHeDQ_dgwVic_h0916UIw8SRBgfYXm8Q,69
|
|
3
|
+
cleanarchitecture/_lint_runner.py,sha256=ICNuOekVvfD2wzvtWPfbuyGKSwyxX1wg83ATjvbBPk0,955
|
|
4
|
+
cleanarchitecture/cli.py,sha256=FtLf38T7rG3xzr8j8QkX5ZY3-1wrlkMAzwMqrkIN_NY,3319
|
|
5
|
+
cleanarchitecture/config.py,sha256=bB4R_4DNCm8cHMqAETvqtlDR4ZTkcRRwItOck4FPHhk,5762
|
|
6
|
+
cleanarchitecture/contracts.py,sha256=Ju_yqW5cbmpptJ7TXcIjaIifTqQCwFOyVTq7gf3wqGE,8798
|
|
7
|
+
cleanarchitecture/overrides.py,sha256=6ZlBhYqXbrIH05iriids0Hyq4cYZkRpL8JQEKyN9TpA,2514
|
|
8
|
+
cleanarch-0.1.0.dist-info/METADATA,sha256=JD0eyGyQjYH894rT42VyGRweelw_-Ov9POAIM__KusQ,4802
|
|
9
|
+
cleanarch-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
10
|
+
cleanarch-0.1.0.dist-info/entry_points.txt,sha256=yi5oO7xWAYYaQxyTg5LyPVuJ_BJ9Bq8odLFwgXrhVWI,57
|
|
11
|
+
cleanarch-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""import-linter を子プロセスとして実行するエントリポイント。
|
|
2
|
+
|
|
3
|
+
`python -m cleanarchitecture._lint_runner <生成した .ini>` として `cli` から起動される。
|
|
4
|
+
別プロセスに分けるのは、import-linter が検査対象プロジェクトのソースを実際に import するため。
|
|
5
|
+
CLI 自身のプロセスに読み込ませると、利用側の import 副作用と sys.path 汚染を持ち込んでしまう。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from importlinter.application.use_cases import lint_imports
|
|
11
|
+
from importlinter.configuration import configure
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main(argv: list[str] | None = None) -> int:
|
|
15
|
+
args = sys.argv[1:] if argv is None else argv
|
|
16
|
+
if len(args) != 1:
|
|
17
|
+
print("usage: python -m cleanarchitecture._lint_runner <config.ini>", file=sys.stderr)
|
|
18
|
+
return 2
|
|
19
|
+
configure()
|
|
20
|
+
return 0 if lint_imports(config_filename=args[0], cache_dir=None) else 1
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if __name__ == "__main__":
|
|
24
|
+
sys.exit(main())
|
cleanarchitecture/cli.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""cleanarch のコマンドライン。
|
|
2
|
+
|
|
3
|
+
cleanarch check アーキテクチャ契約 + 上書き宣言を検査する (CI で使う)
|
|
4
|
+
cleanarch contracts 生成される import-linter 契約を表示する (デバッグ用)
|
|
5
|
+
cleanarch overrides 上書き宣言の一覧と期限を表示する
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from cleanarchitecture import contracts, overrides
|
|
18
|
+
from cleanarchitecture.config import ArchConfig, ConfigError, load
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main(argv: list[str] | None = None) -> int:
|
|
22
|
+
parser = argparse.ArgumentParser(prog="cleanarch", description="モジュラモノリス + DDD のアーキテクチャ契約検査")
|
|
23
|
+
parser.add_argument("command", choices=["check", "contracts", "overrides"], help="実行するコマンド")
|
|
24
|
+
parser.add_argument("-C", "--directory", default=".", help="プロジェクトルート (既定: カレント)")
|
|
25
|
+
args = parser.parse_args(argv)
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
config = load(Path(args.directory))
|
|
29
|
+
except ConfigError as e:
|
|
30
|
+
print(f"設定エラー: {e}", file=sys.stderr)
|
|
31
|
+
return 2
|
|
32
|
+
|
|
33
|
+
if args.command == "contracts":
|
|
34
|
+
print(contracts.build(config))
|
|
35
|
+
return 0
|
|
36
|
+
if args.command == "overrides":
|
|
37
|
+
return _overrides(config)
|
|
38
|
+
return _check(config)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _check(config: ArchConfig) -> int:
|
|
42
|
+
print(f"モジュール: {', '.join(config.modules)}")
|
|
43
|
+
if config.shared:
|
|
44
|
+
print(f"共有カーネル: {', '.join(config.shared)}")
|
|
45
|
+
|
|
46
|
+
architecture = _lint_imports(config)
|
|
47
|
+
print()
|
|
48
|
+
declarations = _overrides(config)
|
|
49
|
+
# どちらか一方でも落ちたら CI を失敗させる (個別の終了コードは区別しない)。
|
|
50
|
+
return 1 if architecture or declarations else 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _lint_imports(config: ArchConfig) -> int:
|
|
54
|
+
"""生成した契約で import-linter を実行する。
|
|
55
|
+
|
|
56
|
+
生成物を利用側リポジトリに書き出さないのは、`.importlinter` をコミットさせないため。
|
|
57
|
+
ファイルとして存在すると必ず手で編集され、中央の更新を受け取れなくなる。
|
|
58
|
+
"""
|
|
59
|
+
with tempfile.NamedTemporaryFile("w", suffix=".ini", encoding="utf-8", delete=False) as f:
|
|
60
|
+
f.write(contracts.build(config))
|
|
61
|
+
config_path = Path(f.name)
|
|
62
|
+
env = {**os.environ, "PYTHONPATH": os.pathsep.join(
|
|
63
|
+
[str(config.source_root), *([os.environ["PYTHONPATH"]] if os.environ.get("PYTHONPATH") else [])],
|
|
64
|
+
)}
|
|
65
|
+
try:
|
|
66
|
+
completed = subprocess.run( # noqa: S603
|
|
67
|
+
[sys.executable, "-m", "cleanarchitecture._lint_runner", str(config_path)],
|
|
68
|
+
cwd=config.source_root,
|
|
69
|
+
env=env,
|
|
70
|
+
check=False,
|
|
71
|
+
)
|
|
72
|
+
finally:
|
|
73
|
+
config_path.unlink(missing_ok=True)
|
|
74
|
+
return completed.returncode
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _overrides(config: ArchConfig) -> int:
|
|
78
|
+
report = overrides.inspect(config.overrides)
|
|
79
|
+
print(overrides.render(report))
|
|
80
|
+
if report.has_error:
|
|
81
|
+
print(
|
|
82
|
+
"\n期限切れの上書きがあります。upstream に拡張点を足して上書きを畳むか、"
|
|
83
|
+
"延長理由を添えて sunset を更新してください。",
|
|
84
|
+
file=sys.stderr,
|
|
85
|
+
)
|
|
86
|
+
return 1
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
if __name__ == "__main__":
|
|
91
|
+
sys.exit(main())
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""`pyproject.toml` の `[tool.cleanarch]` を読み取る。
|
|
2
|
+
|
|
3
|
+
利用側プロジェクトが書くアーキテクチャ設定はこの 1 ブロックだけ。契約そのもの (どんな依存を
|
|
4
|
+
禁じるか) はこのパッケージが持ち、バージョンを上げれば全プロジェクトに伝播する。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import tomllib
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import date
|
|
12
|
+
from pathlib import Path # noqa: TC003 # ArchConfig の実行時フィールド型
|
|
13
|
+
|
|
14
|
+
# 各モジュールの内部層 = 「外から import されてはいけない層」。
|
|
15
|
+
# 公開面は port.adapter.resource (HTTP / MCP 等の入力アダプタ) と core / __init__ のみ。
|
|
16
|
+
DEFAULT_INTERNAL_LAYERS = (
|
|
17
|
+
"application",
|
|
18
|
+
"domain",
|
|
19
|
+
"port.adapter.messaging",
|
|
20
|
+
"port.adapter.persistence",
|
|
21
|
+
"port.adapter.service",
|
|
22
|
+
)
|
|
23
|
+
# ヘキサゴナルの依存方向。左ほど外側。
|
|
24
|
+
DEFAULT_LAYERS = ("port", "application", "domain")
|
|
25
|
+
# layers 契約 (exhaustive) の対象外にするモジュール直下のファイル / パッケージ。
|
|
26
|
+
DEFAULT_LAYER_IGNORES = ("core", "middleware", "exception", "settings", "event", "notification", "doc")
|
|
27
|
+
# 入力アダプタ。ここから domain への直接依存を禁じ、application のユースケース境界を空洞化させない。
|
|
28
|
+
DEFAULT_INBOUND_ADAPTERS = ("port.adapter.resource", "port.adapter.messaging")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _today() -> date:
|
|
32
|
+
"""sunset は暦日での期限。実行環境のローカル暦日で判定する (CI ゲートとして自然な単位)。"""
|
|
33
|
+
return date.today() # noqa: DTZ011
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ConfigError(Exception):
|
|
37
|
+
"""`[tool.cleanarch]` の記述が不正なときに送出する。"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Override:
|
|
42
|
+
"""フレームワーク実装を案件側で差し替えている宣言 (期限付きの負債)。"""
|
|
43
|
+
|
|
44
|
+
target: str
|
|
45
|
+
reason: str
|
|
46
|
+
upstream: str
|
|
47
|
+
sunset: date
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def is_expired(self) -> bool:
|
|
51
|
+
return self.sunset < _today()
|
|
52
|
+
|
|
53
|
+
def days_left(self) -> int:
|
|
54
|
+
return (self.sunset - _today()).days
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class ArchConfig:
|
|
59
|
+
project_root: Path
|
|
60
|
+
source_root: Path
|
|
61
|
+
modules: tuple[str, ...]
|
|
62
|
+
shared: tuple[str, ...] = ()
|
|
63
|
+
layers: tuple[str, ...] = DEFAULT_LAYERS
|
|
64
|
+
layer_ignores: tuple[str, ...] = DEFAULT_LAYER_IGNORES
|
|
65
|
+
internal_layers: tuple[str, ...] = DEFAULT_INTERNAL_LAYERS
|
|
66
|
+
inbound_adapters: tuple[str, ...] = DEFAULT_INBOUND_ADAPTERS
|
|
67
|
+
debt: tuple[str, ...] = ()
|
|
68
|
+
overrides: tuple[Override, ...] = ()
|
|
69
|
+
forbidden: tuple[dict, ...] = field(default=())
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def root_packages(self) -> tuple[str, ...]:
|
|
73
|
+
return tuple(sorted({*self.modules, *self.shared}))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def load(project_root: Path) -> ArchConfig:
|
|
77
|
+
"""`{project_root}/pyproject.toml` から設定を読む。"""
|
|
78
|
+
pyproject = project_root / "pyproject.toml"
|
|
79
|
+
if not pyproject.exists():
|
|
80
|
+
raise ConfigError(f"pyproject.toml が見つかりません: {pyproject}")
|
|
81
|
+
|
|
82
|
+
table = tomllib.loads(pyproject.read_text(encoding="utf-8")).get("tool", {}).get("cleanarch")
|
|
83
|
+
if table is None:
|
|
84
|
+
raise ConfigError(
|
|
85
|
+
f"{pyproject} に [tool.cleanarch] がありません。"
|
|
86
|
+
" 最低限 src (ソースルート) を設定してください。",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
source_root = (project_root / table.get("src", "src")).resolve()
|
|
90
|
+
if not source_root.is_dir():
|
|
91
|
+
raise ConfigError(f"ソースルートが存在しません: {source_root}")
|
|
92
|
+
|
|
93
|
+
shared = tuple(table.get("shared", ()))
|
|
94
|
+
modules = tuple(table.get("modules") or _discover(source_root, shared))
|
|
95
|
+
if not modules:
|
|
96
|
+
raise ConfigError(
|
|
97
|
+
f"モジュールが 1 つも見つかりません ({source_root})。"
|
|
98
|
+
" [tool.cleanarch] の modules に明示してください。",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
unknown = [m for m in (*modules, *shared) if not (source_root / m).is_dir()]
|
|
102
|
+
if unknown:
|
|
103
|
+
raise ConfigError(f"存在しないモジュールが指定されています: {', '.join(unknown)}")
|
|
104
|
+
|
|
105
|
+
return ArchConfig(
|
|
106
|
+
project_root=project_root.resolve(),
|
|
107
|
+
source_root=source_root,
|
|
108
|
+
modules=modules,
|
|
109
|
+
shared=shared,
|
|
110
|
+
layers=tuple(table.get("layers", DEFAULT_LAYERS)),
|
|
111
|
+
layer_ignores=tuple(table.get("layer_ignores", DEFAULT_LAYER_IGNORES)),
|
|
112
|
+
internal_layers=tuple(table.get("internal_layers", DEFAULT_INTERNAL_LAYERS)),
|
|
113
|
+
inbound_adapters=tuple(table.get("inbound_adapters", DEFAULT_INBOUND_ADAPTERS)),
|
|
114
|
+
debt=tuple(table.get("debt", ())),
|
|
115
|
+
overrides=tuple(_override(o) for o in table.get("overrides", ())),
|
|
116
|
+
forbidden=tuple(table.get("forbidden", ())),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _discover(source_root: Path, shared: tuple[str, ...]) -> tuple[str, ...]:
|
|
121
|
+
"""ソースルート直下の Python パッケージをモジュールとみなす。"""
|
|
122
|
+
return tuple(sorted(
|
|
123
|
+
path.name
|
|
124
|
+
for path in source_root.iterdir()
|
|
125
|
+
if path.is_dir()
|
|
126
|
+
and (path / "__init__.py").exists()
|
|
127
|
+
and not path.name.startswith((".", "_"))
|
|
128
|
+
and path.name not in shared
|
|
129
|
+
))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _override(raw: dict) -> Override:
|
|
133
|
+
missing = [key for key in ("target", "reason", "upstream", "sunset") if not raw.get(key)]
|
|
134
|
+
if missing:
|
|
135
|
+
raise ConfigError(
|
|
136
|
+
f"[[tool.cleanarch.overrides]] に {', '.join(missing)} がありません: {raw.get('target', raw)}。"
|
|
137
|
+
" 上書きは理由・upstream Issue・期限をセットで宣言してください。",
|
|
138
|
+
)
|
|
139
|
+
sunset = raw["sunset"]
|
|
140
|
+
if not isinstance(sunset, date):
|
|
141
|
+
raise ConfigError(f"sunset は日付で指定してください (例: 2026-12-31): {raw['target']}")
|
|
142
|
+
return Override(raw["target"], raw["reason"], raw["upstream"], sunset)
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""`[tool.cleanarch]` から import-linter の契約を組み立てる。
|
|
2
|
+
|
|
3
|
+
**契約はここにある** — 利用側の `.importlinter` には無い。契約を 1 本足したいときは
|
|
4
|
+
このパッケージを直して version を上げるだけで、全プロジェクトに伝播する。
|
|
5
|
+
利用側がファイルをコピーしないので、プロジェクトが増えても契約はずれない。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from cleanarchitecture.config import ArchConfig
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Contract:
|
|
20
|
+
"""import-linter の 1 契約。INI のフォーマットはこの型だけが知る。
|
|
21
|
+
|
|
22
|
+
値がリストのオプションはインデント付きの複数行に、空のリストは行ごと落とす。
|
|
23
|
+
各契約ビルダーが文字列連結を書かずに済むため、契約を足す差分が「何を禁じるか」だけになる。
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
id: str
|
|
27
|
+
name: str
|
|
28
|
+
type: str
|
|
29
|
+
options: dict[str, str | list[str]]
|
|
30
|
+
|
|
31
|
+
def render(self) -> str:
|
|
32
|
+
lines = [f"[importlinter:contract:{self.id}]", f"name = {self.name}", f"type = {self.type}"]
|
|
33
|
+
for key, value in self.options.items():
|
|
34
|
+
if isinstance(value, str):
|
|
35
|
+
lines.append(f"{key} = {value}")
|
|
36
|
+
elif value:
|
|
37
|
+
lines.append(f"{key} =")
|
|
38
|
+
lines.extend(f" {item}" for item in value)
|
|
39
|
+
return "\n".join(lines) + "\n"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class Debt:
|
|
44
|
+
"""既存違反 (負債) の 1 エントリ。新規違反だけが CI を落とすようにするための逃がし口。
|
|
45
|
+
|
|
46
|
+
`debt` はプロジェクト全体で 1 箇所 (`[tool.cleanarch] debt`) に集約されるため、負債の総量が
|
|
47
|
+
中央から観測できる。契約ファイルが 9 個に散っていると数えられない。
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
source: str
|
|
51
|
+
target: str
|
|
52
|
+
entry: str
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def parse(entry: str) -> Debt:
|
|
56
|
+
source, _, target = entry.partition("->")
|
|
57
|
+
return Debt(source.strip(), target.strip(), entry)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build(config: ArchConfig) -> str:
|
|
61
|
+
"""import-linter の設定 (INI) を生成する。"""
|
|
62
|
+
debt = [Debt.parse(entry) for entry in config.debt]
|
|
63
|
+
contracts = [
|
|
64
|
+
*(_layers(config, module) for module in config.modules),
|
|
65
|
+
*(_inbound_adapters(config, module, debt) for module in config.modules),
|
|
66
|
+
*(_encapsulation(config, module, debt) for module in config.modules),
|
|
67
|
+
*(_shared_purity(config, shared, debt) for shared in config.shared),
|
|
68
|
+
*(_custom(rule) for rule in config.forbidden),
|
|
69
|
+
]
|
|
70
|
+
return "\n".join([_header(config), *(c.render() for c in contracts if c is not None)])
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _header(config: ArchConfig) -> str:
|
|
74
|
+
packages = "\n".join(f" {name}" for name in config.root_packages)
|
|
75
|
+
return (
|
|
76
|
+
"# このファイルは cleanarch が生成しています。手で編集しないでください。\n"
|
|
77
|
+
"# 契約の追加・変更は cleanarch のバージョンを上げることで受け取ります。\n"
|
|
78
|
+
"[importlinter]\n"
|
|
79
|
+
f"root_packages =\n{packages}\n"
|
|
80
|
+
"# 解消済みの負債エントリで CI を落とさず、可視化だけする\n"
|
|
81
|
+
"unmatched_ignore_imports_alerting = warn\n"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _layers(config: ArchConfig, module: str) -> Contract:
|
|
86
|
+
"""ヘキサゴナルアーキテクチャの依存方向 (port → application → domain)。"""
|
|
87
|
+
return Contract(
|
|
88
|
+
id=f"{module}-layers",
|
|
89
|
+
name=f"[{module}] ヘキサゴナルアーキテクチャの依存違反を禁止",
|
|
90
|
+
type="layers",
|
|
91
|
+
options={
|
|
92
|
+
"layers": list(config.layers),
|
|
93
|
+
"containers": [module],
|
|
94
|
+
"exhaustive": "true",
|
|
95
|
+
"exhaustive_ignores": list(config.layer_ignores),
|
|
96
|
+
},
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _inbound_adapters(config: ArchConfig, module: str, debt: list[Debt]) -> Contract | None:
|
|
101
|
+
"""入力アダプタは application を経由してドメインを操作する。
|
|
102
|
+
|
|
103
|
+
domain を直接 import すると application のユースケース境界が空洞化するため禁止する。
|
|
104
|
+
"""
|
|
105
|
+
adapters = [f"{module}.{name}" for name in config.inbound_adapters if _exists(config, module, name)]
|
|
106
|
+
if not adapters or not _exists(config, module, "domain"):
|
|
107
|
+
return None
|
|
108
|
+
return Contract(
|
|
109
|
+
id=f"{module}-inbound-adapters",
|
|
110
|
+
name=f"[{module}] 入力アダプタから domain への直接依存を禁止",
|
|
111
|
+
type="forbidden",
|
|
112
|
+
options={
|
|
113
|
+
"source_modules": adapters,
|
|
114
|
+
"forbidden_modules": [f"{module}.domain"],
|
|
115
|
+
"allow_indirect_imports": "true",
|
|
116
|
+
"ignore_imports": _entries(_into_own_domain(debt, module)),
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _encapsulation(config: ArchConfig, module: str, debt: list[Debt]) -> Contract | None:
|
|
122
|
+
"""他モジュールから内部層への参照を禁止する (モジュール境界のカプセル化)。
|
|
123
|
+
|
|
124
|
+
`source_modules` は「自分以外の全 root package」から自動生成する。モジュールを 1 つ足したとき
|
|
125
|
+
に既存モジュールの契約へ追記し忘れる (= 境界の穴が開く) 事故を、生成で構造的に塞ぐ。
|
|
126
|
+
"""
|
|
127
|
+
others = [name for name in config.root_packages if name != module]
|
|
128
|
+
internals = [f"{module}.{layer}" for layer in config.internal_layers if _exists(config, module, layer)]
|
|
129
|
+
if not others or not internals:
|
|
130
|
+
return None
|
|
131
|
+
return Contract(
|
|
132
|
+
id=f"{module}-encapsulation",
|
|
133
|
+
name=f"[{module}] 外部モジュールから内部層への参照を禁止",
|
|
134
|
+
type="forbidden",
|
|
135
|
+
options={
|
|
136
|
+
"source_modules": others,
|
|
137
|
+
"forbidden_modules": internals,
|
|
138
|
+
"allow_indirect_imports": "true",
|
|
139
|
+
"ignore_imports": _entries(_from_outside(debt, module)),
|
|
140
|
+
},
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _shared_purity(config: ArchConfig, shared: str, debt: list[Debt]) -> Contract:
|
|
145
|
+
"""共有カーネルから業務モジュールへの依存を禁止する (逆流防止)。
|
|
146
|
+
|
|
147
|
+
共有カーネルに業務語彙が染み出すと、切り出したときに他プロジェクトで邪魔になる。
|
|
148
|
+
レビューの目視ではなく機械で落とす。
|
|
149
|
+
"""
|
|
150
|
+
return Contract(
|
|
151
|
+
id=f"{shared}-purity",
|
|
152
|
+
name=f"[{shared}] 共有カーネルから業務モジュールへの依存を禁止",
|
|
153
|
+
type="forbidden",
|
|
154
|
+
options={
|
|
155
|
+
"source_modules": [shared],
|
|
156
|
+
"forbidden_modules": list(config.modules),
|
|
157
|
+
"ignore_imports": _entries(_originating_in(debt, shared)),
|
|
158
|
+
},
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _custom(rule: dict) -> Contract:
|
|
163
|
+
"""利用側が固有に足す forbidden 契約 (`[[tool.cleanarch.forbidden]]`)。"""
|
|
164
|
+
return Contract(
|
|
165
|
+
id=f"custom-{rule['name']}",
|
|
166
|
+
name=f"[custom] {rule.get('description', rule['name'])}",
|
|
167
|
+
type="forbidden",
|
|
168
|
+
options={
|
|
169
|
+
"source_modules": list(rule["source_modules"]),
|
|
170
|
+
"forbidden_modules": list(rule["forbidden_modules"]),
|
|
171
|
+
"allow_indirect_imports": "true" if rule.get("allow_indirect_imports", True) else "false",
|
|
172
|
+
"ignore_imports": list(rule.get("ignore_imports", ())),
|
|
173
|
+
},
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _exists(config: ArchConfig, module: str, dotted: str) -> bool:
|
|
178
|
+
"""`{module}.{dotted}` が実ファイルとして存在するか。
|
|
179
|
+
|
|
180
|
+
契約を生成で組み立てる以上、モジュールごとの構成差 (HTTP を持たない・messaging を持たない)
|
|
181
|
+
を吸収するのは生成側の責務。存在しないモジュールを契約に書くと import-linter が止まる。
|
|
182
|
+
"""
|
|
183
|
+
base = config.source_root / module / Path(*dotted.split("."))
|
|
184
|
+
return base.is_dir() or base.with_suffix(".py").is_file()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# 負債をどの契約に効かせるかは「向き」で決まる。全契約に配ると import-linter が未使用の
|
|
188
|
+
# ignore として警告するため、契約ごとに向きの合うものだけを拾う。
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _into_own_domain(debt: list[Debt], module: str) -> list[Debt]:
|
|
192
|
+
"""自モジュールの入力アダプタ → 自モジュールの domain"""
|
|
193
|
+
return [d for d in debt if d.source.startswith(f"{module}.") and d.target.startswith(f"{module}.domain")]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _from_outside(debt: list[Debt], module: str) -> list[Debt]:
|
|
197
|
+
"""他モジュール → 自モジュールの内部層"""
|
|
198
|
+
return [d for d in debt if d.target.startswith(f"{module}.") and not d.source.startswith(f"{module}.")]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _originating_in(debt: list[Debt], package: str) -> list[Debt]:
|
|
202
|
+
"""共有カーネル → 業務モジュール"""
|
|
203
|
+
return [d for d in debt if d.source.startswith(f"{package}.")]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _entries(debt: list[Debt]) -> list[str]:
|
|
207
|
+
return [d.entry for d in debt]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""フレームワーク実装の上書き宣言を検査する。
|
|
2
|
+
|
|
3
|
+
案件ごとにポート実装やアプリケーションサービスを差し替えることはある。問題は上書きそのもの
|
|
4
|
+
ではなく、**上書きが不可視のまま放置されること**。宣言を必須にし期限を切ることで、上書きの
|
|
5
|
+
一覧がそのまま「フレームワークに足りない拡張点のバックログ」になる。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING, NamedTuple
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from cleanarchitecture.config import Override
|
|
14
|
+
|
|
15
|
+
# 期限までこの日数を切ったら警告する (upstream 化に着手する猶予)。
|
|
16
|
+
WARN_THRESHOLD_DAYS = 30
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Report(NamedTuple):
|
|
20
|
+
expired: list[Override]
|
|
21
|
+
expiring: list[Override]
|
|
22
|
+
healthy: list[Override]
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def has_error(self) -> bool:
|
|
26
|
+
return bool(self.expired)
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def total(self) -> int:
|
|
30
|
+
return len(self.expired) + len(self.expiring) + len(self.healthy)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def inspect(overrides: tuple[Override, ...]) -> Report:
|
|
34
|
+
expired = [o for o in overrides if o.is_expired]
|
|
35
|
+
expiring = [o for o in overrides if not o.is_expired and o.days_left() <= WARN_THRESHOLD_DAYS]
|
|
36
|
+
healthy = [o for o in overrides if not o.is_expired and o.days_left() > WARN_THRESHOLD_DAYS]
|
|
37
|
+
return Report(expired, expiring, healthy)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def render(report: Report) -> str:
|
|
41
|
+
if report.total == 0:
|
|
42
|
+
return "上書き宣言はありません。"
|
|
43
|
+
|
|
44
|
+
# 重大度ごとに情報量を変える。期限切れは「次に何をすべきか」まで、有効なものは存在だけ。
|
|
45
|
+
lines: list[str] = []
|
|
46
|
+
for override in report.expired:
|
|
47
|
+
lines.extend([
|
|
48
|
+
f" ✕ {override.target}",
|
|
49
|
+
f" 期限切れ {-override.days_left()} 日 (sunset {override.sunset})",
|
|
50
|
+
f" 理由: {override.reason}",
|
|
51
|
+
f" upstream: {override.upstream}",
|
|
52
|
+
])
|
|
53
|
+
for override in report.expiring:
|
|
54
|
+
lines.extend([
|
|
55
|
+
f" ! {override.target} 残り {override.days_left()} 日 (sunset {override.sunset})",
|
|
56
|
+
f" upstream: {override.upstream}",
|
|
57
|
+
])
|
|
58
|
+
lines.extend(f" · {override.target} 残り {override.days_left()} 日" for override in report.healthy)
|
|
59
|
+
|
|
60
|
+
summary = (
|
|
61
|
+
f"上書き {report.total} 件 "
|
|
62
|
+
f"(期限切れ {len(report.expired)} / 期限間近 {len(report.expiring)} / 有効 {len(report.healthy)})"
|
|
63
|
+
)
|
|
64
|
+
return "\n".join([summary, *lines])
|