第 2 章 · 01 SPEC §2 Layout 与依赖方向无环 本节摘要:SPEC §2 定义了 Reasonix 的目录 Layout 和一条最硬的工程约束——依赖方向无环(acyclic)。本节先逐目录走读 Layout( / / / / ),再深讲依赖方向: 这条主干;built-in 子包( 、 )import 父包自注册、父包永不 import 子包的"自注册倒置"模式;Remote-SSH 模块的四层分层( )及其"绝不 import cli/agent/serve、交互全走回调"的桌面友好设计。最后讲 REASONIX.md 的 import cycle 检查规则与 PR hygiene——把"无环"从口号变成可本地复现的检查。
本节摘要:SPEC §2 定义了 Reasonix 的目录 Layout 和一条最硬的工程约束——依赖方向无环(acyclic)。本节先逐目录走读 Layout(
go.mod/Makefile/cmd/internal/docs),再深讲依赖方向:cli → {agent, plugin, config} → {tool, provider}这条主干;built-in 子包(provider/openai、tool/builtin)import 父包自注册、父包永不 import 子包的"自注册倒置"模式;Remote-SSH 模块的四层分层(cli → remote/bootstrap → remote → {forward, sftpfs, config, netclient})及其"绝不 import cli/agent/serve、交互全走回调"的桌面友好设计。最后讲 REASONIX.md 的 import cycle 检查规则与 PR hygiene——把"无环"从口号变成可本地复现的检查。
内容来源:原项目源码
docs/SPEC.md§2 Layout、REASONIX.md(Import cycle rule / PR hygiene)、internal/目录树。
⚠️ 注意:Go 一旦出现 import cycle,
go build与go test会直接[setup failed]并列出环。Reasonix 因为代码量大(16.5 万行)且采用自注册模式,环的风险点集中在"子包测试文件 import 回父包"这条隐蔽路径,REASONIX.md 为此专门写了检查规则。
阅读完本节,你应当能够:
cli → {agent, plugin, config} → {tool, provider}。go test ./path/to/target/ 在本地检测 import cycle。SPEC §2 给出的 Layout(docs/SPEC.md:28-52),精简版:
reasonix/ ├── go.mod / go.sum # module reasonix; require BurntSushi/toml ├── Makefile # build / cross / vet / fmt / test ├── README.md / README.zh-CN.md ├── reasonix.example.toml # 配置示例 ├── docs/SPEC.md # 工程契约 ├── cmd/reasonix/main.go # 入口;blank-import 内置 provider/tool ├── cmd/reasonix-plugin-example/ # MCP stdio 插件参考实现 └── internal/ ├── cli/ # 子命令路由、flag、装配、退出码 ├── config/ # TOML 加载(flag > project > user > defaults) ├── provider/ # Provider interface + 类型 + kind→factory 注册表 │ └── openai/ # OpenAI 兼容实现;init() 注册 "openai" ├── tool/ # Tool interface + Registry │ └── builtin/ # read_file/write_file/edit_file/move_file/bash/ls/glob/grep ├── permission/ # 每次调用的 Policy:allow/ask/deny → Decision ├── command/ # 从 .reasonix/commands/*.md 加载的自定义 slash 命令 ├── plugin/ # stdio JSON-RPC(MCP)客户端;适配远端工具 ├── remote/ # Remote-SSH 模块的 SSH 传输 │ ├── forward/ # -L / -R 端口转发生命周期 │ ├── sftpfs/ # SFTP 文件层(隔离 pkg/sftp) │ └── bootstrap/ # 通过 SSH detached 启动 `reasonix serve` └── agent/ # Session + harness loop
几个细节值得注意:
go.mod 只 require BurntSushi/toml——TOML 是 SPEC §1.3 唯一接受的第三方依赖(其他依赖如 mvdan.cc/sh、tree-sitter 是后来按需加入,但仍守"纯 Go / 不破坏单二进制"的底线)。cmd/reasonix/main.go 是唯一入口;cmd/reasonix-plugin-example 是参考 MCP 插件,可独立编译运行。internal/ 下既有"抽象层"包(provider、tool、permission、plugin),也有"实现层"子包(provider/openai、tool/builtin),还有"运输层"(remote、serve)。紧接 Layout,SPEC 用一句话钉死了依赖方向(docs/SPEC.md:54):
Dependency direction (acyclic):
cli → {agent, plugin, config} → {tool, provider}.
依赖方向无环,主干只有两层:
cli ──→ agent ──→ tool │ │ │ └──→ provider ├──→ plugin ──→ tool └──→ config
含义:
cli 处于最顶层,负责装配;它 import agent、plugin、config。agent、plugin 处于中间层,它们 import tool、provider(底层抽象)。tool、provider 处于最底层,只 import 标准库与极少数工具包(如 internal/diff、internal/nilutil)。tool 不 import agent,provider 不 import cli。这条约束保证底层抽象稳定——改 agent 不会影响 tool/provider,改 cli 不会影响 agent。重构时"哪层能动、哪层不能动"一目了然。
💡 契约要点:无环不是"建议",而是 build/test 能否通过的红线。Go 编译器本身拒绝 import cycle,所以这条约束编译期强制;但 test 文件的环编译器查不出来,需要手动规则(见第四节)。
SPEC §2 紧接着说(docs/SPEC.md:55-56):
Built-in subpackages (
provider/openai,tool/builtin) import their parent to self-register; parents never import children.
这是 Go 实现"插件自注册"的经典手法,也是 Reasonix"薄 harness"在依赖方向上的具体落地:
internal/provider/openai/openai.go:57-58 调用 provider.Register("openai", New),因此必须 import "reasonix/internal/provider"。internal/provider/provider.go 里看不到任何对 openai、anthropic 的引用,只有 interface 和注册表。cmd/reasonix/main.go:10-17 用 blank-import _ "reasonix/internal/provider/openai" 触发子包 init(),把 Factory 注入注册表。为什么是"倒置"?因为常规直觉是"父包知道自己的实现",但那样就会产生 provider → openai → provider 的环(父包 import 子包,子包又 import 父包拿 interface)。倒置后,只有子包→父包一条边,环消失了:
provider(openai 子包) ──import──→ provider(父包,interface + registry) ▲ │ cmd/reasonix blank-import 触发 init()
这套模式适用于所有"编译期内置":
provider/openai 注册 "openai" kind。provider/anthropic 注册 "anthropic" kind。provider/responses 注册 "responses" 和 "dashscope-responses"。tool/builtin 的每个工具文件(bash.go、readfile.go、editfile.go 等)各自注册一个内置工具。⚠️ 注意:加新的内置 provider/tool 时,必须在
cmd/reasonix/main.go加对应 blank-import,否则它的init()永远不会执行,注册表里没有这一项。漏加是新手最常犯的错。
Remote-SSH 是 Reasonix 通过 SSH 连接远端机器、在远端跑 reasonix serve 的能力。SPEC §2 用一段单独的分层描述(docs/SPEC.md:56-60):
The Remote-SSH module layers
cli → remote/bootstrap → remote → {remote/forward, remote/sftpfs, config, netclient};remoteand its subpackages never importcli,agent, orserve, and all interactivity flows through callbacks (host-key / secret prompts) so the desktop module consumes the same surface.
四层从上到下:
cli——用户输入 reasonix remote ... 子命令的入口。remote/bootstrap——通过 SSH detached 启动远端 reasonix serve。remote——SSH 传输核心。remote/forward(端口转发 -L/-R)、remote/sftpfs(SFTP 文件层)、config、netclient——底层能力。关键约束有两条:
remote 及其子包绝不 import cli、agent、serve。这保证远端逻辑与本地 CLI/Agent 解耦——远端可以单独编译、单独测试。remote 不直接 fmt.Scan 读用户输入,而是通过回调函数把"需要用户确认 host key""需要密码"这类事件抛给上层;上层在 CLI 里走 TUI 弹窗,在桌面里走 React 弹窗。第二条是"三前端共用单 Controller"哲学的延伸——remote 是传输无关的,CLI 和桌面能用同一套 surface。第 10 章会详讲 Remote-SSH。
Go 编译器对非测试文件的 import cycle 零容忍——go build 直接报错。但测试文件的环更隐蔽:agent/agent_test.go import tool/builtin,tool/builtin/sessions.go 又 import agent,这种环 go build 看不见(因为 test 不进二进制),但 go test 会以 [setup failed] 失败。
REASONIX.md 给出了检查规则与反例(REASONIX.md:49-56):
## Import cycle rule Before importing a new internal package from a non-test file, verify the target package's **test files** aren't already importing back to you: # BAD: agent(_test.go) → tool/builtin(sessions.go) → agent → setup failed Use `go test ./path/to/target/` to detect cycles **before** pushing. A `[setup failed]` message means a cycle exists.
实操:在 agent 里要 import tool/builtin 之前,先跑 go test ./internal/tool/builtin/,看它的测试文件是否已经 import 回 agent。若是,就会形成 agent(_test) → tool/builtin → agent 的环,go test ./internal/agent/ 会报 [setup failed]。
💡 契约要点:import cycle 的检查不是"等 CI 报错再修",而是 PR 作者在本地用
go test ./path/to/target/主动验证。[setup failed]这个错误信息几乎专指环。
REASONIX.md 还列了三条 PR 卫生(REASONIX.md:58-62):
这三条不是 Go 特有,但 Reasonix 把它们写进项目记忆(REASONIX.md),意味着每个 PR 作者都默认遵守。配合 SPEC §1.6"Evolve, don't over-engineer",整体工程文化是"小步、最小 diff、可追溯"。
go.mod(只 require BurntSushi/toml)、cmd/reasonix(入口)、internal/(40+ 内核包)、cmd/reasonix-plugin-example(MCP 插件样板)。cli → {agent, plugin, config} → {tool, provider},箭头单向、绝不反向。provider/openai、tool/builtin)import 父包自注册,父包永不 import 子包;入口 blank-import 触发 init()。cli → remote/bootstrap → remote → {forward, sftpfs, config, netclient};remote 绝不 import cli/agent/serve,交互全走回调。go test ./path/to/target/ 验证;[setup failed] 几乎专指环;测试文件的环最隐蔽。下一节,我们看 Reasonix 怎么用 TOML 把这一切配置化——config 四级覆盖(flag > project > user > defaults)与
reasonix.example.toml260 行逐段精读,理解"加新模型是 config 编辑,不是代码改动"。