@trim21/personal-pi-extensions 0.1.474 → 0.1.475

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.474",
3
+ "version": "0.1.475",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -80,7 +80,8 @@
80
80
  "src/web/fetch.ts"
81
81
  ],
82
82
  "skills": [
83
- "src/talk/skills"
83
+ "src/talk/skills",
84
+ "src/skills"
84
85
  ]
85
86
  },
86
87
  "lint-staged": {
@@ -0,0 +1,184 @@
1
+ ---
2
+ name: coding-style
3
+ description: Use when 编写、生成、修改、编辑、重构或评审任何代码——涵盖实现功能、修 bug、写脚本、写测试、性能优化、代码迁移、重命名、加减参数或函数、修改函数签名、定义类型、处理函数间数据流、JSON/YAML 反序列化、code review / PR review。任何语言中任何会产出或改动代码的任务(write / edit / refactor / review code)都要加载本 skill,即使用户没有提到风格、规范或最佳实践。只读调查不加载:单纯 debug、定位代码、定位问题、读代码理解逻辑等不改代码的任务不需要加载。
4
+ ---
5
+
6
+ # 编码风格(shared)
7
+
8
+ 语言无关的原则写在本文件;语言特定的实现细则在对应语言的 skill 里,**写或改哪种语言,就同时加载哪个 skill**:
9
+
10
+ | 语言 / 场景 | Skill |
11
+ | --------------------------------------------- | ------------------------------------ |
12
+ | Python | `coding-style-python` |
13
+ | TypeScript / JavaScript | `coding-style-typescript-javascript` |
14
+ | Go | `coding-style-golang` |
15
+ | C++ | `coding-style-cpp` |
16
+ | 跨语言 FFI / 镜像结构(pybind11、Python↔C++) | `coding-style-ffi` |
17
+
18
+ 每条规则都说明**为什么**这么要求——理解动机后,遇到规则没覆盖的灰色地带才能自己判断。
19
+
20
+ ---
21
+
22
+ ## 1. 函数间数据传递:用显式类型,不用 Any / 裸字典 / 裸元组
23
+
24
+ ### 核心原则
25
+
26
+ 函数边界是代码里最容易发生"形状漂移"的地方。一个函数返回一个无类型的字典,下游函数从里面取几个字段——改的人 A 加了个字段,改的人 B 改了某个键名,调用方默默拿到运行时错误,或者拿到一个语义已经变了的数据。类型检查器帮不上忙,IDE 补全靠猜,读代码的人得跳回定义处才能知道这个字典里到底有什么。
27
+
28
+ 所以:**函数之间传递结构化数据时,用带命名字段的显式类型容器(Go 的 struct、Python 的 dataclass、TS 的 interface……),不要用 `Any`、裸字典、裸元组这类无类型载体。**
29
+
30
+ **裸字典唯一合适的领域,是无界 key 对应相同的数据类型**——也就是 Go 里用 `map[K, V]` 的场景。如果一个场景我们不会在 Go 里用 `map[K, V]` 来表达(那是一组已知字段、类型各异的记录,Go 会用 struct),在任何语言里也不该用裸字典来表达。
31
+
32
+ 这条规则约束的是"跨越函数边界的数据"。函数内部的局部变量用什么类型,不在这个 skill 的管辖范围内——内部用什么字典做缓存、用什么临时变量都行,只要它不作为参数传出去、不作为返回值传出去。
33
+
34
+ ### 为什么这样做
35
+
36
+ **类型安全是契约,不是装饰。** 一个 `dict[str, Any]` 风格的参数向调用方承诺了什么?什么都没承诺。一个命名字段的容器参数承诺了"我有这几个字段,类型分别是……"。类型检查器能在编译期(或 CI)就发现你把 `quantity` 当字符串用了。裸字典做不到。
37
+
38
+ **形状漂移是真实发生的 bug 来源。** 当数据以裸字典形式穿过三四个函数,中间任一环节加键、改键名、改语义,下游都会静默出错。typed 容器让这种改动要么是一次显式、可被 review 的字段增删,要么直接是类型错误。
39
+
40
+ **可读性。** 读到 `process(trade)` 且类型是裸字典时,你得打开 `process` 的实现才知道参数里有什么、返回什么。读到 `process(trade: Trade): TradeResult` 这样的签名时,签名本身就回答了这个问题。函数签名是代码里最常被扫到的文档,让它说人话。
41
+
42
+ **重构安全。** 重命名容器字段时,IDE 和类型检查器能定位所有引用;重命名字典里的键时,只能靠 grep + 祈祷。
43
+
44
+ **与类型检查工具链协同。** 这条原则的价值依赖项目在用类型标注(至少函数签名上有)。如果项目根本不跑类型检查,typed 容器的价值会打折——但可读性和防漂移的收益仍然在。不要为了用 typed 容器而在没类型检查的项目里强行引入;先看项目现状。
45
+
46
+ ### 合理的例外
47
+
48
+ 规则约束的是"函数间结构化数据传递",不是所有字典 / Any 的使用。下面这些场景不适用:
49
+
50
+ - **纯字典语义的数据:** 真的在做"键→值"映射且键空间是开放/动态的(缓存、计数器、词频统计)。判断标准就是会不会在 Go 里写成 `map[K, V]`:会,才用字典;不会(一组已知字段、类型还可能不同),就不用。
51
+ - **与外部边界交互:** 解析 JSON、读 CSV、对接 HTTP API,拿到无类型的原始数据是正常的。关键是在进入你自己的函数边界之前,把它解析成 typed 容器(见第 3 节),后续函数间传递用 typed 容器。
52
+ - **Any 作为类型擦除/渐进式标注:** 在给老代码逐步加类型的过渡期,Any 可以作为占位,但应尽快收敛,不要让它成为函数签名的长期状态。
53
+ - **确实无法静态确定类型:** 极少数元编程、动态代理场景。真实业务代码里几乎不会遇到,别拿这个当借口。
54
+
55
+ 判断标准始终是同一条:**这段数据会不会穿过函数边界、会不会被多个函数依赖其形状?** 会 → 用 typed 容器;不会 → 随便。
56
+
57
+ ### 重构现有代码
58
+
59
+ 把已有的裸字典传递代码改成 typed 容器时,按这个顺序做,降低出错面:
60
+
61
+ 1. **先找边界,不急着改内部。** 找出作为函数参数或返回值的裸字典 / 元组 / Any,挑一条数据流(比如"订单从 fetch → enrich → persist")作为目标,一次改一条流。
62
+ 2. **定义容器。** 根据现有代码里实际用到的键,定义容器类型。字段类型从现有用法反推,拿不准的先用 Any 占位,不要凭空猜类型。
63
+ 3. **改返回方先,调用方后。** 先让数据的生产端返回 typed 容器,这样类型检查器能立刻帮你发现漏改的访问点。
64
+ 4. **跑类型检查 + 测试。** 每改完一条流就跑一次类型检查和相关测试。类型错误会精确指出还没改的访问点,测试会兜底行为正确性。
65
+ 5. **别顺手重构无关代码。** 只改数据传递相关的部分。看到别的可以改的地方记下来,单独处理。
66
+
67
+ 如果函数层级很深、字典穿透了很多层,优先改最外层的公共函数签名——内部函数可以暂时继续收字典,因为它们不暴露给外部,影响面小,可以后续慢慢收敛。
68
+
69
+ 容器选型(可变性、命名元组、不可变默认值等)和类型检查器的具体配置见对应语言的 skill。
70
+
71
+ ---
72
+
73
+ ## 2. 数据合法性检查外推到系统边界
74
+
75
+ 把数据合法性的检查推到系统的**边界**(外部输入进入内部的那一层),内部代码默认拿到的数据已经是合法的,不再重复校验。并且,尽量让构造出来的对象本身就持有合法数据,而不是先构造一个"可能不合法"的对象、再在别处修正它。
76
+
77
+ ### 为什么这样做
78
+
79
+ - **检查散落 = 检查遗漏。** 同一个约束在十个函数里各检查一遍,改约束时要改十处,漏一处就是 bug。把检查集中到边界,内部只有一个权威入口,约束变更只改一处。
80
+ - **重复检查是噪声。** 内部函数反复检查同一个已经在边界验证过的约束,掩盖了函数的真实职责,读代码的人分不清哪些检查是"可能真的会出错"、哪些是"防御性冗余"。
81
+ - **"先构造非法再修正"是不安全的。** 先造出一个语义上不该存在的非法对象、再调用 fix-up 修正:在修正真正运行之前,非法对象是可被任何人拿到的——重构、并发、提前 return 都会让它泄漏出去。这类"先脏后净"的写法破坏了"对象总是合法"这一基本保证。
82
+ - **让非法状态不可表达。** 如果类型本身能保证合法(用枚举代替魔法字符串、用字面量联合类型/newtype 限定取值、用专门类型代替裸标量),那么很多检查在构造时就一次性完成,后续代码连写错的机会都没有。这是比"检查"更强的保证。
83
+
84
+ ### 什么是"系统边界"
85
+
86
+ 边界是"外部、不受信任的数据进入你代码"的位置:
87
+
88
+ - 输入解析层:HTTP 请求体、命令行参数、配置文件、JSON/YAML 反序列化。
89
+ - 跨语言/跨进程入口:C++ 经 FFI 回调进 Python、另一个服务发来的消息。
90
+ - 外部 API 响应、数据库读出的原始行——它们承诺的 schema 不一定真的成立。
91
+ - 读的文件、用户上传的内容。
92
+
93
+ 在这些位置做一次完整的校验(合法性、取值范围、类型、必填、关联约束),通过后产出**合法的、有类型的内部对象**。从这之后,内部函数间的数据传递就不必再怀疑数据形状——第 1 节"函数间传递用 typed 容器"正是为这一步服务的。类型标注 + 边界校验 = 内部免检。
94
+
95
+ ### 怎么做
96
+
97
+ **边界校验覆盖两个场景,产出方式不同。**
98
+
99
+ **场景一:把已经构造好的结构化数据在类型上确定下来。** 数据已经存在(外部 JSON、API 响应、反序列化结果),工作是把它的类型确定下来。有成熟校验库时,从原始数据一步解析成 typed 对象,不手写逐字段检查——校验库自动处理缺字段、类型转换、嵌套结构和错误聚合,比手写可靠(手写解析缺字段静默用默认、类型不匹配静默通过、错误信息零散)。具体写法见 ref:Python 用 pydantic,TS 用 zod / typebox。
100
+
101
+ **场景二:自己拼接结构化数据。** 数据没有现成形态,需要自己从多个来源取值、做转换后拼装成对象。正确姿势是"先逐个构造字段值,最后拼装成对象":先在边界函数里用局部变量逐个构造/校验每个值,最后一次性拼装成对象——中间任何一步都不出现"可能不合法的对象"。每个值的校验独立成步,错误发生在各自字段的构造点;最后一行只是纯组合,不可能产出非法对象,中间变量还能被类型检查器单独把关。完整示例见 ref。
102
+
103
+ **能由类型保证的,就不要靠运行时检查。** 类型能表达的约束(取值集合、字段存在性、nullable vs 非 null)尽量交给类型系统——用枚举、字面量联合类型或专用 newtype 代替魔法字符串和裸标量,让非法取值"构造不出来",在调用点就是类型错误;类型表达不了的(范围、跨字段关联约束、业务规则)集中在边界校验里。两类加在一起,让"构造出来即合法"成为内部代码可以依赖的事实。具体写法见 ref。
104
+
105
+ **不要"先构造非法再修正"。** 构造时数据可能非法、指望后面调用一个 normalize/fix-up 修正——万一没调用到、提前 return、并发呢?修正要么发生在各字段的构造点(场景二),要么发生在边界校验一步(场景一),不存在"先构造一个脏对象"的中间态。
106
+
107
+ ### 什么时候在内部也检查
108
+
109
+ 不是所有检查都只该在边界做。内部再次检查是合理的,当且仅当:
110
+
111
+ - **不变量可能被内部逻辑破坏。** 如果某个内部操作有可能把对象弄成不合法状态,那么操作后检查是必要的——但更好的做法是让操作本身就不可能产出非法状态(用"产出新对象"表达状态变化,而不是就地改坏再校验)。真要检查,说明类型没把约束写死,值得考虑能不能用更强的类型。
112
+ - **安全/权限关键路径。** 涉及鉴权、资金、破坏性操作的关键校验,即使边界查过,内部在真正执行前再确认一次是合理的纵深防御。这不是"重复校验同义约束",而是"高代价动作前的最后一道关"。
113
+ - **跨信任域的内部边界。** 模块 A 和模块 B 之间如果有"谁的代码都可能改这个对象"的耦合,那它们之间也是一条边界,值得校验。理想情况下应通过类型/封装消除这种耦合,而不是靠校验兜底。
114
+
115
+ 除此之外,内部代码对"已经从边界进来的、有类型的对象"重复校验同一约束,应当视为冗余——把它删掉,或把它上推到边界/类型里。
116
+
117
+ ---
118
+
119
+ ## 3. JSON/YAML 反序列化分两层:原始 schema + 验证后模型
120
+
121
+ 从 JSON/YAML(配置文件、API 响应)反序列化时,不要手写大段 `raw["xxx"]` 逐字段构造目标对象的代码。把过程拆成两层:
122
+
123
+ 1. **原始 schema**:用成熟校验库从原始 dict 解析。这一层只做"形状对齐 + 基础类型校验",不做业务转换。声明哪些字段看 schema 归谁控制,见下。
124
+ 2. **验证后模型**:如果加载时还需要转换(合并多个来源、派生字段、补默认值、语义校验),定义一个独立的内部类型,用类型安全的代码从原始 schema 转换成它。
125
+
126
+ ### 为什么分两层
127
+
128
+ - **不要手写大量逐字段构造。** 逐字段 `raw["a"]`、`raw.get("c", default)` 的解析代码冗长、易错、缺字段时行为靠 `get` 的默认值悄悄变化,而且没有任何 schema 线索。成熟校验库用类型声明形状,自动处理缺字段、类型转换、嵌套结构、错误聚合,改动 schema 时只改类型声明。
129
+ - **原始 schema = 我们依赖的外部形状的可追踪记录。** 外部数据(尤其是配置文件、第三方 API)有独立的演进生命周期,它的形状不一定等于你内部想用的形状。把"我们依赖的那部分外部形状"固化成一个类型,而不是散落在各处解析逻辑中,改动时才有唯一可对账的地方。加一个可空可选字段,就能清楚地表达"这个字段是新加的、外部可以没有、不破坏向后兼容"——兼容性意图直接体现在类型上。
130
+ - **转换层让外部形状与内部形状解耦。** 外部 schema 因为兼容性要保留历史字段、要允许宽松取值;内部模型为了好用要强类型、要派生字段、要合并多个来源。两层分开,外部 schema 演进不影响内部模型(只要转换函数跟上),内部模型重构不影响外部 schema(只要转换函数跟上)。转换函数是唯一的桥,改起来集中、可 review。
131
+ - **用类型安全的代码写转换,不用字典拼接。** 原始 schema 已经是 typed 对象,转换函数是 `RawConfig -> InternalConfig`,全程操作对象字段、有类型标注、能被类型检查器校验。比在 dict 之间拼凑再构造要安全得多。
132
+
133
+ ### 原始 schema 声明哪些字段:看 schema 归谁控制
134
+
135
+ 第 1 层该声明哪些字段,只取决于一件事:**这份数据的 schema 是我们定义的,还是上游控制的。** 配置文件、API 响应、消息、数据库行都只是这两种归属的具体实例——规则跟着归属走,不跟着数据来源的名字走。
136
+
137
+ 判断问句:**这份字段清单要由谁负责、由谁向人解释?**
138
+
139
+ **我们控制的 schema**——配置文件、我们自己定义并文档化的消息格式、写出去再读回来的持久化数据。这份清单是我们的承诺,所以**声明要完整**:支持的每一项都写出来,写配置的人拿它当功能列表,读代码的人拿它当"外部能保证提供什么"。声明了却不读的字段不要写,那是对外谎称"支持这一项"。
140
+
141
+ **上游控制的 schema**——第三方 API 响应、别的服务发来的消息、不由我们决定的数据库行。我们只是消费方,所以**只声明代码真正读取的字段**,其余一概不写。真实响应动辄几十个键:签名、内部 ID、上游昨天刚加的字段。全抄下来等于替别人维护一份会腐烂的文档,而且**多声明的字段会变成脆性来源**——上游改了一个我们从不读的字段(换类型、变 nullable、直接删掉),整个对象解析抛错,我们把上游每一次 schema 变动都变成自己的故障面。附带的好处是,这一层成了"我们依赖上游什么"的清单,review 时一眼看清。
142
+
143
+ 两种归属共用的规则:
144
+
145
+ - **未声明的键靠解析库默认忽略,不为此写任何配置。** 这个默认值对两边都恰好正确:上游一定会加我们从不读的字段;配置文件里的多余键要么键名拼错了,要么属于同一份文件里别的组件的段落,都轮不到 schema 来否决。给配置那一层配"未知键报错"看着能抓拼写错误,代价是把"还没升级的旧代码读带新键的配置"也变成启动失败——灰度、回滚、多个组件共用一份配置都依赖这个方向;而拼错的键本来就会以"某个行为没按预期发生"的形式暴露在运行日志里,比让进程起不来便宜得多。真要做未知键诊断,写成显式的 warning 检查,不要写成 schema 的硬约束。
146
+ - **字段少不等于可以省校验。** 声明出来的字段仍按第 2 节"数据合法性检查外推到系统边界"写约束;"可选"仍然表现为有默认值。
147
+ - **新增可选字段必须带默认值。** 否则旧配置文件会因为缺这个 key 而解析失败,破坏向后兼容。反过来,想从"可选"改成"必填"也要谨慎——原本可不带的旧数据会突然变非法。
148
+
149
+ 确有特殊需求时才偏离这两种默认做法,且把偏离的理由写进注释:
150
+
151
+ - **我们控制的配置需要读进来、改几个键再原样写回**(配置文件的读写往返工具):这时才需要显式持有未知键,不要为了"能存住"就逐个猜字段。更常见的解法是让每个组件只读写自己那一段,不做整文件往返。
152
+ - **上游数据要全字段落盘 / 审计**(数据本身就是产品):直接保留原始 dict 或原始 bytes,别把上游全部字段抄成类型——那还是一份会腐烂的文档,只是多了一层转换。
153
+ - **就是要主动探测上游新增字段**:显式配置"未知键报错",并在注释里写明这是"上游一动我就报错"的有意选择。
154
+ - **跨语言镜像结构不属于这两种归属**:那是我们自己两侧维护、两侧都读的契约,必须严格 1:1,写法见 `coding-style-ffi`。
155
+
156
+ ### 校验库当引擎,模型是长期契约
157
+
158
+ 校验的价值集中在"从原始数据到 typed 对象"这一步;对象一旦构造出来,后续传递靠的是语言原生的类型系统和普通容器。选校验库和用法时,优先"校验完产出/对应普通类型"的形态,不要让校验库的模型基类侵入整个数据模型——那是把外部 schema 的包袱带进内部。
159
+
160
+ 各语言的具体取舍见对应语言的 skill:Python 优先 stdlib dataclass + pydantic `TypeAdapter`(不用 `BaseModel`,字段级配置用 `Annotated` 注入),见 `coding-style-python`;TS 里 zod / typebox 的 schema 本身就是类型来源,见 `coding-style-typescript-javascript`。
161
+
162
+ ---
163
+
164
+ ## 4. 公共 API:参数放宽,返回收紧
165
+
166
+ 公共 API(会被其他模块、其他人调用的函数、方法、类)遵循一条不对称原则:**参数用能满足需求的最宽松类型,返回值用最精确的类型。**
167
+
168
+ - **参数从宽。** 声明参数时只要求"这个函数真正需要的东西":只需要遍历就收 `Iterable` / `Sequence` 而不是 `list`,只需要两个字段就收只含这两个字段的接口/Protocol 而不是完整实现类型,Go 里收 `io.Reader` 而不是 `*os.File`。调用方手里有什么就该能直接传进来,不为满足你的签名做无谓转换。
169
+ - **返回收紧。** 返回值用调用方能获得最多信息的具体类型:返回具体容器、具体 dataclass / struct,而不是宽泛接口、基类或弱类型。调用方要对返回值做最少的检查、得到最多的保证。
170
+
171
+ ### 为什么这样做
172
+
173
+ - **方向不对称是本质的。** 参数位置上,类型是"我们向调用方提的要求"——要求越少,API 越好用,能传的东西越多;返回位置上,类型是"我们向调用方给的承诺"——承诺越具体,调用方能做的越多、要防的越少。把两者写反(参数收窄、返回放宽)等于"要求多、给得少",两头都吃亏。
174
+ - **返回类型是调用方的依赖,参数类型是调用方的自由。** 返回太宽(接口、`Any`、裸字典),调用方被迫类型断言、字段猜测,实现一变调用方跟着炸;返回具体类型,实现内部怎么变,只要返回的契约还在,调用方无感。参数太窄(收 `list` 其实只需要 `Iterable`),每个调用方都为你的实现细节买单。
175
+ - **和第 1 节的关系:放宽 ≠ 放弃类型。** 参数从宽指的是"最小结构要求"(最小接口、宽容器类型、Protocol),不是 `Any` / 裸字典——传进来的数据仍然是有类型的,只是要求的形状最小。返回收紧也正是第 1 节"函数间传递用 typed 容器"在 API 边界上的体现。
176
+ - **内部私有函数不受此约束。** 这条原则约束的是有外部调用方的公共 API;模块内部的私有函数跟内部约定走,参数类型贴近实际用法即可,不必为假想的灵活性引入接口。
177
+
178
+ ### 各语言的典型形态
179
+
180
+ - **Go:** 经典表述 "accept interfaces, return structs"——参数收小接口,返回具体 struct。
181
+ - **Python:** 参数收 `Iterable[T]` / `Sequence[T]` / `Protocol`,返回具体 dataclass、具体容器;返回 `list[T]` 而不是 `Iterable[T]`(除非真是惰性序列)。
182
+ - **TypeScript:** 参数收最小 interface / 宽联合类型,返回具体类型,不返回 `object` / 宽泛 Record。
183
+
184
+ 语言侧细则在对应语言的 skill 里补充。
@@ -0,0 +1,14 @@
1
+ ---
2
+ name: coding-style-cpp
3
+ description: Use when 编写、生成、修改、编辑、重构或评审任何 C++ 代码——涵盖实现功能、修 bug、写测试、性能优化、代码迁移、重命名、加减参数或函数、修改函数签名、定义 struct / class、处理函数间数据流、JSON 反序列化、nlohmann、code review / PR review。任何会产出或改动 C++ 代码的任务(write / edit / refactor / review C++ code)都要先加载本 skill,即使用户没有提到风格、规范或最佳实践。只读调查不加载:单纯 debug、定位代码、定位问题、读代码理解逻辑等不改代码的任务不需要加载。
4
+ ---
5
+
6
+ # C++ 编码风格
7
+
8
+ **REQUIRED BACKGROUND:先加载 `coding-style`(shared)**——语言无关的原则与"为什么"在那里,章节序号与本文件对应;本文件只放 C++ 侧的具体写法。
9
+
10
+ > 状态:骨架,细则待补充。计划覆盖(与 shared 章节对应):
11
+ >
12
+ > 1. 函数边界容器:struct / aggregate(不用裸 `nlohmann::json` / `void*` 穿函数边界)。
13
+ > 2. 边界校验与"构造即合法":构造函数校验或工厂函数返回 expected / optional,枚举用 `enum class`。
14
+ > 3. JSON 反序列化分两层:原始 struct(nlohmann 宏忠实镜像外部形状)→ 内部 struct 转换;宏选型(`NON_INTRUSIVE` vs `WITH_DEFAULT`)的取舍见 `coding-style-ffi`。
@@ -0,0 +1,210 @@
1
+ ---
2
+ name: coding-style-ffi
3
+ description: Use when 处理任何跨语言 FFI 或跨语言数据传递——pybind11、nanobind 绑定(binding)、Python↔C++ 镜像结构、`SYNC` 契约注释、nlohmann JSON 跨边界序列化、`.pyi` stub 维护、跨语言类型映射。任何要在两种语言之间传递数据或写绑定的任务都要先加载本 skill,即使用户没有提到风格、规范或最佳实践。只读调查不加载:单纯 debug、定位或理解跨语言代码、而不写或改绑定/镜像结构代码的任务不需要加载。
4
+ ---
5
+
6
+ # Python ↔ C++ FFI:镜像结构与跨边界数据传递
7
+
8
+ **REQUIRED BACKGROUND:先加载 `coding-style`(shared)**——语言无关的原则与"为什么"在那里;本文件只放跨语言侧的具体写法。
9
+
10
+ 当同一份配置/事件结构要同时存在于 Python 和 C++,并通过 JSON 跨边界传递时,两侧必须是字段 1:1 的镜像,不能各自演化。用 `SYNC:` 注释把另一侧的定义位置写死;改字段必须一次提交改两侧。
11
+
12
+ ### Python 侧写法
13
+
14
+ ```python
15
+ from dataclasses import dataclass, field, asdict
16
+
17
+ # SYNC: frm_cta/sim_models.hpp::SimRunnerConfig
18
+ @dataclass(frozen=True, kw_only=True)
19
+ class SimRunnerConfig:
20
+ """镜像 C++ `frm_cta::SimRunnerConfig`。
21
+ Fields must stay 1:1 with the C++ struct; any change requires a matching update on both sides.
22
+ """
23
+ signal_path: str
24
+ sim_stats_path: str
25
+ sim_details_path: str
26
+ aum: float = 3e8
27
+ exec_cfg_dict: dict[str, SingleExecConfig] = field(default_factory=dict)
28
+ ```
29
+
30
+ 要点:
31
+
32
+ - `SYNC:` 行写完整路径(头文件相对路径或模块路径 + `::` + 类型名),让另一侧能被直接定位,不只是写个类型名。
33
+ - 紧跟一行说明"字段必须 1:1,改动要双侧同步"——这是契约的文字版,提醒任何修改者。
34
+ - 嵌套结构同样各自定义 dataclass,各自带 `SYNC:`,不要用裸 dict 代替嵌套对象(和 `coding-style` 第 1 节一致)。
35
+ - `asdict()` 会递归把嵌套 dataclass 摊平成 dict,正是 JSON 序列化需要的形态。
36
+
37
+ ### C++ 侧写法
38
+
39
+ 定义普通 `struct`,字段与 Python 1:1,用 nlohmann 的非侵入式宏注册字段,注释里写 `SYNC:` 指回 Python 侧:
40
+
41
+ ```cpp
42
+ #include <nlohmann/json.hpp>
43
+
44
+ namespace frm_cta {
45
+
46
+ // SYNC: py_frm_cta/sim_models.py::SimRunnerConfig
47
+ // Fields below must stay 1:1 with the Python dataclass; any field change requires a matching update on both sides.
48
+ // signal_path / sim_stats_path / sim_details_path 在 Python 侧无默认值(必填),
49
+ // 这里也不给类内默认值—保持"必填"语义两侧一致。
50
+ struct SimRunnerConfig {
51
+ std::string signal_path;
52
+ std::string sim_stats_path;
53
+ std::string sim_details_path;
54
+ double aum{3e8};
55
+ std::unordered_map<std::string, SingleExecConfig> exec_cfg_dict{};
56
+ };
57
+
58
+ // 注意:NON_INTRUSIVE 缺任意键(包括有默认值的 aum / exec_cfg_dict)都会抛异常,
59
+ // 与 Python 侧"缺键用默认值"不同。asdict 总会输出全部字段,正常路径不受影响;
60
+ // 若外部 JSON 可能缺可选字段,改用 WITH_DEFAULT 宏 + 必填字段存在性校验(宏的取舍见下方要点)。
61
+ NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(SimRunnerConfig, signal_path, sim_stats_path, sim_details_path, aum, exec_cfg_dict)
62
+
63
+ } // namespace frm_cta
64
+ ```
65
+
66
+ 要点:
67
+
68
+ - **默认值与 Python 侧对齐,包括"有没有默认值"。** Python 无默认值的字段(必填),C++ 侧也不给类内默认值;Python 有默认值的字段,C++ 侧给相同默认值。"必填 vs 可缺"是一种语义,两侧必须一致,否则 JSON 缺这个键时一侧抛错一侧静默用默认值,行为分叉。注意:在 `NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE` 下这些类内默认值只在手工构造时生效——JSON 缺任意键都会抛;要让缺键走默认值,用 `WITH_DEFAULT` 宏。
69
+ - 宏的选择跟着字段语义走:全部字段都有默认值 → `NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT`(缺键走默认值,前向兼容好);有必填字段 → `NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE`(缺键直接抛,匹配 Python 的"必填"行为)。一个结构里两者混用时,用 `WITH_DEFAULT` 并在解析后对必填字段做一次存在性校验,或拆成两个结构。不要为了统一用一个宏而给必填字段硬塞默认值——那会把"必填"悄悄降级成"可缺"。
70
+ - 每个 `SYNC` 结构都要注册宏,包括嵌套的——nlohmann 是按类型注册的,嵌套类型本身也得能被反序列化。
71
+ - 字段用 C++ 原生类型(`std::string`、`double`、`bool`、`std::int32_t` 等),不要用 nlohmann 的 `json` 类型当字段。
72
+
73
+ ### 传递数据
74
+
75
+ Python → C++:
76
+
77
+ ```python
78
+ import json
79
+ from dataclasses import asdict
80
+
81
+ # 构造 dataclass
82
+ runner_config = SimRunnerConfig(signal_path="...", sim_stats_path="...", ...)
83
+
84
+ # asdict 递归摊平成 dict,再 json.dumps 成字符串跨边界
85
+ config_json = json.dumps(asdict(runner_config))
86
+
87
+ # 传给 C++ 侧(具体 FFI 调用取决于你的绑定层)
88
+ container = strategy_api.get_cpp_strategy_container_v2(
89
+ strategy_config=asdict(runner_config), # 绑定层内部会做 json.dumps
90
+ strategy_class_name="FrmCtaRunner",
91
+ )
92
+ container.trigger(asdict(OnTriggerEvent(...))) # 同理
93
+ ```
94
+
95
+ C++ 侧收到 JSON 字符串后反序列化:
96
+
97
+ ```cpp
98
+ nlohmann::json j = nlohmann::json::parse(json_str);
99
+ SimRunnerConfig cfg = j.get<SimRunnerConfig>(); // 靠宏注册的字段映射
100
+ ```
101
+
102
+ 反向(C++ → Python)不走 JSON,见下方"返回方向"一节。
103
+
104
+ ### 返回方向:用 pybind11/nanobind 绑定 class + `.pyi`
105
+
106
+ 上面讲的都是 Python → C++(入站):Python 是数据的构造方,用 stdlib dataclass 组装好、`asdict` → JSON → C++ 侧 nlohmann 反序列化。Python 侧写起来 ergonomic、对类型检查友好。
107
+
108
+ **C++ → Python(出站)方向不对称,不要走 JSON。** C++ 已经持有对象,再序列化成字符串让 Python 反序列化是纯浪费。出站直接用 pybind11/nanobind 把 C++ `struct`/`class` 绑成一个 Python class 返回,Python 拿到的是带类型的活对象,不拷贝不序列化。
109
+
110
+ 两个返回场景都用 binding + `.pyi`,区别只是 class 里有没有方法:
111
+
112
+ - **带行为的对象**(比如 C++ 实现的 `Reader`、`Container`):绑定时 `.def("read", &Reader::read)` 暴露方法,Python 调用的是 C++ 真实方法,对象本身在 C++ 侧,不拷数据出来。
113
+ - **纯数据**(比如 `struct { std::string name; std::int64_t id; }`):绑定时只有 `def_readonly` 暴露字段,没有方法。Python 拿到的是一个带类型的只读对象,等价于一个 frozen dataclass,但不需要序列化往返。
114
+
115
+ 例子(纯数据):
116
+
117
+ ```cpp
118
+ // config_models.h
119
+ struct Data {
120
+ std::string name;
121
+ std::int64_t id;
122
+ };
123
+ ```
124
+
125
+ ```cpp
126
+ // binding.cpp
127
+ #include <pybind11/pybind11.h>
128
+ #include "config_models.h"
129
+
130
+ namespace py = pybind11;
131
+
132
+ PYBIND11_MODULE(my_mod, m) {
133
+ py::class_<Data>(m, "Data")
134
+ .def_readonly("name", &Data::name)
135
+ .def_readonly("id", &Data::id);
136
+ }
137
+ ```
138
+
139
+ ```python
140
+ # my_mod.pyi
141
+ # SYNC: config_models.h::Data(对应 binding.cpp 中 py::class_<Data>)
142
+ class Data:
143
+ name: str # SYNC: config_models.h::Data::name(C++ std::string)
144
+ id: int # SYNC: config_models.h::Data::id(C++ std::int64_t)
145
+ ```
146
+
147
+ 带方法的 Reader 同理,多加一行 `.def("read", &Reader::read)`,`.pyi` 里方法签名同样加 `SYNC` 注释指回 C++ 方法:
148
+
149
+ ```python
150
+ # my_mod.pyi
151
+ # SYNC: reader.h::Reader(对应 binding.cpp 中 py::class_<Reader>)
152
+ class Reader:
153
+ def read(self, path: str) -> Data: ... # SYNC: reader.h::Reader::read
154
+ id: int # SYNC: reader.h::Reader::id
155
+ ```
156
+
157
+ **要点:**
158
+
159
+ - **`.pyi` + binding + C++ struct 三方同步。** 改 C++ struct 字段/方法时,binding 代码和 `.pyi` 必须同提交一起改。`.pyi` 的类型标注没有运行时校验兜底:mypy/pyright 只信 `.pyi`、不知道 C++ struct 长什么样,字段漏改时类型检查照样通过,直到运行时访问才 `AttributeError`——同步只能靠注释纪律。在每个 class 定义旁加 `SYNC:` 注释指回 C++ struct 和 binding 代码(如示例中的 `# SYNC: config_models.h::Data`),review 时一眼能发现"只改了一侧"。和入站的 JSON 镜像同一套纪律,只是同步的是 binding 而非 JSON schema。
160
+ - **注释分两级:class 级定位,字段/方法级防漂移。** class 旁的 `SYNC` 让 review 时能定位"这个类对应 C++ 哪里";字段/方法级的 `SYNC` 把改名、改类型这类单点漂移也标出来。类型映射不是显然 1:1 的位置(参数、返回值、有转换的字段)尤其值得写注释,否则改 C++ 侧时不知道 `.pyi` 哪里要跟着改。字段很多的 struct 不必每条都写,但命名不一致、类型有转换的字段必须写。
161
+ - **只读字段用 `def_readonly`,不用 `def_readwrite`。** 呼应"frozen 优先":能不让 Python 改就别让改。Python 侧拿到的是 C++ 对象的视图,允许写入反而引入"C++ 对象被 Python 改了"的耦合,破坏不可变语义。
162
+ - **`.pyi` 是类型检查器看到的契约。** Python 运行时拿到的对象类型由 binding 决定,`.pyi` 只给 mypy/pyright 看。两者必须一致——字段名、字段类型、方法签名都要对得上 binding,否则类型检查通过但运行时 AttributeError。
163
+ - **入站用 JSON、出站用 binding,不要对称化。** 反对称是刻意的:入站 Python 是构造方,dataclass + JSON 让 Python 侧写起来自然、类型检查友好;出站 C++ 是持有方,binding 直接暴露对象、避免无谓序列化。别为了"对称"强行让入站也走 binding(Python 侧构造 C++ 对象 ergonomic 差)或出站也走 JSON(白白多一次序列化)。
164
+ - **什么时候出站也走 JSON:** 几乎没有。除非返回值要被 Python 序列化存盘/转发(这时 C++ 直接 `j.dump()` 给个字符串更省事),或绑定一个只读数据 struct 的成本(写 binding + .pyi)相比字段数确实不划算,否则 binding 总是更优。
165
+
166
+ 这里虽然要写的代码多,但是复杂度并不高,反而提高了可维护性。
167
+
168
+ ### 类型映射约定
169
+
170
+ 两侧类型要对上,以下是常用映射,保持项目内一致:
171
+
172
+ | Python | C++ | 说明 |
173
+ | --------------------------- | ------------------------------------ | --------------------------------------------------- |
174
+ | `str` | `std::string` | |
175
+ | `bool` | `bool` | |
176
+ | `int` | `std::int32_t` / `std::int64_t` | 按数值范围选宽度,C++ 侧显式写宽度,别用裸 `int` |
177
+ | `float` | `double` | C++ 侧默认用 `double`,不用 `float`,除非有明确理由 |
178
+ | `list[T]` | `std::vector<T>` | |
179
+ | `dict[str, V]` | `std::unordered_map<std::string, V>` | |
180
+ | `T \| None` / `Optional[T]` | `std::optional<T>` | nlohmann 需要包含 optional 适配头 |
181
+ | 嵌套 dataclass | 嵌套 struct | 两侧都需注册/定义 |
182
+
183
+ ### 维护纪律
184
+
185
+ `SYNC` 注释是契约,不是装饰,要让它真正起作用:
186
+
187
+ - **改一侧必须改另一侧。** 加字段、删字段、改字段类型、改字段名——Python 和 C++ 两处必须同一次提交一起改。`SYNC` 注释的存在就是为了让 review 时能发现"只改了一侧"。
188
+ - **字段顺序保持一致。** JSON 是按键名匹配的,顺序不影响解析,但两侧字段顺序对齐能让 diff 和人工对照更容易,降低看错的概率。
189
+ - **默认值两侧对齐。** Python dataclass 字段默认值和 C++ struct 字段默认值必须一致,否则 JSON 里缺这个字段时两侧得到的值不一样,埋下隐性分歧。详见下一节"默认值与缺字段语义"。
190
+ - **不要在镜像结构里塞单侧才有的字段。** 如果某字段只有 Python 用、C++ 不需要,不要塞进 `SYNC` 结构——另起一个非镜像的 dataclass。镜像结构必须严格 1:1,否则 `SYNC` 契约失效。
191
+ - **命名一致。** 同一个字段 Python 叫 `trading_day`,C++ 就别叫 `tradingDay`,否则 JSON 键对不上,反序列化静默失败或落到默认值。蛇形命名跨两侧通常最省心。
192
+
193
+ ### 默认值与缺字段语义
194
+
195
+ 镜像结构两侧默认值不一致是最隐蔽的跨语言 bug:JSON 里某个字段缺失时(老数据、部分构造、上游没填),Python 和 C++ 各自用自己的默认值补,两边拿到不同的对象继续往下跑,行为分叉却很难发现。前提:**"缺键补默认值"只在 C++ 用 `NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT` 宏时存在;`NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE` 缺任意键(包括有默认值的字段)都会直接抛**。规则:
196
+
197
+ **1. 默认值两侧字面一致。** Python 写 `aum: float = 3e8`,C++ 就写 `double aum{3e8};`,不能一侧 `3e8` 另一侧 `0`。这意味着"有默认值的字段"在两侧都要有默认值、"必填字段"在两侧都无默认值——否则一侧必填一侧可缺,JSON 缺字段时行为就分叉。
198
+
199
+ **2. 区分"空容器"和"缺省(None/optional)",两侧语义要对齐。** 这是跨语言最容易错的地方:
200
+
201
+ - Python `list[T] = field(default_factory=list)`(缺省 = 空列表)↔ C++ `std::vector<T>{}`(缺省 = 空 vector)。语义:字段存在但为空。
202
+ - Python `T | None = None`(缺省 = 不存在)↔ C++ `std::optional<T>`(缺省 = `std::nullopt`)。语义:字段可能不存在,存在时才有值。
203
+
204
+ 不要混用这两种语义。同一个字段,Python 用 `None` 默认值而 C++ 用空容器默认值(或反过来),会让 JSON 里没这个键时一侧解释成"空"、一侧解释成"不存在",下游逻辑分歧。选好一个语义,两侧都按它对齐。
205
+
206
+ **3. mutable 默认值用 `field(default_factory=...)`。** 这是 dataclass 的硬性要求,不限于跨语言场景,但镜像结构里尤其要检查——Python 直接写 `x: list[str] = []` 会触发共享可变默认值的坑,C++ 侧没有对应概念但容易在对齐时被忽略。容器/字典/可变嵌套 dataclass 一律用 `field(default_factory=...)`。
207
+
208
+ **4. C++ 侧缺省值的有无和取值都要与 Python 对齐。** "必填字段"在 Python 侧没有默认值,C++ 侧就不要给类内默认值——否则 JSON 里缺这个键时 Python 构造抛 `TypeError`、C++ 却静默用默认值,行为分叉。只对 Python 有默认值的字段给 C++ 类内默认值,且取值字面一致。注意 `WITH_DEFAULT` 宏的一个坑:对没有类内默认值的字段,缺键时会保留字段的默认构造值——裸 `double aum;` 是未定义值,读取即 UB。所以必填字段要么不给默认值并配非 `WITH_DEFAULT` 宏(缺键直接抛),要么显式 `double aum{};` 零初始化并在解析后自行校验存在性。
209
+
210
+ **5. 改默认值要两侧同提交改。** 默认值是契约的一部分,不只是结构形状。把某字段默认值从 `3e8` 改成 `5e8`,Python 和 C++ 必须一起改,否则"没显式传该字段的旧调用方"两侧行为悄悄分叉。`SYNC` 注释覆盖的不只是字段增删改名,也包括默认值变更。
@@ -0,0 +1,15 @@
1
+ ---
2
+ name: coding-style-golang
3
+ description: Use when 编写、生成、修改、编辑、重构或评审任何 Go 代码——涵盖实现功能、修 bug、写测试、性能优化、代码迁移、重命名、加减参数或函数、修改函数签名、定义 struct、处理函数间数据流、JSON 反序列化、code review / PR review。任何会产出或改动 Go 代码的任务(write / edit / refactor / review Go code)都要先加载本 skill,即使用户没有提到风格、规范或最佳实践。只读调查不加载:单纯 debug、定位代码、定位问题、读代码理解逻辑等不改代码的任务不需要加载。
4
+ ---
5
+
6
+ # Go 编码风格
7
+
8
+ **REQUIRED BACKGROUND:先加载 `coding-style`(shared)**——语言无关的原则与"为什么"在那里,章节序号与本文件对应;本文件只放 Go 侧的具体写法。
9
+
10
+ > 状态:骨架,细则待补充。计划覆盖(与 shared 章节对应):
11
+ >
12
+ > 1. 函数边界容器:struct(构造函数 `NewXxx`、值 vs 指针接收者对数据流的影响、不用 `map[string]any` / `any` 穿函数边界)。
13
+ > 2. 边界校验与"构造即合法":构造时返回 error、枚举用自定义类型 + 受限常量集合(`type Side string` + `SideBuy`/`SideSell`)。
14
+ > 3. JSON 反序列化分两层:`encoding/json` 的原始 struct(tag 忠实镜像外部形状)→ 内部 struct 转换,或第三方校验库选型。
15
+ > 4. 公共 API:"accept interfaces, return structs"——参数收小接口,返回具体 struct。
@@ -0,0 +1,468 @@
1
+ ---
2
+ name: coding-style-python
3
+ description: Use when 编写、生成、修改、编辑、重构或评审任何 Python 代码——涵盖实现功能、修 bug、写脚本、写测试、性能优化、代码迁移、重命名、加减参数或函数、修改函数签名、定义类型、dataclass、处理函数间数据流、JSON 反序列化、pydantic、code review / PR review。任何会产出或改动 Python 代码的任务(write / edit / refactor / review Python code)都要先加载本 skill,即使用户没有提到风格、规范或最佳实践。只读调查不加载:单纯 debug、定位代码、定位问题、读代码理解逻辑等不改代码的任务不需要加载。
4
+ ---
5
+
6
+ # Python 编码风格
7
+
8
+ **REQUIRED BACKGROUND:先加载 `coding-style`(shared)**——语言无关的原则与"为什么"在那里,章节序号与本文件对应;本文件只放 Python 侧的具体写法。
9
+
10
+ ---
11
+
12
+ ## 1. 函数边界容器:dataclass 与 NamedTuple
13
+
14
+ ### 什么时候用 dataclass,什么时候用 NamedTuple
15
+
16
+ 两者都能满足"显式类型 + 字段命名"的核心要求,并且都默认不可变(dataclass 按本 skill 一律带 `frozen=True`,见下文同名小节),所以**可变性不是判别依据**——数据会变就用 `dataclasses.replace` 产出新对象。**默认用 dataclass**:它未来加字段、方法、默认值的阻力最小;NamedTuple 只在有特定理由时才用(见下)。
17
+
18
+ **默认用 `dataclass(frozen=True, kw_only=True)`:**
19
+
20
+ ```python
21
+ from dataclasses import dataclass, field
22
+
23
+ @dataclass(frozen=True, kw_only=True)
24
+ class Position:
25
+ symbol: str
26
+ quantity: int
27
+ avg_price: float
28
+ realized_pnl: float = 0.0
29
+ tags: list[str] = field(default_factory=list)
30
+
31
+ def market_value(self, price: float) -> float:
32
+ return self.quantity * price
33
+ ```
34
+
35
+ 特点:不可变(配合 `dataclasses.replace` 表达状态变化)、支持默认值、可挂方法、可继承。适合带方法、带默认值的对象——账户、持仓、配置、累加器。加字段、加方法、加默认值的阻力最小,所以是默认选择。
36
+
37
+ **`NamedTuple` 只在有特定理由时用:**
38
+
39
+ ```python
40
+ from typing import NamedTuple
41
+
42
+ class Point(NamedTuple):
43
+ x: float
44
+ y: float
45
+
46
+ class TradeKey(NamedTuple):
47
+ symbol: str
48
+ side: str # "buy" / "sell"
49
+ trading_day: int
50
+ ```
51
+
52
+ 特定理由:
53
+
54
+ - **序列化语义要求 JSON array。** NamedTuple 是 `tuple` 子类,序列化库会把它转成数组;dataclass 的 `asdict()` 产出的是对象。跨边界协议约定要数组时,用 NamedTuple。
55
+ - **需要位置访问/解包。** 调用方要 `p[0]`、`x, y = p` 这种用法时(见本节末尾的注意)。
56
+ - **内存/性能敏感的大批量小记录。** NamedTuple 带 `__slots__` 语义,比 dataclass 省内存。
57
+
58
+ 特点:不可变(天然线程安全、可哈希、可作 dict key)、内存占用小。适合那些"本质是一个带名字的元组"的记录型数据——一旦创建就不该改,改了就语义上是另一个值。
59
+
60
+ **一句话判断:** 没有特定理由就用 dataclass(`frozen=True, kw_only=True`);只有上面列出的理由成立时才用 NamedTuple。不确定时一律 dataclass。dataclass 的默认参数配置见下文"默认用 `frozen=True, kw_only=True`"。
61
+
62
+ **`frozen=True` dataclass 是 NamedTuple 的可扩展替代:** 想要不可变但又预见到以后要加方法/默认值,用 `@dataclass(frozen=True)`,不要硬上 NamedTuple 然后受困于它的局限。
63
+
64
+ **NamedTuple 的位置访问与解包:** `p[0]`、`x, y = p` 是 NamedTuple 的事实特性,在字段少、上下文明确时是便利;但字段多或跨函数传递时优先用属性名——字段顺序变化时解包不会报错,只会静默错位。构造 NamedTuple 时始终用关键字参数(`ParsedOrder(symbol=..., quantity=..., price=...)`),别按位置传参。
65
+
66
+ ### 反模式
67
+
68
+ 下面这些写法都是函数边界上的 `Any` 传递,应该替换成 typed 容器。
69
+
70
+ #### 反模式 1:裸 dict 作为参数 / 返回值
71
+
72
+ ```python
73
+ # 不要这样
74
+ def fetch_position(symbol: str) -> dict:
75
+ return {"symbol": symbol, "quantity": 100, "avg_price": 12.5}
76
+
77
+ def summarize(pos: dict) -> str:
78
+ return f"{pos['symbol']}: {pos['quanitity']}" # 拼写错误,运行时才炸
79
+ ```
80
+
81
+ ```python
82
+ # 这样写
83
+ @dataclass(frozen=True, kw_only=True)
84
+ class Position:
85
+ symbol: str
86
+ quantity: int
87
+ avg_price: float
88
+
89
+ def fetch_position(symbol: str) -> Position:
90
+ return Position(symbol=symbol, quantity=100, avg_price=12.5)
91
+
92
+ def summarize(pos: Position) -> str:
93
+ return f"{pos.symbol}: {pos.quantity}" # 拼写错误,类型检查直接报
94
+ ```
95
+
96
+ #### 反模式 2:`dict[str, Any]` / `list[dict]` 作为函数签名
97
+
98
+ `dict[str, Any]` 本质就是"一个我想不清楚形状的结构"。如果这个结构在多个函数间流转,它就是一个匿名 dataclass,请给它起名字。
99
+
100
+ ```python
101
+ # 不要这样
102
+ def enrich_trades(trades: list[dict[str, Any]]) -> list[dict[str, Any]]:
103
+ ...
104
+ ```
105
+
106
+ ```python
107
+ # 这样写
108
+ @dataclass(frozen=True, kw_only=True)
109
+ class Trade:
110
+ symbol: str
111
+ price: float
112
+ quantity: int
113
+ timestamp: int
114
+
115
+ @dataclass(frozen=True, kw_only=True)
116
+ class EnrichedTrade:
117
+ trade: Trade
118
+ notional: float
119
+ venue: str
120
+
121
+ def enrich_trades(trades: list[Trade]) -> list[EnrichedTrade]:
122
+ ...
123
+ ```
124
+
125
+ #### 反模式 3:裸 tuple 返回多值
126
+
127
+ ```python
128
+ # 不要这样:返回元组,调用方得数位置,改返回结构时所有调用方都得改
129
+ def parse_order(s: str) -> tuple[str, int, float]:
130
+ symbol, qty, price = s.split(",")
131
+ return symbol, int(qty), float(price)
132
+ ```
133
+
134
+ ```python
135
+ # 这样写
136
+ @dataclass(frozen=True, kw_only=True)
137
+ class ParsedOrder:
138
+ symbol: str
139
+ quantity: int
140
+ price: float
141
+
142
+ def parse_order(s: str) -> ParsedOrder:
143
+ symbol, qty, price = s.split(",")
144
+ return ParsedOrder(symbol=symbol, quantity=int(qty), price=float(price))
145
+ ```
146
+
147
+ 如果你确有理由用 NamedTuple(比如序列化时要求 JSON array),从裸 tuple 迁移到 NamedTuple 的成本几乎为零——调用方 `symbol, qty, price = parse_order(s)` 和 `order.symbol` 两种写法都成立,但拿到了命名和类型。没有这类理由时,默认用 dataclass(见上)。
148
+
149
+ #### 反模式 4:`Any` 显式标注的参数
150
+
151
+ ```python
152
+ # 不要这样
153
+ def transform(data: Any) -> Any:
154
+ ...
155
+ ```
156
+
157
+ `Any` 等于"我放弃对这段数据做任何约束"。如果 `data` 真的可以是任意类型,那通常说明这个函数职责太宽,应该拆分或用泛型(`TypeVar`);如果 `data` 实际上有固定形状,就给它一个类型。
158
+
159
+ 这个反模式不包括序列化/反序列化/校验函数:它们本来就该是"从 `Any` 到 typed 容器"(把外部原始数据解析进内部)或"从 typed 容器到 `Any`"(序列化输出),`Any` 只出现在边界、不参与内部流转,符合 `coding-style` 第 1 节"合理的例外"里"与外部边界交互"一条。
160
+
161
+ ```python
162
+ # 泛型版本:当函数对多种具体类型做同样的操作
163
+ from typing import TypeVar, Type
164
+ T = TypeVar("T")
165
+
166
+ def clone(data: T) -> T:
167
+ ...
168
+
169
+ # 或者:当 data 有固定形状,就给它命名
170
+ @dataclass(frozen=True, kw_only=True)
171
+ class TransformInput:
172
+ ...
173
+
174
+ def transform(data: TransformInput) -> TransformResult:
175
+ ...
176
+ ```
177
+
178
+ #### 反模式 5:`**kwargs: Any` 收口不确定参数
179
+
180
+ ```python
181
+ # 不要这样:调用方完全不知道能传什么,实现方完全不知道会收到什么
182
+ def run_strategy(config: dict[str, Any], **params: Any) -> Any:
183
+ ...
184
+ ```
185
+
186
+ ```python
187
+ # 这样写
188
+ @dataclass(frozen=True, kw_only=True)
189
+ class StrategyConfig:
190
+ lookback: int
191
+ threshold: float
192
+ symbols: list[str]
193
+
194
+ def run_strategy(config: StrategyConfig) -> StrategyResult:
195
+ ...
196
+ ```
197
+
198
+ 如果确实需要"可选、可扩展"的参数传递(比如插件式配置),优先用嵌套 dataclass 或 `kw_only` dataclass,而不是 `**kwargs: Any` 黑洞。
199
+
200
+ **兼容性场景可以保留。** 公共 API 演进时,为了不破坏老调用方——他们可能还传着已废弃的参数——保留 `**kwargs: Any` 吸收未知参数是合理的。但已知参数仍然用显式参数接收,`**kwargs` 只负责接住并忽略旧参数,不作为新功能的入口。
201
+
202
+ ### dataclass 默认用 `frozen=True, kw_only=True`
203
+
204
+ (`kw_only` 需要 Python 3.10+,本 skill 默认在此版本之上,不做旧版本兼容。)
205
+
206
+ 定义 dataclass 时,默认带上这两个参数:
207
+
208
+ ```python
209
+ from dataclasses import dataclass
210
+
211
+ @dataclass(frozen=True, kw_only=True)
212
+ class Position:
213
+ symbol: str
214
+ quantity: int
215
+ avg_price: float
216
+ ```
217
+
218
+ #### 为什么 `kw_only=True`
219
+
220
+ 位置参数是隐式契约,而且是个脆弱的契约。
221
+
222
+ - **防同类型字段传错位置。** `Position("AAPL", 100, 12.5)` 里字段顺序错了,类型检查只有在类型不匹配时才报;如果两个字段类型相同(比如 `x: float, y: float`),位置传反了类型检查发现不了,运行时也不报,语义就静默错了。`kw_only` 强制 `Position(symbol="AAPL", quantity=100, avg_price=12.5)`,字段名显式出现在调用点,传错立刻可见。
223
+ - **字段增删/重排不破坏调用方。** 位置参数的 dataclass 一旦加字段、删字段、调顺序,所有按位置构造的调用点都得改,而且类型检查未必能全兜住(尤其是同类型字段)。`kw_only` 让字段顺序变得无关,加字段只要给默认值就不影响老调用方。
224
+ - **可读性。** 构造点自带字段名,读到 `Position(symbol=..., quantity=..., avg_price=...)` 不用跳回定义就知道每个值的含义。
225
+
226
+ #### 为什么 `frozen=True`
227
+
228
+ 可变性是 dataclass 默认行为里最容易引入 bug 的一个。
229
+
230
+ - **防止共享数据被意外修改。** 一个 `Position` 对象穿过 fetch → enrich → persist 三个函数,中间某个函数顺手 `pos.quantity += 10` 改了它,上游调用方如果还持有引用,看到的数据就变了,而且毫无痕迹。`frozen` 让这种修改在赋值时直接抛 `FrozenInstanceError`,而且 mypy / pyright 在静态检查时就会对这类赋值报错(`Cannot assign to attribute ...`)——错误在编译期就暴露,不用等到运行时;运行时异常则是最后一道兜底。双重保障逼你显式"构造一个新对象"来表达状态变化——这正是数据流清晰的写法,也和 `coding-style` 第 1 节"函数间传递的是值不是状态"的理念一致。
231
+ - **可哈希。** `frozen` dataclass 在字段都可哈希时默认可哈希,能当 dict key、放 set、做缓存键。可变 dataclass 不行。
232
+ - **并发安全。** 不可变对象天生线程安全,跨函数、跨线程传递时不用担心竞态。
233
+
234
+ 注意:`frozen` 冻结的是属性赋值,不冻结容器字段的**内容**——`pf.positions["X"] = pos` 照样能改 dict,不会抛 `FrozenInstanceError`。需要整体不可变时,容器字段用 `tuple` / `frozenset`;否则接受该字段可变,状态变化通过 `replace` + 重建容器表达(见下方示例),而不是就地改容器内容。
235
+
236
+ #### "状态会变"不是放开 frozen 的理由
237
+
238
+ 最常见的放开 `frozen` 的冲动是"这个对象的状态会变"——累积器、随事件增长的状态、逐步填充的构建器。但这恰恰是应该坚持 frozen 的场景,因为 `dataclasses.replace` 让"用新对象表达状态变化"几乎没有代价:
239
+
240
+ ```python
241
+ from dataclasses import replace
242
+
243
+ @dataclass(frozen=True, kw_only=True)
244
+ class Portfolio:
245
+ positions: dict[str, Position] = field(default_factory=dict)
246
+ realized_pnl: float = 0.0
247
+
248
+ # "更新"状态 = 产出新对象,原对象不变
249
+ def add_position(pf: Portfolio, pos: Position) -> Portfolio:
250
+ return replace(pf, positions={**pf.positions, pos.symbol: pos})
251
+
252
+ def realize(pf: Portfolio, pnl: float) -> Portfolio:
253
+ return replace(pf, realized_pnl=pf.realized_pnl + pnl)
254
+ ```
255
+
256
+ ### 与类型检查工具配合
257
+
258
+ 本 skill 的价值依赖类型检查器把守边界。实操建议:
259
+
260
+ - 函数签名上必须有返回类型标注。无返回类型标注的函数,调用方拿到的就是 `Any`,typed 容器的传递链就断了。哪怕函数体很难标全,先把签名标上。
261
+ - 在 `mypy` / `pyright` 配置里启用 `disallow_untyped_defs` 或等价选项,让无标注的函数签名在 CI 里报错,从制度上阻止 `Any` 回潮。
262
+ - 对 `dict[str, Any]` 这类签名,可以配 `warn_return_any` 让它显眼。
263
+
264
+ 类型检查工具是本 skill 的执行机构;skill 定义规范意图,工具把意图变成可强制检查的约束。
265
+
266
+ ---
267
+
268
+ ## 2. 边界校验与"构造即合法"的 Python 写法
269
+
270
+ 原则见 `coding-style` 第 2 节;下面是各场景的 Python 代码。
271
+
272
+ **场景一:把已经构造好的结构化数据在类型上确定下来。** 有 pydantic 这类成熟校验库时,从 dict 一步解析,不手写逐字段检查:
273
+
274
+ ```python
275
+ from typing import Annotated
276
+ from pydantic import Field, TypeAdapter
277
+
278
+ # 边界:解析外部配置
279
+ @dataclass(frozen=True, kw_only=True)
280
+ class Position:
281
+ symbol: str
282
+ quantity: Annotated[int, Field(ge=0)] # 内部约定:>= 0,pydantic 在边界强制执行
283
+ avg_price: Annotated[float, Field(gt=0)] # 内部约定:> 0
284
+
285
+ _PositionAdapter: TypeAdapter[Position] = TypeAdapter(Position)
286
+
287
+ def parse_position(raw: dict[str, Any]) -> Position:
288
+ # 在边界一次性校验,不合法就抛 ValidationError,不让坏数据进入内部
289
+ return _PositionAdapter.validate_python(raw)
290
+
291
+ # 内部:信任 Position 已经合法,不再重复校验
292
+ def market_value(pos: Position, price: float) -> float:
293
+ return pos.quantity * price # 不写 if pos.quantity < 0: raise
294
+ ```
295
+
296
+ **能由类型保证的,就不要靠运行时检查:**
297
+
298
+ ```python
299
+ # 不要:靠约定 + 运行时检查保证取值合法
300
+ @dataclass(frozen=True, kw_only=True)
301
+ class Order:
302
+ side: str # 约定只能是 "buy"/"sell",但 str 类型本身不保证
303
+ # ... 每个用到的地方都 if order.side not in ("buy", "sell"): raise
304
+
305
+ # 这样:用枚举把"非法取值"变成"构造不出来"
306
+ from enum import Enum
307
+ class Side(Enum):
308
+ BUY = "buy"
309
+ SELL = "sell"
310
+
311
+ @dataclass(frozen=True, kw_only=True)
312
+ class Order:
313
+ side: Side # 传非法值类型检查器直接在调用点报错(arg-type),后续免检
314
+ ```
315
+
316
+ **场景二:自己拼接结构化数据——先逐个构造字段值,最后拼装成对象:**
317
+
318
+ ```python
319
+ # 不要:构造时数据可能非法,指望后面修正
320
+ order = Order(side="maybe_invalid", quantity=-1)
321
+ normalize(order) # 事后修正,万一没调用到呢?提前 return 呢?
322
+
323
+ # 要:每个值独立构造/校验,最后纯组合
324
+ def make_order(raw: dict) -> Order:
325
+ side = Side(raw["side"]) # 非法值在这一步就抛
326
+ quantity = _validate_quantity(raw["quantity"])
327
+ price = _validate_price(raw["price"])
328
+ return Order(side=side, quantity=quantity, price=price)
329
+ ```
330
+
331
+ ---
332
+
333
+ ## 3. JSON 反序列化分两层(pydantic TypeAdapter + stdlib dataclass)
334
+
335
+ 原则见 `coding-style` 第 3 节;Python 侧的选型与写法如下。
336
+
337
+ ### 优先用 stdlib dataclass + pydantic 校验引擎,而不是 BaseModel
338
+
339
+ 两层都用 stdlib `@dataclass`:原始 schema 也是 `@dataclass`,pydantic 只作为校验引擎在解析瞬间起作用,校验完产出的是普通 stdlib dataclass 实例。不要用 pydantic `BaseModel` 做原始 schema,除非有具体理由。
340
+
341
+ 为什么优先 stdlib dataclass:
342
+
343
+ - **两层一致。** 原始 schema 和内部模型都是 stdlib dataclass,同样的 `frozen`/`kw_only`/`field(default_factory=...)` 规则适用,读代码不用在两套类型系统之间切换。`BaseModel` 有自己的构造、继承、`model_config`、`model_dump` 等语义,混进来增加心智负担。
344
+ - **校验是瞬时动作,类型是长期契约。** pydantic 的价值集中在"从 dict 到 typed 对象"这一步;对象一旦构造出来,后续传递靠的是 stdlib dataclass 的类型标注,不需要 pydantic 的运行时开销和方法。用 `TypeAdapter` 把 pydantic 当工具调用,而不是让 `BaseModel` 污染整个数据模型。
345
+ - **字段级 pydantic 配置用 `Annotated` 注入。** 需要别名、约束等 pydantic 特有配置时,用 `Annotated[T, pydantic.Field(alias=...)]` 写在类型标注上,不引入 `BaseModel`。类型标注仍然是 stdlib 类型,pydantic 元数据是附加层,mypy/IDE 仍然按 stdlib 类型理解。
346
+
347
+ 什么时候才用 `BaseModel`:需要 pydantic 的高级特性(ORM 模式、`model_dump` 的复杂序列化控制、字段方法、动态模型生成等),且 stdlib dataclass + `TypeAdapter` 无法覆盖。这是少数情况,多数"校验 dict → 对象"的需求 `TypeAdapter` 已经够用。
348
+
349
+ ### 怎么做
350
+
351
+ ```python
352
+ from dataclasses import dataclass, field
353
+ from pathlib import Path
354
+ from typing import Annotated
355
+ import json
356
+ from pydantic import Field, TypeAdapter
357
+
358
+ # — 第 1 层:原始 schema,1:1 对应配置文件形状(stdlib dataclass)—
359
+ @dataclass(frozen=True, kw_only=True)
360
+ class RawStrategyConfig:
361
+ """忠实镜像配置文件 schema。字段与文件里的键 1:1 对应,不做业务转换。"""
362
+ lookback: int
363
+ # 外部键用 snake_case 以外的写法时,用 Annotated 注入 pydantic alias,
364
+ # 类型标注仍是 stdlib int,不引入 BaseModel
365
+ threshold: Annotated[float, Field(alias="thresh")]
366
+ symbols: list[str]
367
+ # 新加字段:用默认值表达"外部可以没有",兼容旧配置文件
368
+ venue: str | None = None
369
+ fee_bps: float = 0.0
370
+
371
+ _RawAdapter: TypeAdapter[RawStrategyConfig] = TypeAdapter(RawStrategyConfig)
372
+
373
+ def load_raw_config(path: str) -> RawStrategyConfig:
374
+ raw = json.loads(Path(path).read_text())
375
+ # pydantic 按类型标注校验、类型转换、错误聚合,返回 stdlib dataclass 实例
376
+ return _RawAdapter.validate_python(raw)
377
+
378
+ # — 第 2 层:内部模型,加载时转换得到 —
379
+ @dataclass(frozen=True, kw_only=True)
380
+ class StrategyConfig:
381
+ lookback: int
382
+ threshold: float
383
+ symbols: tuple[str, ...] # 内部要不可变,转成 tuple
384
+ venue: str # 内部不接受 None,转换时给默认/报错
385
+ fee_rate: float # 派生字段:bps → 比率
386
+
387
+ def to_internal(raw: RawStrategyConfig) -> StrategyConfig:
388
+ # 类型安全的转换:全程操作对象字段,有类型标注兜底
389
+ venue = raw.venue if raw.venue is not None else "default_venue"
390
+ return StrategyConfig(
391
+ lookback=raw.lookback,
392
+ threshold=raw.threshold,
393
+ symbols=tuple(raw.symbols),
394
+ venue=venue,
395
+ fee_rate=raw.fee_bps / 1e4,
396
+ )
397
+
398
+ def load_config(path: str) -> StrategyConfig:
399
+ return to_internal(load_raw_config(path))
400
+ ```
401
+
402
+ 要点:
403
+
404
+ - **两层都用 stdlib `@dataclass(frozen=True, kw_only=True)`。** 原始 schema 和内部模型遵循同一套规则,pydantic 只在 `validate_python` 那一瞬间起校验作用,产出的是普通 dataclass。不要把内部模型也做成带验证逻辑的 pydantic 模型到处传——那是把外部 schema 的包袱带进内部。
405
+ - **pydantic 通过 `TypeAdapter` 当校验引擎,不通过 `BaseModel`。** `TypeAdapter[T]` 能校验任意 stdlib 类型(含 dataclass),校验完返回该类型的普通实例。模块级建一个 `TypeAdapter` 复用,不要每次解析都新建。
406
+ - **字段级 pydantic 配置用 `Annotated[T, pydantic.Field(...)]`。** 别名、数值约束、描述等写在类型标注里:`Annotated[int, Field(alias="thresh", ge=0)]`。类型标注仍是 stdlib 类型,pydantic 元数据是附加层,对类型检查器透明。
407
+ - **可选字段的语义就是"有默认值"。** 在原始 schema 里,一个字段是否可选由它有没有默认值决定——有默认值的字段,外部 dict 里可以不出现对应的 key,解析时用默认值补上;没有默认值的字段是必填,缺 key 就报错。由此推出一条硬性约束:**新增可选字段时必须带默认值**,否则旧配置文件会因为缺这个 key 而解析失败,破坏向后兼容。反过来,想从"可选"改成"必填"也要谨慎——原本可不带的旧数据会突然变非法。
408
+ - **转换函数从原始 schema 产出内部模型。** 需要合并多个来源(比如配置文件 + 命令行 + 环境变量)时,让转换函数接收多个原始 schema 参数,合并逻辑集中在这一处。
409
+ - **原始 schema 声明哪些字段,看 schema 归谁控制:我们定义的(配置文件)写全支持的项,上游控制的(API 响应、消息、DB 原始行)只写代码读取的项。** 未声明的键两种情况都靠解析库默认忽略,不为此加配置。详见下一小节。
410
+ - **不要跳过第一层直接 dict → 内部模型。** 即使外部形状和内部形状恰好相同,也保留原始 schema 这一层——它把"外部形状"作为可追踪的契约固化下来。一旦未来外部形状和内部形状分叉(几乎必然发生),你有一个明确的层去改,而不是去改散落各处的 `raw["key"]`。
411
+ - **用成熟校验库,不要手写解析。** pydantic、msgspec 都可以,选项目已在用的。手写 `isinstance` + `get` + 逐字段构造的解析代码是 bug 温床(缺字段静默用默认、类型不匹配静默通过、错误信息零散),成熟库替你处理这些并把错误信息聚合抛出。
412
+
413
+ ### 原始 schema 声明哪些字段:代码示例
414
+
415
+ 第 1 层该声明哪些字段的判断标准(我们控制的声明完整 / 上游控制的只声明读到的)见 `coding-style` 第 3 节。落到 Python 侧:
416
+
417
+ ```python
418
+ # 上游控制:只声明代码读取的字段,响应里其余几十个键都不进 schema
419
+ @dataclass(frozen=True, kw_only=True)
420
+ class RawOrderRow:
421
+ order_id: str
422
+ status: str
423
+ fills: list[RawFill] # 嵌套同样只声明读到的那条路径
424
+ # 签名、内部 ID、我们从不读的上游字段一概不写:
425
+ # 它们怎么变都不该让我们的解析抛错
426
+ ```
427
+
428
+ 我们控制的那一侧就是上面"怎么做"里的 `RawStrategyConfig`:配置文件支持的配置项一个不漏地声明,新加的可缺省项带默认值,让旧文件继续解析得了。两个类长得像,取向相反。
429
+
430
+ 两种归属共用的规则:
431
+
432
+ - **未声明的键靠解析库默认忽略,不为此写任何配置。** 这个默认值对两边都恰好正确:上游一定会加我们从不读的字段;配置文件里的多余键要么键名拼错了,要么属于同一份文件里别的组件的段落,都轮不到 schema 来否决。给配置那一层加 `extra="forbid"` 看着能抓拼写错误,代价是把"还没升级的旧代码读带新键的配置"也变成启动失败——灰度、回滚、多个组件共用一份配置都依赖这个方向;而拼错的键本来就会以"某个行为没按预期发生"的形式暴露在运行日志里,比让进程起不来便宜得多。真要做未知键诊断,写成显式的 warning 检查,不要写成 schema 的硬约束。
433
+ - **字段少不等于可以省校验。** 声明出来的字段仍按"数据合法性检查外推到系统边界"写约束(`Annotated[T, Field(...)]`);"可选"仍然表现为有默认值。
434
+
435
+ 确有特殊需求时才偏离这两种默认做法,且把偏离的理由写进注释:
436
+
437
+ - **我们控制的配置需要读进来、改几个键再原样写回**(配置文件的读写往返工具):这时才需要显式持有未知键(`extra="allow"` + pydantic 的 `model_extra`),不要为了"能存住"就逐个猜字段。更常见的解法是让每个组件只读写自己那一段,不做整文件往返。
438
+ - **上游数据要全字段落盘 / 审计**(数据本身就是产品):直接保留原始 dict 或原始 bytes,别把上游全部字段抄成 dataclass——那还是一份会腐烂的文档,只是多了一层转换。
439
+ - **就是要主动探测上游新增字段**:显式 `extra="forbid"`,并在注释里写明这是"上游一动我就报错"的有意选择。
440
+ - **Python ↔ C++ 跨语言镜像结构不属于这两种归属**:那是我们自己两侧维护、两侧都读的契约,必须严格 1:1,写法见 `coding-style-ffi`。
441
+
442
+ ---
443
+
444
+ ## 4. 文件读写:简单单次读写用 `Path.read_text` / `read_bytes`
445
+
446
+ 简单的单次文件读写(整个文件一次读入、整个文件一次写出),优先用 `pathlib.Path` 的 `read_text` / `read_bytes` / `write_text` / `write_bytes`,不要用裸 `open`:
447
+
448
+ ```python
449
+ from pathlib import Path
450
+
451
+ # 这样:一次读完,自带关闭
452
+ content = Path("config.json").read_text(encoding="utf-8")
453
+ Path("out.txt").write_text(content)
454
+
455
+ # 不要这样:要自己管 with 块
456
+ with open("config.json", encoding="utf-8") as f:
457
+ content = f.read()
458
+ with open("out.txt", "w", encoding="utf-8") as f:
459
+ f.write(content)
460
+ ```
461
+
462
+ 为什么:
463
+
464
+ - **没有资源管理负担。** `read_text` 自己处理打开、读取、关闭,不存在漏 `close` / 忘 `with` 的资源泄漏;`open` 必须配 `with` 或手动关闭,是多出来的心智负担。
465
+ - **编码显式写在调用点。** `Path.read_text(encoding="utf-8")` 的编码是调用点上的显式参数;`open` 不写 `encoding` 时依赖 locale,跨机器行为不一致,而 `read_text` 的形式逼你在每个调用点决定。
466
+ - **返回 str / bytes 一步到位。** `read_text` 直接返回解码后的 `str`、`read_bytes` 直接返回 `bytes`,不用在 `f.read()` 之后再处理类型问题。
467
+
468
+ 什么时候该用 `open`:大文件流式处理(逐行、逐块读,不能一次全部载入内存)、追加写(`"a"` 模式)、同时读写、自定义缓冲——`Path` 的便捷方法覆盖不了的场景。这些场景下 `open` + `with` 仍然是对的,规则只约束"简单单次读写"。
@@ -0,0 +1,138 @@
1
+ ---
2
+ name: coding-style-typescript-javascript
3
+ description: Use when 编写、生成、修改、编辑、重构或评审任何 TypeScript / JavaScript 代码——涵盖实现功能、修 bug、写脚本、写测试、性能优化、代码迁移、重命名、加减参数或函数、修改函数签名、定义类型、interface、处理函数间数据流、JSON 反序列化、zod、typebox、tsconfig、code review / PR review。任何会产出或改动 TS/JS 代码的任务(write / edit / refactor / review TypeScript or JavaScript code)都要先加载本 skill,即使用户没有提到风格、规范或最佳实践。只读调查不加载:单纯 debug、定位代码、定位问题、读代码理解逻辑等不改代码的任务不需要加载。
4
+ ---
5
+
6
+ # TypeScript / JavaScript 编码风格
7
+
8
+ **REQUIRED BACKGROUND:先加载 `coding-style`(shared)**——语言无关的原则与"为什么"在那里,章节序号与本文件对应。本文以 TypeScript 为准;纯 JavaScript 没有类型标注,只有"边界校验 + JSDoc 标注"一条弱化路径可选。
9
+
10
+ ---
11
+
12
+ ## 1. 函数边界容器
13
+
14
+ - **不用 `any`、`{}`、`object` 做函数参数/返回值。** `any` 等于 `Any`;`{}` / `object` 是"任何非 null 值",和裸字典一样零信息。
15
+ - **结构化数据用 `interface` / `type`**,字段带类型。不在 Go 里写成 `map[K, V]` 的场景,TS 里也不用 `Record<string, X>` 硬凑。
16
+ - **边界收 `unknown`,不用 `any`。** `JSON.parse` 返回值天然是 `any`,拿到后第一时间显式标注为 `unknown`,再走边界校验。
17
+ - **多参数函数用参数对象。** 参数 ≥ 3 个、或含同类型参数(如 `x: number, y: number`)时,收一个参数对象 `opts: PositionOpts`,字段名显式出现在调用点——对应 Python 的 `kw_only` 原则。TS 按位置传参比 Python 更常见,同类型字段传反了类型检查发现不了。
18
+ - **不可变(对应 Python `frozen`):** 数据对象字段用 `readonly`,集合用 `ReadonlyArray<T>` / `ReadonlyMap` / `ReadonlySet`;"更新状态 = 产出新对象"用对象展开 `{ ...obj, field: v }`。注意 `readonly` 是浅层的,嵌套容器字段同样要 readonly。
19
+ - **取值集合用字面量联合类型或枚举**,不用魔法字符串:`type Side = "buy" | "sell"`。非法值在调用点就是类型错误。
20
+ - **开启 `strict: true` 与 `noUncheckedIndexedAccess`**(tsconfig),后者让 `obj[key]` 返回 `T | undefined`,把"键不存在"从静默 undefined 变成必须处理。
21
+
22
+ ## 2. 边界校验 + 反序列化分两层
23
+
24
+ TS 生态里 zod 与 typebox 是两个主流校验库:zod 更常用、类型推断顺手;typebox 产物即 JSON Schema、适合需要 schema 跨语言共享或性能敏感的场景。二选一跟项目走,同一项目不混用。
25
+
26
+ 两层结构与 `coding-style` 第 3 节一致:第 1 层是校验库的原始 schema(1:1 对应外部形状,只做形状 + 基础类型校验);第 2 层是手写的 readonly 内部模型,转换函数从 raw 到 internal,类型安全。
27
+
28
+ ### 用 zod
29
+
30
+ ```ts
31
+ import { z } from "zod";
32
+ import { readFileSync } from "node:fs";
33
+
34
+ // — 第 1 层:原始 schema,1:1 对应配置文件形状 —
35
+ const RawStrategyConfigSchema = z.object({
36
+ lookback: z.number().int(),
37
+ threshold: z.number().positive(),
38
+ symbols: z.array(z.string()),
39
+ // 新加字段:optional/default 表达"外部可以没有",兼容旧配置文件
40
+ venue: z.string().nullish(),
41
+ feeBps: z.number().default(0),
42
+ });
43
+
44
+ type RawStrategyConfig = z.infer<typeof RawStrategyConfigSchema>;
45
+
46
+ function loadRawConfig(path: string): RawStrategyConfig {
47
+ const raw: unknown = JSON.parse(readFileSync(path, "utf-8"));
48
+ return RawStrategyConfigSchema.parse(raw); // 非法即抛 ZodError,不让坏数据进入内部
49
+ }
50
+
51
+ // — 第 2 层:内部模型,转换得到 —
52
+ interface StrategyConfig {
53
+ readonly lookback: number;
54
+ readonly threshold: number;
55
+ readonly symbols: readonly string[];
56
+ readonly venue: string; // 内部不接受 null/undefined,转换时给默认/报错
57
+ readonly feeRate: number; // 派生字段:bps → 比率
58
+ }
59
+
60
+ function toInternal(raw: RawStrategyConfig): StrategyConfig {
61
+ return {
62
+ lookback: raw.lookback,
63
+ threshold: raw.threshold,
64
+ symbols: [...raw.symbols],
65
+ venue: raw.venue ?? "default_venue",
66
+ feeRate: raw.feeBps / 1e4,
67
+ };
68
+ }
69
+
70
+ export function loadConfig(path: string): StrategyConfig {
71
+ return toInternal(loadRawConfig(path));
72
+ }
73
+ ```
74
+
75
+ 要点:
76
+
77
+ - **`z.infer` 让 schema 是类型的唯一来源。** 原始 schema 的 TS 类型从 schema 推导,不要手写一份平行的 `interface` 再让两者漂移。
78
+ - **内部模型手写成 readonly interface**,而不是把 zod 推导类型一路传下去——内部不接受 `null | undefined`、要 readonly 时,正是转换层存在的意义(`coding-style` 第 3 节"转换层解耦")。
79
+ - **模块级复用同一个 schema 对象**;`parse` 抛错即边界拒绝,内部不需要再校验。
80
+ - **键名映射在 schema 层解决**(上游键名与内部命名不一致时用 `.transform()` 等 schema 能力),不要在转换函数里用字符串索引 raw。
81
+
82
+ ### 用 typebox
83
+
84
+ ```ts
85
+ import { Static, Type } from "@sinclair/typebox";
86
+ import { Value } from "@sinclair/typebox/value";
87
+ import { readFileSync } from "node:fs";
88
+
89
+ const RawStrategyConfigSchema = Type.Object({
90
+ lookback: Type.Integer(),
91
+ threshold: Type.Number(),
92
+ symbols: Type.Array(Type.String()),
93
+ venue: Type.Optional(Type.Union([Type.String(), Type.Null()])),
94
+ feeBps: Type.Number({ default: 0 }),
95
+ });
96
+
97
+ type RawStrategyConfig = Static<typeof RawStrategyConfigSchema>;
98
+
99
+ function loadRawConfig(path: string): RawStrategyConfig {
100
+ const raw: unknown = JSON.parse(readFileSync(path, "utf-8"));
101
+ // Value.Parse 校验失败时抛错;也可以用 Value.Check 先判,自行报错
102
+ return Value.Parse(RawStrategyConfigSchema, raw);
103
+ }
104
+ ```
105
+
106
+ `toInternal` 与 zod 版完全相同——这正是两层设计的价值:校验库换了,内部模型和转换层不动。
107
+
108
+ 要点:
109
+
110
+ - `Static` 是 typebox 的类型来源,同 `z.infer` 的角色。
111
+ - TypeBox 的 `default` 值要经过 `Value.Parse`(或带 default 的 decode 管线)才会补上,不是 TypeScript 类型层行为。
112
+ - 需要给非 TS 消费方共享 schema 时,typebox 产物即 JSON Schema,这是选它而非 zod 的主要理由。
113
+
114
+ ### schema 归谁控制与未知键
115
+
116
+ 与 `coding-style` 第 3 节同规则,落到 zod/typebox 上:
117
+
118
+ - **我们控制的 schema(配置文件):** 支持的项全部声明;新加可缺省项用 `.optional()` / `.default()`(zod)或 `Type.Optional`(typebox),旧文件继续可解析。
119
+ - **上游控制的 schema(API 响应、消息):** 只声明代码真正读取的字段。zod 默认剥离未知键(strip);TypeBox 不声明 `additionalProperties` 即不校验未知键——都不要配成"未知键报错",理由见 `coding-style` 第 3 节。
120
+ - **可选语义 = 有默认值。** 新增字段必须 optional/default,否则旧数据缺键直接 parse 抛错,破坏向后兼容。
121
+
122
+ ## 3. 类型检查配置
123
+
124
+ tsconfig 最低要求:
125
+
126
+ ```json
127
+ {
128
+ "compilerOptions": {
129
+ "strict": true,
130
+ "noUncheckedIndexedAccess": true,
131
+ "exactOptionalPropertyTypes": true
132
+ }
133
+ }
134
+ ```
135
+
136
+ - `strict: true` 关掉隐式 any 与一系列松散检查,是第 1 节一切规则的前提(对应 Python 侧的 `disallow_untyped_defs`)。
137
+ - `noUncheckedIndexedAccess` 让字典/数组索引访问必须判 undefined,配合"裸字典只用于 `map[K, V]` 语义"。
138
+ - `exactOptionalPropertyTypes` 区分"没有这个键"和"键的值是 undefined",与两层 schema 的"缺键走默认值"语义对齐。