ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

oh-my-pi 内置规则剖析:为什么 TypeScript 中禁止滥用 `isRecord` 类型守卫,以及正确的边界解析姿势

oh-my-pi 内置规则剖析:为什么 TypeScript 中禁止滥用 `isRecord` 类型守卫,以及正确的边界解析姿势 oh-my-pi 内置规则剖析为什么 TypeScript 中禁止滥用isRecord类型守卫以及正确的边界解析姿势【免费下载链接】oh-my-pi⌥ Coding agent with the IDE wired in项目地址: https://gitcode.com/GitHub_Trending/oh/oh-my-pi本文以 oh-my-pi一个与 IDE 深度耦合的 Coding Agent内置的代码生成规则ts-no-local-is-record为线索完整讲解该规则的触发条件、禁用原因、推荐替代方案与例外情形并结合仓库源码说明这条规则是如何被内置、加载并参与会话的。读完本文你将掌握在 TypeScript 项目中如何正确做运行时对象形状校验避免用isRecord掩盖数据契约。规则速览一条禁止手写 isRecord的内置规则在 oh-my-pi 的 coding-agent 中规则Rule是一种通过 Markdown frontmatter 声明的约束文件会被 Agent 在编辑代码时用于纠正或指导生成行为。ts-no-local-is-record就是其中一条针对 TypeScript 的默认规则其完整内容位于 packages/coding-agent/src/discovery/builtin-rules/ts-no-local-is-record.md。它的 frontmatter 定义了这条规则的全部元信息--- description: Never use isRecord condition: - \\bfunction\\sisRecord(?:\\s*[^]*)?\\s*\\( - \\b(?:const|let|var)\\sisRecord\\b\\s*(?::[\\s\\S]{0,300}?)?\\s*(?:async\\s)?(?:function\\b|(?:[^\\n]*\\s*)?(?:\\([^)]*\\)|[A-Za-z_$][\\w$]*)\\s*(?::[\\s\\S]{0,300}?)?) scope: tool:edit(*.{ts,tsx,mts,cts}), tool:write(*.{ts,tsx,mts,cts}) interruptMode: never ---各字段含义如下字段值作用descriptionNever use isRecord规则的简短说明供 Agent 与用户理解规则意图condition两条正则触发 TTSRTime Traveling Stream Rules匹配的条件第一条命中function isRecord(...)形式的函数声明第二条命中const/let/var isRecord ... 形式的箭头函数赋值两条覆盖了最常见的两种手写守卫写法scopetool:edit(*.{ts,tsx,mts,cts}), tool:write(*.{ts,tsx,mts,cts})规则只在edit与write工具针对 TypeScript 家族文件.ts、.tsx、.mts、.cts的参数流上生效interruptModenever即使命中也从不打断当前流、不插入提示仅作为静态约束存在需要说明的是condition中的正则使用了双重转义\\b这是因为 frontmatter 中的正则字符串需要经过 YAML 解析后仍保持语义正确。从 packages/coding-agent/src/capability/rule.ts 的compileRuleCondition实现看规则引擎还会兼容 PCRE 风格的前导内联标志如(?i)将其转换为原生RegExp标志避免非法正则被静默丢弃。为什么isRecord是错的规则正文开宗明义地给出了三条禁用理由一个Recordstring, unknown守卫只能证明它是对象不能证明它的字段是什么。也就是说通过isRecord之后你仍然不知道对象里有哪些字段、字段是什么类型类型信息依然全部是unknown。要么不必要地复杂要么强度不足。真正需要校验的场景下它不够强字段仍未知简单场景下又显得多余。反复出现的守卫会向读者和 TypeScript 隐藏数据契约。每一处isRecord都是一次临时起意的检查分散在各调用点导致对象真实形状schema无法被集中表达、被编译器检查。这三条理由指向同一个核心类型守卫的价值在于收窄到有意义的类型而Recordstring, unknown恰恰是最没有意义的收窄结果之一——它把对象和未知绑在一起既没有告诉编译器任何具体信息也没有保护运行时安全。Use正确的三种做法规则正文给出了三种替代方案覆盖了从完全信任到彻底校验的强度梯度方案一在数据边界用 schema 校验器解析一次网络响应、配置文件、IPC 消息、持久化数据、被复用的数据结构——这些从外部进入系统的数据形状应当在边界处只解析一次使用项目既有的 schema 校验器例如 Zod并直接消费其推导出的具名输出类型const Config z.object({ retries: z.number().int().nonnegative() }); type Config z.infertypeof Config; const config Config.parse(raw);这里的关键点是z.object(...)集中定义了数据契约字段名、字段类型、约束int整数、nonnegative非负都一目了然z.infertypeof Config让类型与运行时校验单一来源不会出现校验器写一份、类型写一份的双份维护成本Config.parse(raw)在边界抛错后续代码拿到的config是完整收窄后的Config类型所有字段类型已知无需任何额外守卫。方案二运行时形状不确定时只检查用到的属性如果运行时形状确实无法预知不要写一个宽泛的isRecord而是针对即将访问的具体属性做精确检查typeof value.field string—— 检查字符串字段Array.isArray(value.list)—— 检查数组字段field in value—— 检查属性存在性使用判别字段discriminant例如value.type config后按分支收窄。这种方式把需要确认什么显式地写在了使用点附近编译器能随之收窄类型而不是把一切都塞进unknown。方案三既有不变量已保证形状时在边界断言具名类型如果项目的既有不变量invariant已经保证数据形状例如上游代码已用同一 schema 校验过那么应该在边界处用类型断言声明具名类型而不是在每一个调用点重复写守卫const config value as Config;as Config是有意为之的信任声明把我相信这里是Config这个决策集中到数据进入系统的唯一位置而不是散落在 N 个调用点各写一个isRecord。Avoid必须避免的写法规则正文明确列出两种需要避免的实现模式// 反例 1function 声明形式 function isRecord(value: unknown): value is Recordstring, unknown { return !!value typeof value object !Array.isArray(value); } // 反例 2箭头函数形式 const isRecord (value: unknown): value is Recordstring, unknown value ! null typeof value object;注意反例 1 与反例 2 在语义上的细微差异反例 1 通过!value排除了null且用!Array.isArray排除了数组但反例 2 没有排除数组数组也是typeof object。这两者的不一致恰好说明了手写守卫的典型问题——每个调用点都可能写出语义不同的守卫而它们又都只收窄到Recordstring, unknown对调用方毫无帮助。Exceptions唯一的例外情形规则正文给出了一个例外一个独立的、没有共享类型守卫模块的软件包standalone package可以定义唯一一个权威守卫。它必须从该包的类型守卫模块type-guard module中导出绝不允许在单个调用点重新创建。也就是说例外的前提是该包真的没有共享守卫模块且即便允许定义也要求单一权威实现 集中导出这与规则反对的每个调用点各写一份是同一枚硬币的两面——反对的是重复与散落而不是守卫本身。源码佐证这条规则如何在 oh-my-pi 中生效1. Markdown 以文本形式内嵌进二进制在 packages/coding-agent/src/discovery/builtin-rules/index.ts 中所有内置规则通过with { type: text }以文本导入方式内嵌这样在bun build --compile生成编译产物时规则内容会随二进制一起打包产物不再依赖散落的规则文件import tsNoLocalIsRecord from ./ts-no-local-is-record.md with { type: text };随后这些规则被收集进BUILTIN_RULE_SOURCES每条规则由name与contentfrontmatter 正文的完整 Markdown构成。2. 以最低优先级注册任何同名规则都可覆盖在 packages/coding-agent/src/discovery/builtin-defaults.ts 中内置规则以一个名为builtin-defaults的 provider 注册优先级被刻意设为1所有规则 provider 中最低const DISPLAY_NAME Builtin Defaults; // Lowest priority: every other rule provider wins a name conflict. const PRIORITY 1;这意味着用户级、项目级或工具级的任何同名规则例如用户自建ts-no-local-is-record都会按名字覆盖内置副本first-wins 去重。内置规则只是兜底默认值而非不可变更的强制约束。此外内置规则还提供三种关闭方式见该文件头部注释及 docs/rulebook-matching-pipeline.md 的说明将ttsr.builtinRules设为false一次性丢弃整组内置规则在ttsr.disabledRules中列出规则名单独丢弃某一条在更高优先级来源中定义同名规则覆盖内置副本。3. 解析为统一的 Rule 结构并参与 TTSR 匹配builtin-defaults.ts通过buildRuleFromMarkdown将 Markdown 解析为统一的Rule结构见 packages/coding-agent/src/discovery/helpers.tsfrontmatter 中的condition、scope、interruptMode、agents、alwaysApply等字段被逐一抽取正文则作为content保留。Rule的规范结构定义在 packages/coding-agent/src/capability/rule.tsinterface Rule { name: string; path: string; content: string; globs?: string[]; alwaysApply?: boolean; description?: string; condition?: string[]; astCondition?: string[]; scope?: string[]; agents?: string[]; interruptMode?: never | prose-only | tool-only | always; _source: SourceMeta; }能力层以rule.name作为去重与优先级判定的键ruleCapability.key rule rule.name所以同名规则之间的覆盖是纯名称级的。在会话创建时见 docs/ttsr-injection-lifecycle.md规则会通过bucketRules(...)分桶命中condition的规则注册进TtsrManager用于流式监控interruptMode: never则意味着即便命中也不触发流中断。对ts-no-local-is-record而言它的作用更多是常驻的编码约束配合scope只观察edit/write写向.ts/.tsx/.mts/.cts的参数流。小结ts-no-local-is-record这条规则浓缩了 oh-my-pi 内置规则的设计哲学规则不是教条而是把分散在调用点的坏习惯收敛为边界处的一次性决策。对 TypeScript 开发者来说记住三个层次即可有 schema 校验器在边界parse一次消费具名类型别无脑写守卫形状不确定按需检查typeof/Array.isArray/in/ 判别字段形状已保证在边界断言具名类型别让isRecord在每个调用点重复出现。如果想在项目里复用这套机制可以参照 packages/coding-agent/src/discovery/builtin-rules/ts-no-local-is-record.md 的 frontmatter 格式在.omp/rules/或RULES.md详见 packages/coding-agent/src/discovery/builtin.ts 的加载逻辑中编写自己的同构规则——只要保持conditionscopeinterruptMode的声明结构Agent 就会自动识别并约束其编辑行为。【免费下载链接】oh-my-pi⌥ Coding agent with the IDE wired in项目地址: https://gitcode.com/GitHub_Trending/oh/oh-my-pi创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表