fix(layering): storage 层相对导入 server(fenced.py)逃过分层守护——AST 门禁对相对导入整体失明
Nobody has claimed this yet.
Assessment
- Difficulty
- 3/5
- Estimated time
- Half a day
- Newbie friendliness
- 45/100
Research direction
Start with visit_ImportFrom in tests/integration/test_layering.py (around lines 101-107), which skips every import with node.level > 0, and with _collect_top_level_imports (around lines 132-160), which has the same gap. The known violation is plaita/storage/fenced.py lines 37-40, which imports ExecutionLeaseError relatively from ..server.execution_lease. Done means the layering gate reports that import as a storage-to-server violation, and the existing 12 tests still pass, checked with pytest tests/integration/test_layering.py. Confirm the fix with the maintainer first, because the issue also asks for a decision on whether to relocate the exception or add a documented exemption.
Written by the indexing model from the issue text.
Description
优先级 P2 · 依赖:无
基线 HEAD = 65f48f2
Summary
分层守护测试 tests/integration/test_layering.py 采集导入时只认绝对导入(node.level == 0),
对同一文件的相对导入完全不采;而 plaita/storage/fenced.py 恰好用相对导入反向依赖了 plaita.server。
该反向依赖今天没有任何运行期后果,但门禁对它失明——同一处依赖一旦被写成绝对导入就会红,写成相对导入则恒绿。
1. 现存违规:storage 层运行时反向导入 server
plaita/storage/fenced.py:37-40(逐字):
try: # server/__init__ 仅含轻量辅助,无循环导入风险
from ..server.execution_lease import ExecutionLeaseError
except ImportError: # pragma: no cover — 极端打包环境退化为 RuntimeError
ExecutionLeaseError = RuntimeError # type: ignore[misc,assignment]
运行时可达性(实测,import plaita.storage.fenced 会把 plaita.server 整个拉进 sys.modules):
$ /tmp/plaita-audit-venv/bin/python -c "import plaita.storage.fenced as f, sys; print('plaita.server' in sys.modules, 'plaita.server.execution_lease' in sys.modules, f.ExecutionLeaseError)"
True True <class 'plaita.server.execution_lease.ExecutionLeaseError'>
该依赖被真实使用:plaita/storage/fenced.py:149 抛出这个异常类。
$ grep -n "raise ExecutionLeaseError" plaita/storage/fenced.py
149: raise ExecutionLeaseError(
2. 门禁对相对导入整体失明
tests/integration/test_layering.py:101-107(逐字)——注释里承诺的
"per-layer prefix mapping elsewhere" 在全文并不存在(grep -n "level" tests/integration/test_layering.py
只命中 :105 与 :156 两处 node.level == 0 判断,无任何把 level > 0 解析成绝对名的逻辑):
def visit_ImportFrom(self, node: ast.ImportFrom):
# Only absolute imports resolve to a real module dependency;
# relative imports stay inside the package and are handled by
# the per-layer prefix mapping elsewhere.
if not self.in_type_checking and node.level == 0 and node.module:
imports.append(node.module)
self.generic_visit(node)
tests/integration/test_layering.py:46-51(逐字)——_FORBIDDEN 与 _violations_for_layer(:253-265)
只做绝对模块名前缀比对,因此 ..server.execution_lease 这种字符串从未进入比对:
_FORBIDDEN = {
"foundation": ("plaita.storage", "plaita.server"),
"event": ("plaita.storage", "plaita.server"),
"storage": ("plaita.server",),
"server": (),
}
对应的用例本身是存在的(plaita/server 在 storage 的禁列表里),只是永远采不到这条违规。
tests/integration/test_layering.py:301-306(逐字):
def test_storage_no_upward_imports(self):
violations = self._violations_for_layer("storage")
assert violations == [], (
"plaita.storage must not import server:\n"
+ "\n".join(f" - {v}" for v in violations)
)
门禁假绿(实测):
$ /tmp/plaita-audit-venv/bin/python -m pytest tests/integration/test_layering.py -q
............ [100%]
12 passed in 1.24s
同一处依赖只要改写成绝对导入,就会被现行门禁抓到;保持相对导入则永远抓不到。
对 fenced.py 原文做「仅把该行等价改写为绝对导入、其余字节不动」的对照实验,跑门禁同款采集器:
$ /tmp/plaita-audit-venv/bin/python /tmp/rel_scan_9b.py
relative (real) -> ['__future__', 'contextvars', 'typing']
absolute (equivalent) -> ['__future__', 'contextvars', 'typing', 'plaita.server.execution_lease']
3. 全仓独立扫描:该盲区目前掩盖的违规有且仅有一条
按 node.level 解析相对导入的绝对名后再比对 _FORBIDDEN(脚本 /tmp/rel_scan_9.py):
$ /tmp/plaita-audit-venv/bin/python /tmp/rel_scan_9.py
VIOLATIONS: 1
storage/fenced.py:38 layer=storage -> plaita.server.execution_lease (rel level=2)
4. 其他守卫均不覆盖该层
tests/e2e/test_import_layering.py只扫plaita/core(:22CORE_DIR = ... / "plaita" / "core",
:45test_no_core_to_server_imports),不覆盖 storage。scripts/ci-gate.sh:62-77的 layering 相位先跑上面这个 e2e,失败才跑内联脚本,两者都只针对 core。pyproject.toml:108-114的[tool.ruff]只有:113一行select = ["E722", "BLE001"],无 import 禁令;
仓内无.importlinter、无.pre-commit-config.yaml(实测ls两者均 No such file)。- 结论:AST 集成测试是 storage 层的唯一守卫,而它对该相对导入失明。
5. 契约出处
docs-site/docs/architecture/layering.md:28(逐字):
| `plaita.storage` | foundation + `event`;**禁止** `server` |
docs-site/docs/architecture/layering.md:31(逐字):
**禁止**:foundation / event / storage 反向依赖更上层。这是被 AST 扫描测试守护的硬约束。
AGENTS.md:18(逐字):
依赖方向:`foundation/core` ← `event/storage/node` ← `server`(禁止反向)。
6. 异常回退分支今天是死代码
plaita/server/__init__.py 全文只有 _require_server() 一个纯标准库函数(无任何第三方顶层
import),plaita/server/execution_lease.py:1-17 也只 import 标准库(__future__ / logging /
uuid / typing)。因此 except ImportError 回退当前不可达,今天没有用户可见故障;
flow_worker.py 依赖同一类对象(plaita/server/execution_lease.py:61 逐字
class ExecutionLeaseError(RuntimeError):;plaita/server/flow_worker.py:1677 逐字
except ExecutionLeaseError:;:2747 逐字 except ExecutionLeaseError as exc:),
一旦回退生效,storage 抛出的 RuntimeError 不会被这两处命中,会落到
plaita/server/flow_worker.py:2820 的 except Exception as exc: 兜底重投——
这是未来风险,不是当前故障。本条的实质是守护失效 + 现存违规,而非运行期损失。
影响
- 受影响的是分层契约与 CI 门禁本身,不是某个部署形态的运行期行为:
plaita.storage承诺不依赖plaita.server(layering.md:28),今天却已经依赖了。 - 场景一(门禁静默放行):任何作者或 agent 在
plaita/storage/**、plaita/event/**、plaita/core/**
下新增from ..server.xxx import yyy这类相对反向导入,CI 与本地pytest tests/integration/test_layering.py
一律 12 passed,没有一条测试会失败。storage 层从此可以悄悄依赖 server 的任意模块,
分层约束退化为「只对愿意写绝对导入的人生效」。 - 场景二(契约破裂的真实代价):
plaita.storage被独立使用("库嵌入"形态——只要 storage 与
redis 后端、不要 server/FastAPI 栈的宿主)时,import plaita.storage.fenced会连带导入plaita.server。
今天这一步恰好只多做一次轻量 import,但它是无告警的层级泄漏:server 层任何时候引入一个
重量级或会失败的顶层依赖(FastAPI/uvicorn/SQLAlchemy 之类),这条泄漏就会把
「只想要 storage」的用户一起拖下水,而门禁依然全绿。 - 场景三(异常身份跨层):
ExecutionLeaseError的定义在 server 层、使用点(fenced.py:149)在 storage 层,
而消费点(flow_worker.py:1677/:2747)依赖的是同一类对象。回退分支一旦可达,
storage 抛RuntimeError而 worker 只 catchExecutionLeaseError,失租语义会退化成
:2820的兜底重投——同一个执行被反复重投而非按租约语义退出。当前该分支不可达,属潜伏风险。 - 不修的后果:分层守护继续对相对导入整体失明,且已经存在一处违规被它掩盖;
后续任何 storage→server 的反向依赖都会静默通过 CI,直到某天以「用户装不起 / 导入即失败」的形式爆发。
建议
- 补齐门禁(主要修法,
tests/integration/test_layering.py):让visit_ImportFrom能解析相对导入。
最小改法是在采集时把node.level与当前文件相对plaita/的包路径拼成绝对模块名后再做前缀比对,
例如给_collect_runtime_imports传入该文件的模块名(_layer_for已经算过相对路径),
在node.level > 0时按'.' * node.level的层级上溯得到plaita.<pkg>.<module>;
_collect_top_level_imports(:132-160,同样只判level == 0)需同步修,否则 foundation 的
「顶层不得 import event」约束同样可被相对导入绕过。顺带删掉 :102-104 那句已经失真的注释。
修完后_violations_for_layer("storage")必须能报出storage/fenced.py的这条违规。 - 处置现存违规(
plaita/storage/fenced.py:37-40),二选一,都可通过 ci-gate:- 消除反向依赖:把
ExecutionLeaseError的定义(或一个异常基类/协议)下沉到 storage 可依赖的层
(plaita/core/errors.py或 storage 自身),server 侧execution_lease.py改为复用该定义。 - 保留现状但显式豁免:若确认这条依赖是设计意图,就把它改成
TYPE_CHECKING+ 运行期惰性取用,
或在门禁里加一条带理由与 issue 链接的白名单条目——而不是靠门禁看不见它。
- 消除反向依赖:把
- 验收条件:
- 新增/修改
tests/integration/test_layering.py的用例,断言门禁能解析相对导入:
构造一个「临时把fenced.py:38的相对导入等价改写为绝对导入」的对照(或直接对现在这行)
并断言_violations_for_layer("storage")非空 / 或在违规被修掉后断言其为空。 - 决定性验收:现在跑
/tmp/plaita-audit-venv/bin/python -m pytest tests/integration/test_layering.py -q是
12 passed;修复后,在同样输入下该文件应至少新增一条能捕获相对反向导入的用例,
且python /tmp/rel_scan_9.py的输出由VIOLATIONS: 1变为VIOLATIONS: 0
(若采纳方案 2 的第一种修法)。 - 不得通过放宽
_FORBIDDEN["storage"]或删掉test_storage_no_upward_imports来「通过」。
- 新增/修改
- 回归面:改完后跑
bash scripts/ci-gate.sh,确认 layering 相位与既有 12 条用例全绿。
边界
- 不扩大扫描范围:本条只修「相对导入让门禁失明」这一个缺口,不顺手给 storage/event 增加新的
分层规则,也不重写_FORBIDDEN的表结构。 - 不重构 server/storage 的异常体系:
ExecutionLeaseError是否应当整体下沉、fenced.py的
fencing 语义是否合理,均不在本条范围;本条只要求「门禁能看见这条依赖 + 该依赖被显式处置」。 - 不修
tests/e2e/test_import_layering.py的覆盖范围:它只扫plaita/core是既有的有意设计
(scripts/ci-gate.sh也按此组织),本条不把它扩到 storage;storage 层由集成测试负责。 - 当前故障不可达:
except ImportError回退分支今天走不到(plaita/server/__init__.py无第三方
顶层 import,execution_lease.py仅依赖标准库),所以本条不承诺「修复后用户可见故障消失」——
可观测的变化是门禁由假绿变为能报出这条违规。 - 与既有单的关系:查重
gh issue list -R jeffkit/plaita --state all --limit 100返回的 33 条
(#11–#52)中无同根条目;#40(storage 后端缺陷)、#38(僵尸 reaper 的 fence 问题)虽同在
storage/server 交界,但分别讲 SQLAlchemy/Redis 后端行为与 reaper 判据,与本条「分层门禁失明」
不是同一根因。
- Dominant language
- Python
- Stars
- 0
- Forks
- 1
- Avg merge
- 3h 53m
- Merged PRs (30d)
- 3
Getting set up
This project ships no dev container, Dockerfile or contributing guide, so setting up is up to you: start from its README, and see our first-contribution guide for the general steps.
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from jeffkit/plaita
-
Difficulty 4/5 3-5 days Newbie friendliness 42/100
-
Difficulty 4/5 3-5 days Newbie friendliness 48/100
-
Difficulty 5/5 Over a week Newbie friendliness 24/100
-
Difficulty 4/5 3-5 days Newbie friendliness 8/100
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 77/100
TomerAberbach/profiler-md#733 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
its-me-prash/vwgroup-connect-ha#1858 ·
Maintainers usually reply within 1 day
-
area: client bug
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
Maintainers usually reply within 1 day
-
Deleting all alarms or timers shows deleted ones until the next update when there are none leftOpenbug
Difficulty 2/5 1-3 hours Newbie friendliness 85/100
leikoilja/ha-google-home#1107 ·
Maintainers usually reply within 1 day
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
Graphify-Labs/graphify#4524 · 1 comment ·
Maintainers usually reply within 1 day