oh-my-pi 内置规则解析:用 net.JoinHostPort 取代 fmt.Sprintf 构造 Go 网络地址
2026/9/10 11:48:58 网站建设 项目流程

oh-my-pi 内置规则解析:用 net.JoinHostPort 取代 fmt.Sprintf 构造 Go 网络地址

【免费下载链接】oh-my-pi⌥ Coding agent with the IDE wired in项目地址: https://gitcode.com/GitHub_Trending/oh/oh-my-pi

在 oh-my-pi 这个"把 IDE 接入编码 Agent"的项目中,packages/coding-agent/src/discovery/builtin-rules/目录内置了一批针对 Go / Rust / TypeScript 的编码约束规则。其中go-join-hostport.md专门纠正一个高频且隐蔽的 Go 网络编程错误:用fmt.Sprintf("%s:%d", host, port)拼接host:port地址会在 IPv6 场景下产出不可解析的字符串。读完本文,你将掌握net.JoinHostPort的正确用法、IPv6 地址加方括号的底层原理,并理解这条规则在 oh-my-pi 的 TTSR(Time Traveling Stream Rules)机制中如何被自动发现、触发与注入,以及如何按需禁用或覆盖它。

问题根源:IPv6 字面量自带的冒号

网络地址中的端口号之前需要冒号分隔,但 IPv6 字面量本身就包含冒号。以环回地址::1为例:

fmt.Sprintf("%s:%d", "::1", 80) // 结果: "::1:80"

::1:80存在歧义:究竟是地址::1加端口80,还是地址::加端口1:80?标准库的net.Dial无法解析这种形式。一个本意是[::1]:80的连接串,被拼成了无法 parse 的::1:80,网络请求会直接失败。这正是原规则文档指出的核心问题:

An IPv6 literal like::1has its own colons, sofmt.Sprintf("%s:%d", "::1", 80)yields::1:80— unparseable bynet.Dial.

Go 标准库对此的标准答案是 net.JoinHostPort:当 host 中含有冒号时自动补上方括号(RFC 3986 的 IPv6 字面量表示法),对普通 IPv4 地址和主机名则原样保留,不做任何画蛇添足的处理。

禁止写法(Avoid)

原规则文档给出的反面示例,是 Agent 在生成 Go 代码时经常"顺手"写出的模式:

addr := fmt.Sprintf("%s:%d", host, port) conn, err := net.Dial("tcp", addr)

这段代码在 host 为 IPv4(如127.0.0.1)或域名(如example.com)时工作正常,一旦 host 变成 IPv6 字面量(::12001:db8::1等)就会静默产出非法地址。这种"平时没事、特定场景才炸"的写法是最难排查的一类缺陷,因此在规则中被标记为高风险模式。

推荐写法(Use)

// port is a string here; convert an int with strconv.Itoa. addr := net.JoinHostPort(host, strconv.Itoa(port)) conn, err := net.Dial("tcp", addr)

注意net.JoinHostPort的端口参数类型是string而非int。如果端口来自int变量,需要先用strconv.Itoa转换。原规则文档特别强调:该函数在所有受支持的 Go 版本中均可用(自 Go 1.0 起就是标准库的一部分),不存在引入新依赖或要求最低 Go 版本的门槛,可以直接替换。

补充:解析侧同样要配套

与构造侧对称,解析一个host:port字符串时应使用net.SplitHostPort,而不是strings.Split或手动查找冒号——net.SplitHostPort能正确剥离 IPv6 字面量的方括号,并区分[::1]:80中的地址与端口。构造用JoinHostPort、解析用SplitHostPort,二者配套才能保证 IPv6 场景下的往返一致。

静态防线:go vet 的 hostport 分析器

原规则文档还提到了一个重要的自动化检查手段:Go 1.25 的go vet新增了hostport分析器,专门标记fmt.Sprintf("%s:%d", host, port)这类拼接模式。也就是说,除了依赖代码审查和 Agent 规则,开发者还可以把go vet(或go vet -vettool自定义分析器)接入 CI,从工具链层面拦住这类问题,形成"Agent 规则 + 静态检查"的双重防线。

规则如何在 oh-my-pi 中生效:frontmatter 语义

go-join-hostport.md不是一篇普通的 Markdown 笔记,它的 frontmatter 定义了规则被 oh-my-pi 发现和触发的方式:

--- description: "Build network addresses with net.JoinHostPort, not fmt.Sprintf(\"%s:%d\", host, port) — the Sprintf form breaks on IPv6" condition: 'fmt\.Sprintf\("%s:%d"' scope: "tool:edit(*.go), tool:write(*.go)" interruptMode: never ---

四个字段分别控制:

字段含义
description规则的一句话说明进入 rulebook,供 Agent 在生成代码时参考;同时也是规则能被"检索/引用"的依据
conditionfmt\.Sprintf\("%s:%d"正则匹配条件,命中即触发 TTSR 拦截
scopetool:edit(*.go), tool:write(*.go)限定触发流:仅在edit/write工具处理*.go文件时生效
interruptModenever命中时不中断当前生成流,改为在工具结果前注入提醒

conditionscopeinterruptMode等字段的解析逻辑位于 packages/coding-agent/src/capability/rule.ts:parseRuleConditionAndScope负责把condition/scope转成规则对象;interruptMode支持neverprose-onlytool-onlyalways四种取值,其中never表示"匹配到也不中断,仅静默提醒"。

scope: "tool:edit(*.go), tool:write(*.go)"的含义是:只有当 Agent 调用editwrite工具、且目标文件是*.go时,该规则的匹配才生效。这意味着 Agent 在写普通文本或编辑非 Go 文件时,这条规则不会产生任何干扰;一旦开始生成/修改 Go 源码并出现fmt.Sprintf("%s:%d"模式,规则立刻被激活。

interruptMode: never 的实际效果

结合 docs/ttsr-injection-lifecycle.md 对触发决策的描述,interruptMode: never的规则在命中时不会 abort 当前流,而是走"非中断注入"路径:

  • 若匹配发生在edit/write这类工具调用流上,TTSR 会在该工具返回结果时,把渲染好的ttsr-tool-reminder.md提醒块前置toolResult.content之前(即<system-reminder reason="rule_violation" ...>包装的规则正文),Agent 拿到结果时就能看到"请改用net.JoinHostPort"的提醒;
  • 若匹配发生在正文/思考流上,则排队一个隐藏的注入消息,在成功的助手消息之后通过agent.followUp()补发。

选择never的理由也很清晰:这类"写法不合规但代码仍可运行"的问题不需要打断生成流程重试,注入一条提醒让 Agent 自我纠偏即可,兼顾了正确性与生成效率。

内置规则的嵌入与优先级

这 27 条规则(Go 8 条、Rust 5 条、TypeScript 14 条)并非运行时从磁盘读取的散装文件,而是在编译期通过with { type: "text" }嵌入进二进制,见 packages/coding-agent/src/discovery/builtin-rules/index.ts:

import goJoinHostport from "./go-join-hostport.md" with { type: "text" }; // ... { name: "go-join-hostport", content: goJoinHostport },

这样bun build --compile产出的二进制不带任何零散规则文件,规则文本全部内嵌,原生安装包同样读取这一组模块。

随后 packages/coding-agent/src/discovery/builtin-defaults.ts 以PRIORITY = 1的最低优先级把它们注册为builtin-defaultsprovider:

// Lowest priority: every other rule provider wins a name conflict. const PRIORITY = 1;

最低优先级意味着"同名覆盖"机制:任何来自用户级、项目级或工具级的同名规则(如自定义一个go-join-hostport)都会按 first-wins 规则优先生效,覆盖内置副本。规则的分流逻辑集中在 packages/coding-agent/src/capability/rule-buckets.ts 的bucketRules中,处理顺序为:disabledRules剔除 →builtinRules === false时整体剔除 →agents范围过滤 → 注册为 TTSR 规则 → 进入 always-apply / rulebook 分桶。

触发链路:从流式检测到注入

规则注册进TtsrManager后(见 packages/coding-agent/src/export/ttsr.ts 的addRule),其condition会被编译为正则RegExpcompileRuleCondition还兼容(?i)这类 PCRE 内联旗标),并在会话流式输出期间实时匹配:

  1. turn_start时重置流缓冲区;
  2. message_update期间按 source/tool 隔离缓冲区,对edit/write工具流用重建的源码快照调用checkSnapshot匹配,正则命中即返回匹配规则;
  3. 触发决策依据各规则的interruptMode聚合处理,never规则走非中断注入路径。

完整生命周期(发现 → 注册 → 流式匹配 → 触发决策 → 重试调度 → 持久化 → 事件广播)可参考 docs/ttsr-injection-lifecycle.md,其中明确描述了condition正则编译失败时的降级行为:该条件被跳过并记录 warning,其余规则不受影响,会话正常启动。

如何禁用或覆盖这条规则

go-join-hostport这类内置规则默认开启(ttsr.builtinRules默认true),用户有三种方式调整,配置项定义见 packages/coding-agent/src/config/settings-schema.ts:

1. 整体关闭所有内置规则

{ "ttsr": { "builtinRules": false } }

这会丢掉整个builtin-defaults集合(27 条全部不生效),用户/项目规则仍正常加载。

2. 单独禁用某一条

{ "ttsr": { "disabledRules": ["go-join-hostport"] } }

规则在分桶前即被剔除,既不会作为 TTSR 匹配,也不会出现在 rulebook 中。

3. 用同名规则覆盖

在任意更高优先级的来源(项目级.cursor/.windsurf/.cline等目录,或用户级配置)放置一个同名的go-join-hostport规则文件,即可用自定义内容覆盖内置副本——例如放宽匹配范围、修改为interruptMode: "tool-only",或补充自定义的违规示例。

小结

net.JoinHostPortfmt.Sprintf("%s:%d", ...)之争的实质,是"手工拼接"与"标准库语义化构造"的差异:前者在 IPv6 面前必然失守,后者天然免疫,且零版本门槛。在 oh-my-pi 中,go-join-hostport规则把这一结论编译进了 Agent 的行为约束——通过 frontmatter 声明的condition/scope/interruptMode,配合 TTSR 流式检测与never模式的非中断提醒,让 Agent 在编辑任何*.go文件时自动避开这个坑,同时不打断正常的生成节奏。理解这条规则,也就理解了 oh-my-pi 内置规则体系"Markdown 声明式 + 低优先级可覆盖 + 流式注入"的整体设计思路,可直接迁移到自定义规则与团队编码规范的落地实践。

【免费下载链接】oh-my-pi⌥ Coding agent with the IDE wired in项目地址: https://gitcode.com/GitHub_Trending/oh/oh-my-pi

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询