mcpField
I
isolating-product-facade-contracts

isolating-product-facade-contracts

github.com/posthog开发40.5k184 次安装今天更新
代码仓库

关于 isolating-product-facade-contracts

将现有产品迁移到 facade 与 contracts 架构的仓库技能,指导工程师评估迁移阶段、设计边界、迁移调用方并验证结果。

主要功能

  1. 01评估迁移阶段
  2. 02Facade 与 contracts 指南
  3. 03耦合扫描与调用方迁移
  4. 04隔离检查与验证

适用场景

  1. 01隔离已位于 products/ 下的产品
  2. 02继续进行中的隔离迁移

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/posthog/posthog --skill isolating-product-facade-contracts

安装后在 Claude Code 中输入/isolating-product-facade-contracts确认已加载。

详细介绍

通过 facade 和契约隔离产品

在选定要隔离的产品之前,请先查看 已尝试过的事项。其中记录了哪个产品是第一个候选对象,以及为什么现场测试转移到了另一个产品。

使用本技能将现有产品迁移到 Visual review 所使用的隔离架构。 优化目标应是缩短日历时间上的暴露,而不是缩小 diff:编写代码的成本很低,且验证 链能捕获机械性破坏,而真正瓶颈是人类审查的延迟和快速变化的 master。默认采用 一个 PR,并组织为可审查的提交;单独的 facade 优先 PR 仅作为团队分片清理的合并 基线存在 —— 具体参见下文中的 PR 策略。

前提条件: 产品必须已经位于 products/<name>/ 下。本技能不涵盖将代码移出 posthog/、ee/ 或其他共享目录 —— 请先完成那一步。

首先需要加载的核心文档

在修改代码之前,请阅读以下内容:

  1. products/architecture.md
  2. products/README.md
  3. docs/internal/monorepo-layout.md
  4. posthog/models/team/README.md(team 扩展模型规则)
  5. docs/published/handbook/engineering/type-system.md(serializer/OpenAPI 类型流转)
  6. docs/published/handbook/engineering/ai/implementing-mcp-tools.md(schema 质量与 team 隔离预期)
  7. .agents/security.md(SQL/HogQL 安全指南)

将 Visual review 作为具体的参考实现:

在修改代码之前,先获取基线:

hogli product:maturity <name>       # scores models, facade, presentation, boundaries, codegen
hogli product:lint <name>           # structural lint + isolation chain, prints the rung: Lenient → Strict → Sealed → Isolated
hogli product:isolate:scan <name>   # the recon: import map, coupling gate, preflight (see below)

首先,阅读基线以了解你处于哪个迁移阶段 —— 你不会一开始就知道这是尚未触及还是 处于中途,而这决定了你从哪里接着做。 所有信号都在上面的输出中;没有状态文件可供参考(手工维护的状态文件会偏离 —— 成熟度分数加上 ignore_imports TODO 集合 就是 状态,而 lint-imports 会在模块 完成后修剪后者):

  • 全新 —— facade/presentation 得分为零,没有 backend/facade/,该产品也没有 ignore_imports 条目。从步骤 1 开始。
  • 清理中途 —— facade/contracts.py + facade/api.py 已存在,但核心仍在导入 内部实现(扫描仍会列出 model-access/etc.,或 product:lint 警告 has legacy interface leaks)。facade 设计已经确定;继续迁移调用方(步骤 4)—— 不要重新设计它。
  • presentation 波次中途 —— 链基本完整,但 pyproject.toml 中仍有该产品的 ignore_imports TODO 条目。清理已完成;在正是那些被推迟的模块上恢复 presentation 波次。
  • 基本完成 —— 链完整,不再有 ignore_imports 条目。请根据完成标准进行验证, 而不要做任何改动。

该扫描完成了否则需要十几次临时 grep 才能完成的侦察工作,并且是后续整个流程的 唯一事实来源:

  • 按类别列出引用 — 对产品内部实现的每一处跨边界引用, 按类别分类(model-access、query-runner、celery-task、temporal-wiring、test-fixture、 string-reference),并对应到每一类映射的门面模式。这是排查清单;字符串引用(@patch(...) mock 路径、配置中的点分名称)也包含在内,而基于 import 的 grep 会漏掉这些。
  • 核心耦合计数 — 决定下面采用哪种 PR 策略:接近零意味着整个迁移适用单 PR 默认方案;三位数意味着调用方排查需要按所属团队切分。
  • 严格 lint 预检 — product:lint 在 facade/contracts.py 存在的瞬间会从宽松切换到严格;扫描以强制严格模式运行结构检查,从而让那些要求(根目录 tsconfig.json、tasks.py 位于 tasks/tasks.py、必需的根文件就位)在前期就暴露出来,而不是等到迁移中途。严格 lint 只检查必需的根文件。它不限制内部目录名称:被强制的形态是 import 链 — routes.py 只 import presentation/,而 presentation/ 只 import facade/ — 因此产品可以添加其他内部包(services/、reviewer/、……),只要它们待在门面之后。
  • 每个视图模块的薄/厚信号 — 未来 ignore_imports 允许列表的大小。
  • 盲区 — 扫描只读取 backend.* 的 import。耦合计数为零是必要条件,而非充分条件:它看不到产品根包中核心所 import 的内容(dags/),也看不到非 import 渠道(配置点分字符串、hogql 系统表、进程内 API 测试分发)。在宣布产品完成之前,请参阅下面的“清除扫描看不到的耦合”。

--json 会输出机器可读的配方 — 将其与 PR 放在一起;针对最新 master 重新生成迁移会从一次全新的扫描开始。

当工具不合适时,修工具

不要悄悄手动绕过它。如果 isolate:scan/:move 漏掉了你产品的形态 — 它检测不到的布局、它看不到的耦合渠道 — 而其他产品也有同样的形态,那就扩展工具并更新本 skill,让下一次迁移继承这个修复。backend/api/ 子包支持和 Dagster-assets 渠道就是这样加进去的。把手动绕过(在 PR 中说明)留给真正的一次性情况;悄悄手动 hack 只会让下一个产品撞上同一堵墙。

“没有进程内调用方,就不需要门面”是错误的判断标准

核心耦合计数衡量的是清扫范围——零导入方意味着一次单 PR 迁移,无需迁移调用方——但它并不能决定是否要隔离。门面同时也是结构性密封,使 CI 跳过变得 可靠,而这与导入方数量毫无关系:

  • 产品的 HTTP API 会被测试以进程内方式调用(Django 测试客户端在同一进程内分发到视图栈,而不是通过真实套接字)。横切测试——权限、schema、活动日志、“每个 viewset 都做 X”——通过 URL 触及产品的端点,以零导入的方式耦合到其运行时行为。tach、lint-imports 和本次扫描都只读取导入图,因此它们都无法看到这一点。“没有导入方”是必要条件,而非充分条件。
  • 由于该通道无法被枚举,它应通过构造而非审计来封闭:保持表现层轻薄,只能通过门面触及内部,从而让每个可观察行为要么位于门面中(在产品内、边界内被测试),要么位于序列化器形态中(OpenAPI schema,其变更已经会强制运行完整测试套件);并将行为测试保留在产品内。

因此,如果一个产品的唯一消费方都通过 HTTP(node 服务、TS/MCP 代码生成),那么它并非门面可选——在那里,门面的全部职责就是密封它自己的表现层(步骤 2 的第二个需求来源)。真正的例外是几乎不含 Django 侧逻辑的产品(对外部服务的薄封装):它没有什么需要密封,但这时它只是简单地 未被隔离——没有 backend:contract-check,仍然要付出完整测试套件的代价。这是接受成本的选择,而不是“无门面的隔离”。

这是第三种非导入耦合通道,与 hogql 系统表和点分字符串配置并列(步骤 6.4)——而且是最不显眼的一种,因为另外两种至少还留下可供 grep 的字符串。

清理扫描无法显示的耦合

product:isolate:scan 遍历 backend.* 的导入图。有六种耦合能逃过它——但没有一种是死胡同,每一种都有明确的应对动作。后端清扫之后,运行 git grep "products.<name>"(不只是 .backend),并阅读扫描的字符串引用部分来找到它们。

反向访问器。 一个跨产品边界、且没有 related_name="+" 的关系字段(FK、O2O、M2M)会向目标类添加一个反向访问器和一个反向查询名。它不产生导入,因此扫描无法看到,但任何调用方都可以遍历它。应对动作:用 related_name="+" 密封该关系,移除任何显式的 related_query_name(它会保持 filter() 遍历可用,并显示为一行 query:<name>),并在同一次变更中从 products/model_crossing_uses_baseline.txt 删除它的 reverse-accessor(...) 行 (bin/hogli product:crossings --all --write-baseline);为需要反向访问的调用方提供一个门面读取函数。crossing ratchet 会阻止新的未密封关系。见 products/architecture.md § Cross-product foreign keys。

信号耦合。 接收方的发送方属于另一个边界时,会在本边界的保存路径中运行那个边界的代码,而当发送方是字符串时就没有导入边。按情况有三种应对动作:

  • 核心监听产品模型(字符串发送方)——反转方向:让产品在自己的保存路径中调用核心的公共函数 (remote_config.mark_dirty(team_id)),这是一次正常的产品→核心导入。

  • 产品响应核心保存——通过核心拥有的 hook 注册(register_team_extension_signal 那种形态),而不是在外来发送方上使用原始的 @receiver。hook 注册表就是清单。

  • 任一方向——绝不要在接收方主体中做慢操作或外部操作。改用 transaction.on_commit 来派发任务。 测试基础设施耦合。 测试位于 tach 的接口图中,但不在其依赖图中(hogli lint:tach 运行 CI 运行的两轮检查)。测试可以导入任何产品的公开表面,而无需 depends_on 条目,而导入了另一个产品内部实现的测试会像生产代码一样导致 CI 失败。这样的测试会采用下面的一种迁移方式。当产品尚无法提供该迁移方式时,一个 legacy-leak [[interfaces]] 块会指明这笔债务和退出路径,将该产品降级,直到该块被清空;该块绝不是以受认可的名义把内部实现交出去的 facade 模块。核心测试会深入产品的测试辅助代码,而没有任何 facade 重导出会自然而然地覆盖这些内容:

  • Monkeypatch 目标 — 核心测试基类会 patch 产品模块的属性(例如 posthog/test/base.py 会在每个 runner 模块上 patch execute_hogql_query)。通过 facade 重导出模块对象(facade/queries.py 重导出 web_overview,而不仅仅是它的类),并将 patch 指向 facade 路径。

  • 共享 fixtures / 基类 — 核心测试继承产品的测试基类。确定归属:如果该 fixture 是核心关注点的基础设施(例如预聚合表测试基类,且这些表位于核心中),就把它下移到核心中,让双方都向下导入;如果该核心测试实际上是在测试产品的行为,就把该测试迁移到产品中。无论哪种方式,跨边界的测试导入都会消失。

  • 需要产品数据行的 fixtures — 核心测试会预置一个产品模型(InsightViewed、Account)来搭建其场景。通过 facade 写入函数预置,并使用正常导入。如果尚不存在,就添加一个;这与生产调用者所做的迁移方式相同。仅供 fixtures 使用的辅助函数放在 facade/testing.py 子模块中:tach 接口已经暴露了 backend.facade.*,而任何其他被暴露的模块都算作 legacy leak,并会阻塞隔离测试。绝不要在模块作用域使用 apps.get_model("<label>", "<Model>"):这是同一个依赖,只是移除了导入边。 tach、mypy 和 LSP 会看不到它,而 snob(PR 测试选择器)在该模型变更时不会选中该测试,因此故障会落到 master 上。紧挨着它的 TYPE_CHECKING 导入并不能恢复这条边;tach 会忽略仅类型导入,snob 也是如此。

backend/ 之外的表面。 产品可以在其根目录下暴露非 Django 表面 — 例如 products/<name>/dags/ 下的 Dagster assets — 而核心会直接导入它们。扫描只遍历 backend.*,因此不会列出它们,而 tach / product:lint 会在后期失败并报 "not part of the public interface"(直接暴露接口也会触发 legacy-leak 检查)。通过 facade 子模块重导出它们(facade/dags.py 重导出这些 asset 模块),并重新路由核心导入方,就像 temporal 和 query-runner 的接线一样。

没有外部数据消费者。 上文已涵盖 — facade 为产品自己的视图服务。为其模型提供 facade 读取函数;接入成本低的视图;将成本高的视图(嵌套序列化器或事务性 viewset)作为命名 ignore_imports 推迟到展示阶段。提供 facade 函数同时推迟其调用方是一种合法的中间状态,而不是半迁移。

产品拥有的 HogQL 系统表。 当核心挂载产品的联邦系统表(schema/system.py、lazy_join_registry.py)时,回答两个相互独立的问题:

  • 引用可以是普通的 facade 导入吗? 可以——表定义和懒连接 函数都是普通的模块级对象;把它们移入产品 (例如 facade/hogql.py),然后像其他任何接线一样重新路由 core 的导入。
  • 这些对象会进入 静态 pickle 目录吗? Core 会构建一次 目录,并在每次请求时通过一个受限的 unpickler 重新加载它 (posthog/hogql/database/database.py 中的 build_database_root_node)。 目录树中任何由产品定义的 类(PostgresTable/LazyTable 的子类)都需要将其模块加入 _CATALOG_PICKLE_MODULES——逐项加入允许列表,而不是按前缀。 缺少条目会导致 core 目录测试失败,并给出指明该模块的消息。Warehouse 风格的按团队表在 请求时构建,永远不会进入静态目录,这就是为什么大多数 产品从未遇到这个问题。

web_analytics 迁移是这三者的完整示例:其 preagg 测试 基座下移到 core,其时区集成测试移入产品, 其 Dagster asset 增加了一个 facade/dags.py,其 filter-preset 读取落到了 facade/api.py,而 viewset 则被延后。

消费者已持有的模型类

一些产品仍在受监视模型允许范围 (MODEL_CROSSINGS)下对外提供模型类。该允许范围只说明该类可以离开产品; 它并不说明消费者会如何使用它。有两条规则涵盖这一点:

  • 默认拒绝。 跨界的类在消费者代码中只能以检查 认定为不涉及实例的形态出现:注解、X.DoesNotExist、嵌套类 属性(X.Status)、X._meta、以 values/values_list/count/exists/aggregate 结尾的 manager 链,或嵌入 Exists(...)/Subquery(...) 的链。其他任何情况都不允许。
  • 移动,而非允许。 查询、序列化或写入某个模型的代码应 归属于该模型所在的产品。对不允许的用法,补救措施是移动;facade 函数是移动后留下的东西,而消费者保留编排 和 id。

apps.get_model('label', 'Class') 也会被计入,而且是针对 每一个 产品模型, 不只是允许范围内的模型。它不留下导入边,因此 tach 无法拒绝它。 测试模块不在扫描范围内,这是一个盲点,而非许可:以这种方式触及某个模型的 core 测试 fixture 不会被计入,也不会被选中(参见 上文 "Test-infrastructure coupling")。迁移同样不在范围内:历史 注册表是迁移触及某个模型的唯一途径。生产代码不得 添加此类调用。

hogli product:crossings <product> 列出某个产品的跨界类,并将每一处 消费者用法按类型分桶,不允许的排在最前。不允许的用法被冻结在 products/model_crossing_uses_baseline.txt 中,并由一个仓库不变量测试守护; 计数只能下降,而 hogli product:crossings --all --write-baseline 记录这一减少。参见 products/architecture.md § Wiring couplings。

防护规则

  • 保持外观层轻薄;将业务规则放在外观层之后,默认位于 logic/。其他内部包(services/、reviewer/ 等)只要仍位于外观层之后即可。
  • 事务边界属于外观层(或逻辑层),不属于视图。
  • 为契约提供服务的视图通过 ObjectAccessRef 检查对象 RBAC:从 safely_get_object() 返回一个,或将一个传给 self.check_object_permissions(request, ref)。切勿手动检查或在在外观层检查。参见 products/architecture.md § 谁拥有 RBAC?
  • 切勿跨产品边界返回 ORM 模型。
  • hogli product:lint 读取外观层签名,类型包括 facade-returns、 facade-accepts(<parameter>) 和 facade-logic。products/model_crossing_uses_baseline.txt 中的匹配行记录外观层当前的行为,且只会收缩,因此新的发现会导致 lint 失败。使用 bin/hogli product:crossings --all --write-baseline 重新生成, 规则见 products/architecture.md § 外观层:公共接口。
  • 用 related_name="+" 声明每一个跨产品边界的关系字段 —— 反向访问器棘轮机制会阻止新的未封装字段。
  • 不要在另一个边界的发送者上注册信号接收器;请使用 上文"信号耦合"中的做法。
  • 保持契约文件纯净(facade/contracts.py 或 facade/enums.py 中没有 Django/DRF 导入)。此处的 choices 枚举是来自 posthog/enums.py 的 LabeledStrEnum / LabeledIntEnum。
  • 在 queryset 中按 team_id 过滤。
  • 不要向 Team 添加产品特定字段;请使用 Team Extension 模型。
  • 在 viewset 端点上添加请求/响应 schema 注解(@validated_request 或 @extend_schema)。
  • 当 serializer/view 变更影响 API schema 时,重新生成 OpenAPI/types(hogli build:openapi)。
  • 表现层只能通过外观层访问内部 —— 由 pyproject.toml 中的 presentation must use facade import-linter 契约强制执行 (tool.importlinter)。任何新的内部模块(cache.py、helpers.py 等)都会 自动被覆盖;无需维护阻止列表。新的横切导入 必须通过外观层,或在那里被临时加入允许列表。

必需的迁移工作流

  1. 为目标产品构建导入映射。
    • 找出跨产品导入到目标内部模块(models、logic、presentation、非 facade 模块)的引用。
    • 按能力对每一处用法分类(读/列表、详情/读取、创建/更新/删除、异步/任务、webhook/事件)。
  2. 从两个需求来源定义契约面。
    • 外部消费者(core、其他产品):只从当前已消费的字段出发。
    • 产品自身的 presentation(第 5 步):其视图将调用的 facade 函数。对于 厚视图,这部分可能被推迟——但要说明推迟——它决定了你开启的 隔离是可靠的还是乐观的(见第 5 步)。
    • 在 backend/facade/contracts.py 中使用 pydantic.dataclasses.dataclass 创建冻结 dataclass——与 stdlib 版本形状相同,但在构造时进行运行时类型校验。
  3. 在 backend/facade/api.py 中引入一层薄 facade。
    • 用显式 mapper 函数把 ORM 实例映射到契约。
    • 保持方法名面向能力且稳定。
    • core 注册的接线(celery beat 任务、temporal workflows/activities/metrics/ schedule 常量、query-runner registry hooks、Dagster assets)以对象而非数据的形式 跨越边界——为每一项提供独立的 facade 子模块(facade/tasks.py、 facade/temporal.py、facade/queries.py、facade/dags.py),精确重新导出 core 所接触的内容。注册表风格的消费者基于类身份(isinstance)分发,因此 要重新导出类本身;monkeypatch 消费者需要模块对象,因此也要重新导出模块对象。 保持 facade/api.py 不包含重量级导入(HogQL、temporalio),这样仅需配置的 消费者不会把它们拖到 django.setup() 路径上——同样出于这个原因,把轻量的共享 表(facade/hogql.py)从重量级 runner 重新导出中拆分出来。
  4. 默认一次性迁移调用方。
    • 重写是机械性的(替换导入 + 映射调用),且由 校验链完整检查;分批只会拉长 master 在分支之下漂移的时间窗口。
    • 需要产品行的 core 测试 fixture 走 facade 的写入 函数或产品测试入口,用普通导入——而不是 apps.get_model(原因见上文“测试基础设施耦合”)。
    • 兼容 shim 用于在连续的 PR 之间过渡——一次性完成的形态很少 需要它们。如果某个 shim 不可避免,它在最终清理 PR 中消亡,而不是“以后”。
    • 例外:行为微妙的调用方(事务边界、写入路径 语义)可以在自己的小 PR 中迁移——见下文 PR 策略。
  5. 让 presentation 改为消费 facade。
    • Serializer 在 JSON <-> 契约之间转换;视图只调用 facade 方法。
    • 按视图模块、基于规模估算决定其 facade 重构是落在 本 PR 还是推迟。估算每个模块的工作量:runner 执行替换很 便宜(runner 已经返回 posthog.schema 类型——无需新契约); 普通模型 CRUD 需要一个小契约加几个函数;嵌入在 serializer 中的事务性 或跨产品逻辑是最昂贵的一档。替换每一个工作量 便宜的模块;只推迟昂贵的那些,在 PR 描述中写明原因和 残留的 ignore_imports 条目。
    • 推迟的模块在 pyproject.toml 的 TODO 区保留精确配对的 ignore_imports 条目。这笔债为 skip 设闸:只要还有任何条目存在,产品 就绝不能启用 backend:contract-check(第 6 步),这样完整测试套件会继续运行, 不存在覆盖漏洞。这就是可靠性边界——skip 只在 facade/presentation 变更时 重新运行测试套件,因此只有当 presentation 变薄后才诚实。厚推迟视图会直接接触内部模块, 因此通过它流向 HTTP 的内部变更会溜过 skip(也会溜过那些以 URL 方式、零导入 测试该产品的横切 core 测试)。 hogli product:lint 强制执行这道闸——只要 ignore_imports 条目存在, contract-check 就会导致 lint 失败。skip 就是 presentation 波次 PR 清空这些条目后解锁的奖励。
    • 机械性部分是一条命令:hogli product:isolate:move <name>(先运行 --dry-run)把 ViewSet 模块移入 presentation/views/(自动检测, --views 可覆盖),把 tasks.py 移入 tasks/ 包并固定 celery 名称 (它重新导出该模块的公共接口,并会在通过包路径触达 private 任务名时 发出警告——例如 @patch("...backend.tasks._helper") 目标 ——因为这些需要手工重新指向 ...backend.tasks.tasks._helper;自动重写 它们会破坏固定的 name= 字符串),并在全仓库重写点分路径。 backend/api/ 子包整体移动: 生产辅助子包(例如 destination_tests/)在 presentation/views/ 下保持其结构,随前缀重命名一同迁移,而测试子包(test/、 tests/)迁至 backend/tests/api/——它们离开 api 命名空间,因此朴素的前缀重写无法跟随它们。 如果某个 api/ 模块已经在 presentation/<stem>.py 处有镜像,移动会拒绝—— 这意味着先前的手工迁移留下了兼容 shim,移动它会重复该模块;先删除 shim 并重新指向其调用方, 然后再对其余部分重新运行(error_tracking 是现成的示例)。
    • 重写是刻意的双工具拆分,参照模型迁移工具链 而非另起炉灶:绝对全限定路径和字符串引用 (@patch(...) mock 路径)走带保护的字边界正则(词法上 无歧义);相对导入(from ..x import y,任意深度)走 libcst (cst_helpers.py,从 product-model-migration 的 import_rewriter 移植), 因为解析它们需要导入模块所属的包,而行正则无法知道这一点。
  6. 强制边界并校验。这是一个四步链——每一步都依赖 前一步,如果跳过任何一步,hogli product:lint(通过 IsolationChainCheck) 都会失败:
    1. 真实 facade——backend/facade/api.py 必须有真正的函数定义, 而不仅仅是从 logic 重新导出。
    2. Tach 接口——首选路径是把产品名添加到 tach.toml 中现有共享 [[interfaces]] 块里的正则,该块 暴露 backend\.facade.* 和 backend\.presentation\.views.*。只有当 产品需要非标准的暴露模式时,才添加新的专用块。
    3. backend:contract-check 脚本——添加到 package.json,让 turbo-discover 将该产品视为已隔离。这是最后一块: 仅当产品完全隔离后才添加它——没有残留的 ignore_imports 条目, presentation 已变薄。只要还有推迟条目,hogli product:lint 就会阻止该脚本, 因为在那之前 skip 是不可靠的(见第 5 步)。
    4. 收窄的 turbo.json inputs——把 backend:contract-check inputs 限制为 backend/facade/** 和 backend/presentation/**,这样 Django 测试套件只在 facade/presentation 变更时重新运行(见 products/visual_review/turbo.json)。inputs 还必须监视 整个模型面——backend/models.py 或 backend/models/**, 加上 backend/migrations/**(即使在第一个迁移落地之前也需要): 模型可以在没有导入的情况下被触达 (apps.get_model 字符串、迁移、admin、hogql 系统表、 点分字符串配置),因此 tach/import-linter 无法证明外部没有观察到它。 hogli product:lint 会阻止遗漏其中任何一项的收窄, 而 product:bootstrap 生成的 inputs 已经覆盖了它。
    • 永久接口例外(不可消除的导入耦合)。 有些 导入耦合确实无法排空:ClickHouse DDL 模块 (backend.sql、backend.embedding……)被 core 的 posthog/clickhouse/schema.py 注册表、conftest.py 以及冻结的 ClickHouse 迁移导入,后者永远硬编码导入路径。你无法 重新路由冻结的迁移,也无法移动该模块。对此,在暴露这些模块的 tach [[interfaces]] 块正上方的行上标记 # isolation:permanent-interface 注释。该标记告诉 hogli product:lint 这个块是一个已声明的、 不可消除的暴露——不是遗留泄漏——因此它不再扣留 backend:contract-check。通过与 turbo.json 配对来保持可靠性: 每个永久暴露的模块必须出现在 contract-check inputs 中(例如 backend/sql.py),这样对它的变更仍会 重新运行完整测试套件。IsolationChainCheck 强制这种配对, 如果有标记的模块缺失于 inputs 就会失败。仅对既是 非 HTTP 行为相关又无法重新路由的耦合使用此标记 (冻结迁移 / schema 注册表 DDL)——不要把它当作 尚未迁移的模型/逻辑导入的逃生口。这一限制是 结构性的,而非风格性的:只有当每个 冻结固定的模块只包含 DDL 时该标记才可靠。error_tracking 是现成的 示例(sql / embedding / indexed_embedding 是纯 DDL 模块)。 cohorts 也符合——迁移 0010 固定了 products.cohorts.backend.models.sql,一个仅 DDL 的子模块,因此该标记 恰好适用于那个子模块(不是 backend.models.*)。 event_definitions 不符合:迁移 0120 固定了 products.event_definitions.backend.models.property_definition,一个 既定义 PropertyDefinition 模型类又定义其 DDL 常量的模块, 所以标记它为永久暴露会暴露模型访问——恰恰是 这一例外所禁止的逃生口。这种形态的产品需要先把 DDL 提取到专用模块中,并用原模块中的重新导出 shim 保留冻结的导入路径;该 shim 的残留 暴露(冻结迁移仍导入模型模块)必须在块注释中如实 记录,而不是用标记掩盖。
    • 用 hogli lint:tach、lint-imports (presentation → facade 的 import-linter 契约)和 hogli product:lint <name> 进行校验。
    • 使用 hogli product:maturity <name> 获取剩余 隔离工作的详细分解,按 models、facade、presentation、boundaries、codegen 评分。
    • 对变更文件运行聚焦测试,然后运行产品级后端测试。
迁移期间的遗留泄漏

这是高耦合产品的例外路径,这类产品的调用方清理工作被按团队切分(见下方 PR 策略)——tach 边界会在所有核心导入迁移完成之前就启用,而 backend:contract-check CI 开关必须等到该块被删除后才能启用(在仍存在泄漏时,hogli product:lint 会拒绝该脚本)。在默认的单 PR 模式下,所有核心调用方都在同一个 PR 中落地,因此这个块永远不需要存在。

如果 posthog/ 或 ee/ 在隔离链启用时仍然导入产品内部模块(backend.models、backend.oauth……),请在 tach.toml 的 "Legacy leaks" 部分下添加第二个 [[interfaces]] 块,仅将核心仍触及的模块加入允许列表。这样可以在其余按团队切分的 PR 落地期间保持构建通过。随着导入迁移到 facade 之后,逐步缩小并删除该块——最终的清理 PR 会将其完全移除。hogli product:lint 会对任何仍存在遗留泄漏接口的产品发出 ⚠ has legacy interface leaks 警告。

PR 策略

稀缺资源是评审者的注意力和日历时间——而不是编写工作量。 每一个未合并的迁移分支都会随着并行工作落地而腐化,而且每多一个 PR 都会增加一个 串行的评审窗口外加一次全量 CI 运行(产品尚未隔离,因此 每个迁移 PR 都要付出全量套件的代价)。使用合并拓扑所要求的最少 PR 数量, 让提交结构——而非 PR 边界——来组织评审。

默认:一个 PR,以可评审的提交来组织。

  1. 提交——facade。 契约 + facade(包括接线子模块)+ 行为对等测试。纯新增;评审者将这次提交作为设计来阅读。
  2. 提交——清扫。 所有调用方迁移。机械性改动;评审者抽样,由 验证链来兜底。
  3. 提交——结构 + 链。 展示层移动与重写 (hogli product:isolate:move)、严格 lint 修复、tach 接口。在这里添加 backend:contract-check + 收窄的 turbo.json 仅当该产品在本 PR 中 完全隔离——没有延迟的视图模块。如果有任何模块被延迟 (仍存在 ignore_imports 条目),跳过尚不能开启(product:lint 会 阻止它),因此它随展示层那一波 PR 一起落地;本 PR 不带跳过地发布, 继续付出全量套件代价,这是合理的。

不要为了可评审性而将 facade 拆分为独立 PR:新文件无论放在哪个 PR 中 都不会冲突,因此独立的设计 PR 买不到任何冲突保护——它只会串行化两个 评审窗口,并使迁移的 CI 成本翻倍。更糟的是,没有调用方的 facade 只能 在抽象层面被评审;在同一个 PR 中,调用方的 diff 就紧挨着设计,为其辩护 (或指控)。参见 user_interviews PR #59132 和 logs 迁移(#63180 + #63184,在学到这一点之前被做成两个) 的形态。

在 PR 描述中,枚举携带行为的改动——写入路径、事务 边界、序列化器变更、任何被重命名或具有固定兼容性的事物(celery 任务名)。这份清单就是评审注意力所去之处;其余一切都是抽样。如果 某个写入路径确实微妙,就将那几个调用方拉到它们自己的小 PR 中作为 例外。

把 PR 视为可重新生成的,而非可 rebase 的:导入映射加上 facade 设计完全 决定了重写,因此在冲突时,针对最新的 master 重新生成分支, 而不是手工解决。只在评审窗口期间保持其开启。

独立 facade 优先的 PR 存在的原因只有一个:当清扫必须被切片时,它是 共享的合并基线。 以基线中的核心耦合计数为门控:

  • 低(零到低两位数): 单个 PR,如上所述;完全跳过遗留泄漏 机制。
  • 高(三位数): 清扫无法作为一个单元来评审。先落地契约 + facade
    • 对等测试——在这里独立的评审是有意义的,因为每个切片 都将要针对它编码——然后按谁拥有调用代码(消费团队/领域,而非 产品自己的所有者)来切片清扫,并行评审,绝不 按能力串行。在切片落地期间添加遗留泄漏 tach 块(上文), 但此时不要添加 backend:contract-check——hogli product:lint 在仍有泄漏时 会拒绝该脚本。最终的清理 PR 移除该块、ignore_imports TODO、死适配器和垫片,只有到那时才启用 contract-check + 收窄的 turbo.json 输入——这条路径的 CI 回报在最后才落地。

延迟的视图模块(步骤 5)在任一路径上都会增加一个后续: 展示层那一波——参见下文了解其流程与产出。

需要接受的权衡:一个 PR 的回滚比多个 PR 更粗粒度。这没问题,因为 映射器是行为保持的,携带行为的改动是预先枚举的, 而且整个 PR 是可重新生成的——在一个热门产品上花费数周的串行 PR 在 冲突和丧失的隔离 CI 时间上的代价,超过偶尔一次粗粒度回滚。

呈现波次

同一 facade 上的第二波需求:产品_自身视图_所需的函数,而第一波服务的是外部消费者。每个产品运行一次,在迁移 PR 落地之后执行,从 PR 中列出的延迟模块开始(扫描的视图模块表就是工作清单)。

对每个延迟模块:

  1. 设计其视图所需的 facade 函数。查询端点获得 run-function,接收 posthog.schema 查询对象并返回 runner 的响应类型——这些已经与框架无关,因此通常不需要新的契约。基于模型的端点获得契约以及 CRUD/能力函数(visual_review 中的 DataclassSerializer 模式)。
  2. 划清界限:执行和事务相关关注点(ExecutionMode、查询标记、select_for_update 上限、跨产品副作用)移到 facade 之后;HTTP 相关关注点(请求校验、响应码、report_user_action 分析)留在视图中。
  3. 将视图精简为 parse → facade → serialize,并为新的 facade 函数编写对等测试,与第一波相同。
  4. 删除该模块的 ignore_imports 条目——lint-imports 必须在没有它们的情况下通过。删除操作就是标志着模块完成的产出。

这是一个设计 PR,不是清扫:它与 facade 提交具有相同的评审权重(契约和函数形态是长期存在的 API),因此它单独成一个 PR,而不是搭着机械性工作的便车。顺便重构第一波的某个函数(例如将 runner builder 归入某个 run-function)是可以的——其调用方很少,且对等测试锁定行为。下面的产品完成标准只有在本次波次清空 allowlist 之后才能完全满足——而清空它正是解锁跳过机制的关键:删除最后一个 ignore_imports 条目的 PR 就是最终添加 backend:contract-check 加窄化的 turbo.json 的地方(在那之前 product:lint 会阻止它们)。对于延迟了模块的产品,这就是它获得 CI 跳过机制的时机。

完成标准

仅当满足以下条件时,才将迁移视为完成:

  • 跨产品导入仅使用 backend/facade。
  • Facade 返回/接受契约,而非 ORM。
  • 呈现层不再编码业务逻辑。
  • 测试覆盖 facade 和呈现边界。
  • 该产品列在 tach.toml 中共享的 [[interfaces]] 块里,暴露 backend.facade.* 和 backend.presentation.views.*——不残留任何遗留泄漏块。
  • hogli lint:tach 对该产品通过,无违规。
  • lint-imports 通过,且 pyproject.toml 的 TODO 部分中没有为该产品留下任何 ignore_imports 条目——这些条目是来自延迟视图模块的已跟踪架构债务;呈现波次会删除它们。
  • hogli product:lint <name> 不显示遗留泄漏警告,且隔离链完好。
  • backend:contract-check 已出现在 package.json 中,且 turbo.json 的 inputs 窄化为 backend/facade/** 和 backend/presentation/**(启用 CI 跳过机制)。这是最后添加的东西——product:lint 会阻止它,直到上述 ignore_imports 条目消失,因此跳过机制只会对完全隔离的产品开启。

评论

支持 Markdown