From 99c915b93a19eea1c3730c69e69eef4df1917555 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Mon, 24 Aug 2026 01:19:32 +0800 Subject: [PATCH] refactor: separate config debt from boundaries --- .../backend-architecture-next-stage.md | 7 +++ scripts/architecture/baseline.py | 37 +++++++++++++-- .../configuration-debt-baseline.json | 46 +++++++++++++------ tests/test_architecture_baseline_cli.py | 30 +++++++++++- tests/test_architecture_contract_baseline.py | 18 +++++++- 5 files changed, 117 insertions(+), 21 deletions(-) diff --git a/docs/refactor/backend-architecture-next-stage.md b/docs/refactor/backend-architecture-next-stage.md index faa3d18da..87c93cee4 100644 --- a/docs/refactor/backend-architecture-next-stage.md +++ b/docs/refactor/backend-architecture-next-stage.md @@ -109,6 +109,9 @@ 宿主 Module 的部署配置读取也已统一到 `RuntimeSettingsCompat`:PostgreSQL、Redis、qBittorrent、 rTorrent 和 Transmission 不再直接导入全局 Settings,模块目录由架构测试保持零直连;兼容代理仍保留 启动早期与旧插件/测试的动态 Settings 注入语义。 + 配置治理基线进一步区分债务与批准边界:canonical 未批准 Settings 直连和非组合根 + `SystemConfigOper()` 构造均为 `0`;`app/db/base.py`、`engine.py`、`session.py` 的启动前数据库基础设施 + 读取及 startup 唯一 Oper 构造点以带理由的固定边界登记。四个集合都只能减少,不能新增或换位置。 ### P2:中长期可演进性债务 @@ -1256,6 +1259,10 @@ FS proxy、Local/WebPush 存储适配以及 Module host adapter 切换到动态 Settings,避免代理自递归,配置债务由 15 个文件降至 8 个。数据库引擎/Session 与下载器模块保留底层 Settings 读取作为基础设施边界,架构基线已明确记录该例外,启动与架构专项测试通过。 +2026-08-24 将 configuration baseline 升级为 schema v2:待整改的直接 Settings 与 Oper 构造均清零; +数据库模型/引擎/Session 的 3 个启动前读取和 startup 的唯一 Oper 构造点进入带理由的批准边界。ratchet +同时冻结债务与批准边界,批准项不能扩张,因此不会通过新增“例外”掩盖配置回流。 + 同日修正适配器配置下沉边界:OCR、CookieCloud、DoH、Rust 和资源签名等低层实现不再直接依赖 `app.application`,由 `app.runtime.settings` 端口承接组合根注入;未启动装配时仍回退旧 Settings ABI, 架构依赖专项和官方插件语义观察均通过。 diff --git a/scripts/architecture/baseline.py b/scripts/architecture/baseline.py index fb816de5f..f7ae97a03 100644 --- a/scripts/architecture/baseline.py +++ b/scripts/architecture/baseline.py @@ -65,6 +65,15 @@ CONFIGURATION_EXCLUDED_ROOTS = ( APP_ROOT / "runtime" / "compat", APP_ROOT / "testing", ) +FOUNDATIONAL_SETTINGS_BOUNDARIES = { + "app/db/base.py": "模型声明阶段必须在运行时配置服务装配前确定数据库主键类型", + "app/db/engine.py": "数据库引擎是运行时配置服务的底层依赖,不能通过兼容代理自递归", + "app/db/session.py": "数据库会话与连接配额必须在应用组合根装配前可用", +} +COMPOSITION_ROOT_OPER_BOUNDARIES = { + ("app/startup/initializers/modules.py", "SystemConfigOper"): + "启动组合根负责构造唯一的系统配置数据库适配器", +} def discover_modules() -> dict[str, Path]: @@ -118,9 +127,11 @@ def iter_runtime_import_nodes(tree: ast.AST): def collect_configuration_debt_baseline() -> dict[str, Any]: - """收集宿主 canonical 代码直接读取 settings 和构造数据库配置适配器的债务。""" + """分离配置债务与数据库基础设施、组合根的固定批准边界。""" settings_files: list[str] = [] + foundational_settings: list[dict[str, str]] = [] oper_calls: list[dict[str, Any]] = [] + composition_root_calls: list[dict[str, str]] = [] for path in sorted(APP_ROOT.rglob("*.py")): if any(path.is_relative_to(root) for root in CONFIGURATION_EXCLUDED_ROOTS): continue @@ -143,14 +154,22 @@ def collect_configuration_debt_baseline() -> dict[str, Any]: if alias.name == "SystemConfigOper" ) if imports_settings: - settings_files.append(relative) + if reason := FOUNDATIONAL_SETTINGS_BOUNDARIES.get(relative): + foundational_settings.append({"file": relative, "reason": reason}) + else: + settings_files.append(relative) for node in ast.walk(tree): if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name): continue if node.func.id in direct_oper_names: - oper_calls.append({"file": relative, "name": node.func.id}) + call = {"file": relative, "name": node.func.id} + boundary_key = (relative, node.func.id) + if reason := COMPOSITION_ROOT_OPER_BOUNDARIES.get(boundary_key): + composition_root_calls.append({**call, "reason": reason}) + else: + oper_calls.append(call) return { - "schema_version": 1, + "schema_version": 2, "scope": { "root": "app", "excluded": [ @@ -164,10 +183,18 @@ def collect_configuration_debt_baseline() -> dict[str, Any]: "count": len(settings_files), "files": settings_files, }, + "foundational_settings_boundaries": { + "count": len(foundational_settings), + "entries": foundational_settings, + }, "system_config_oper_constructions": { "count": len(oper_calls), "calls": oper_calls, }, + "composition_root_oper_boundaries": { + "count": len(composition_root_calls), + "entries": composition_root_calls, + }, } @@ -1326,6 +1353,8 @@ def configuration_ratchet_matches( sections = ( ("settings_imports", "files"), ("system_config_oper_constructions", "calls"), + ("foundational_settings_boundaries", "entries"), + ("composition_root_oper_boundaries", "entries"), ) for section, entries_key in sections: expected_section = expected.get(section, {}) diff --git a/tests/fixtures/architecture/configuration-debt-baseline.json b/tests/fixtures/architecture/configuration-debt-baseline.json index 2ba30282f..f871c11ac 100644 --- a/tests/fixtures/architecture/configuration-debt-baseline.json +++ b/tests/fixtures/architecture/configuration-debt-baseline.json @@ -1,5 +1,32 @@ { - "schema_version": 1, + "composition_root_oper_boundaries": { + "count": 1, + "entries": [ + { + "file": "app/startup/initializers/modules.py", + "name": "SystemConfigOper", + "reason": "启动组合根负责构造唯一的系统配置数据库适配器" + } + ] + }, + "foundational_settings_boundaries": { + "count": 3, + "entries": [ + { + "file": "app/db/base.py", + "reason": "模型声明阶段必须在运行时配置服务装配前确定数据库主键类型" + }, + { + "file": "app/db/engine.py", + "reason": "数据库引擎是运行时配置服务的底层依赖,不能通过兼容代理自递归" + }, + { + "file": "app/db/session.py", + "reason": "数据库会话与连接配额必须在应用组合根装配前可用" + } + ] + }, + "schema_version": 2, "scope": { "excluded": [ "app/plugins", @@ -10,20 +37,11 @@ "root": "app" }, "settings_imports": { - "count": 3, - "files": [ - "app/db/base.py", - "app/db/engine.py", - "app/db/session.py" - ] + "count": 0, + "files": [] }, "system_config_oper_constructions": { - "calls": [ - { - "file": "app/startup/initializers/modules.py", - "name": "SystemConfigOper" - } - ], - "count": 1 + "calls": [], + "count": 0 } } diff --git a/tests/test_architecture_baseline_cli.py b/tests/test_architecture_baseline_cli.py index 395f16fea..085358d90 100644 --- a/tests/test_architecture_baseline_cli.py +++ b/tests/test_architecture_baseline_cli.py @@ -53,7 +53,7 @@ def _performance_sample( def _transaction_sample(methods: list[dict[str, str]]) -> dict: """构造最小事务债务 fixture,供单向 ratchet 行为测试。""" return { - "schema_version": 1, + "schema_version": 2, "scope": "app/db/models and app/db/oper transaction ownership debt", "model_decorators": { "count": len(methods), @@ -75,8 +75,12 @@ def _transaction_sample(methods: list[dict[str, str]]) -> dict: def _configuration_sample( settings_files: list[str], oper_calls: list[dict[str, str]], + foundational_entries: list[dict[str, str]] | None = None, + composition_entries: list[dict[str, str]] | None = None, ) -> dict: """构造最小配置债务 fixture,供单向 ratchet 行为测试。""" + foundational_entries = foundational_entries or [] + composition_entries = composition_entries or [] return { "schema_version": 1, "scope": { @@ -91,6 +95,14 @@ def _configuration_sample( "count": len(oper_calls), "calls": oper_calls, }, + "foundational_settings_boundaries": { + "count": len(foundational_entries), + "entries": foundational_entries, + }, + "composition_root_oper_boundaries": { + "count": len(composition_entries), + "entries": composition_entries, + }, } @@ -414,6 +426,22 @@ def test_configuration_ratchet_allows_removal_but_rejects_new_access() -> None: ) +def test_configuration_ratchet_rejects_new_approved_boundaries() -> None: + """批准边界同样只能减少,不能借新增理由把新债务改名为豁免。""" + existing = {"file": "app/db/base.py", "reason": "model declaration"} + added = {"file": "app/db/new.py", "reason": "new exemption"} + expected = _configuration_sample([], [], [existing]) + + assert architecture_baseline.configuration_ratchet_matches( + expected, + _configuration_sample([], [], []), + ) + assert not architecture_baseline.configuration_ratchet_matches( + expected, + _configuration_sample([], [], [existing, added]), + ) + + def test_architecture_write_host_only_updates_host_files( tmp_path: Path, monkeypatch, diff --git a/tests/test_architecture_contract_baseline.py b/tests/test_architecture_contract_baseline.py index 1e3902c93..38c3ec912 100644 --- a/tests/test_architecture_contract_baseline.py +++ b/tests/test_architecture_contract_baseline.py @@ -173,11 +173,11 @@ def test_host_oper_does_not_call_base_implicit_write_wrappers() -> None: def test_configuration_debt_baseline_tracks_canonical_direct_access() -> None: - """配置债务基线必须排除插件兼容面,并冻结两个可下降的直接访问集合。""" + """配置基线必须把零债务与固定基础设施边界分开冻结。""" baseline_path = BASELINE_ROOT / "configuration-debt-baseline.json" baseline = json.loads(baseline_path.read_text(encoding="utf-8")) - assert baseline["schema_version"] == 1 + assert baseline["schema_version"] == 2 assert baseline["scope"]["excluded"] == [ "app/plugins", "app/sdk", @@ -190,6 +190,20 @@ def test_configuration_debt_baseline_tracks_canonical_direct_access() -> None: assert baseline["system_config_oper_constructions"]["count"] == len( baseline["system_config_oper_constructions"]["calls"] ) + assert baseline["settings_imports"] == {"count": 0, "files": []} + assert baseline["system_config_oper_constructions"] == { + "count": 0, + "calls": [], + } + assert { + entry["file"] + for entry in baseline["foundational_settings_boundaries"]["entries"] + } == {"app/db/base.py", "app/db/engine.py", "app/db/session.py"} + assert baseline["foundational_settings_boundaries"]["count"] == 3 + assert baseline["composition_root_oper_boundaries"]["count"] == 1 + assert baseline["composition_root_oper_boundaries"]["entries"][0]["file"] == ( + "app/startup/initializers/modules.py" + ) def test_startup_performance_baseline_records_normal_and_safe_lifecycle_resources():