name: head-worker-skills
version: 3.5.5
description: 元Skill——创建沙盒包(v3.5.5)。先陪人看清自己真正的愿望、再判断形态、区分普通JS与ToolPkg、再探索现状、对照通用Bug与优化清单自由判断、逐步与用户确认抉择,最后进入平台的沙盒包开发工具撰写流程。兼容层级、按类型性能准则、QuickJS陷阱全内置。内置形态区别表、第一次创建必读清单。附带file-head五军文件操作沙盒(Node.js零依赖,分页翻书+SCOUT侦察+军师摘要)。仅在用户明确表达"想做沙盒包/工具/脚本"或主动要求了解沙盒包概念时触发。
disable-model-invocation: false
Sandbox Package Creator v3.4
不要直接写代码。最贵的错误不是代码bug,是做完发现方向错了。本Skill从根上防止这件事——先让人看清「我真正想要什么体验」,再走淘汰链定形态,再诊断风险,再逐条确认——四轮过后才动手。沙盒包代价比Skill大得多,写错了不是改SKILL.md能解决的重做成本。
🤝 协作协议 —— 看清自己当下的位置
这个协议不做两件事:不给人分类,不替人决定。 它只做一件事:让你看到自己现在站哪。看清楚了,你自然知道该让AI怎么配合。
自检镜
下面是开放问题。选一个、全答、写「其他」——都行。括号里是我们见过的特征,大概率指向那个方向,但不绝对。你不是数据点。
① 你现在的状态?
- 「我很清楚要什么」→ AI 少追问,多翻译,快交付
- 「有个方向但说不清」→ AI 多穷举可能,帮你挖出你已经知道但没说出来的
- 「只有直觉,连方向都模糊」→ 从愿望(1.1节)开始聊,别急着往下走
- 「其他:____」
② 你更需要AI「执行」,还是「挑战」?
- 「执行」→ AI 不质疑方向,你说什么就往什么推。(适合:你确定自己是对的)
- 「挑战」→ AI 主动说「这个方向有代价 X,Y 会不会更适合?」。(适合:你做决定前想被推一把)
- 「其他:____」
⚠️ 常见陷阱: 自以为需要「执行」,但方向没想清楚——最惨的结局是「做完了,不对,重来」。如果你不确定方向,先让 AI 挑战你一轮。
③ 这件事的本质?
- 「完成它」→ 紧凑,快
- 「想清楚它」→ 松弛,允许来回、推翻、重来
- 「其他:____」
④ 「刚刚好」的尺度?
这不是问你要什么品质。是问你不需要什么品质——因为「什么都要」=「什么都平庸」。
- 「别让基本 bug 出现就行」→ AI 侧重诊断清单(第3节)
- 「代码得比别人快一档」→ AI 侧重按类型性能准则(第5.2节)
- 「给别人的,接口要干净」→ AI 侧重兼容层级和文档(第1.4节、第5.1节)
- 「我要它成为同类里最好的」→ AI 火力全开,所有准则全上
- 「其他:____」
🎯 刚刚好 = bug 不至于、优化恰恰好、不多不少。 最危险的从来不是「做得不够」,是「优化了一个没人用的路径,把它搞复杂了」。
⑤ 「我看不懂术语,先给我看个例子」
- → AI 不跑淘汰链,不追着确认,不列技术表。只做一件事:打开一个真实沙盒包,指认每部分在干什么。 像认路一样,不背地名。节奏由你控制——看够了说「继续」,再往下走。
- 这条适合:第一次接触沙盒包、不确定「工具」「包」「接口」是什么、想先摸到实物再理解概念。
调校
基于你的回答,AI 会调校:
| 维度 | 调校范围 |
|---|
| Shape 追问深度 | 浅 / 中 / 深 |
| Develop 质量侧重 | 防bug / 性能 / 接口 / 全开 |
| 整体节奏 | 紧凑 / 松弛 / 来回推敲 |
同一人、不同任务、不同时间,答案不一样。每一步都可能回来重答。这是正常的。
1. Shape —— 先想清楚这究竟是什么
在碰任何代码之前,穷举可能性。目标是帮用户看清:这个需求本质是什么?沙盒包真的是最优解吗?做了之后往哪走?别人能看懂我的初衷吗?
1.1 愿望 —— 「你真正想要什么体验?」👤→🤖
先别管「做什么」——先说「想要什么」。人类不需要说清技术;AI 负责翻译成工程语言。
「我希望 AI 能更好地搜索」→ 翻译:多引擎聚合、统一返回格式、自动熔断
「我希望打开手机就能调这个功能」→ 翻译:高频调用、低延迟、本地优先
愿望对了,形态自然对。愿望没挖出来,选什么都可能后悔。
1.2 需求本质 —— 从愿望到工程 🤖→👤
AI 从愿望出发,穷举四个维度。人类可能没想到的点,AI 主动追问:
| 维度 | 问题 | 为什么重要 |
|---|
| 动作类型 | 这是「执行代码」「定义规则」「调外部接口」还是「组合多个操作」? | 直接决定了三层里谁最合适 |
| 调用频率 | 每次对话都用到?偶尔用?一次性? | 高频→沙盒包,一次性→脚本 |
| 数据流向 | 数据从哪来、到哪去?经过平台内部还是外部? | 涉及外部协议→MCP更合适 |
| 用户是谁 | 只有自己用?小团队?公开发布? | 公开→文档和错误处理要更完整 |
1.3 形态判断 —— 淘汰链 + 反向追问 👤🤖同时
不要列四个选项让用户选。按顺序走淘汰链,每一步干掉一个,最后剩下的就是答案。但走到沙盒包后,加一问反向追问。
淘汰链(按顺序,命中即停):
需要执行代码?
├─ 不需要 → Skill。「教AI怎么想」足够了,别上沙盒包。
└─ 需要 ↓
涉及标准化外部协议?(REST API有固定规范、需要被外部系统按协议调用)
├─ 是 → MCP。标准化接口天生适合这个,沙盒包的灵活性在这没用。
└─ 否 ↓
逻辑自由度高、需要QuickJS/Java Bridge、操作文件?
├─ 否 → 内联脚本。一次性任务不值得建包。
└─ 是 → 沙盒包。但先别停——追问下面这条。
反向追问:不换形态,深耕能达到吗?
你现在已经有的框架/代码里,是不是还有没挖完的潜力?换形态是捷径还是绕路?
——Search Vault 从 v1.0 到 v3.6,一直是沙盒包。不是不能变,是同一个框架里深耕比换架构更有价值。
确认代价:
选了沙盒包,意味着放弃了什么?——MCP 的标准化对外接口、Skill 的零成本修改。这个代价能接受吗?
如果用户犹豫,退回上一步重新走淘汰链。AI 的职责是带着用户走完淘汰链,给出唯一推荐,并说清楚为什么。
1.3.1 沙盒包的两种形态:普通 JS 包 vs ToolPkg
淘汰链走到「沙盒包」还没完——沙盒包内部还有两条岔路。AI 必须主动帮用户区分,不能默认跳过去。 这是 v2.5 新增的硬规则——此前多次出现 AI 不区分形态就直接动手,导致方向错误。
| 维度 | 普通 JS 沙盒包 | ToolPkg |
|---|
| 本质 | 一个 .js 文件,通过平台沙盒包系统加载,暴露工具函数 | 一个 .toolpkg 压缩包,含 manifest、多文件、资源,可注册宿主级扩展 |
| 何时选 | 只加/改工具函数、参数、返回结构、环境变量——纯逻辑 | 需要配置界面、工具箱UI、lifecycle hook、message processing、prompt hook 等宿主级能力 |
| METADATA | 写在文件头 /* METADATA { ... } */ 注释块里 | 写在独立 manifest.json 中 |
| version 字段 | ⚠️ METADATA 无 version 字段——这是已知空白。AI 在交付时必须口头告知用户版本号,并建议在包描述中手动标注 | manifest.json 有 version 字段(可选但强烈建议填写) |
| 文件结构 | 单文件 .js,自包含 | 多文件目录 → 打包为 .toolpkg zip;含 main.js、manifest.json、资源文件 |
| 注册方式 | 平台扫描 METADATA 自动注册工具 | 通过 registerToolPkg() 注册;需 operit_editor:debug_toolpkg 调试安装 |
| 能否含 UI | ❌ 不能 | ✅ 可注册配置界面、工具箱页面 |
| 能否含 hook | ❌ 不能 | ✅ lifecycle hook / prompt hook / message processing |
| 跨包引用 | ✅ 可被其他包通过标准工具调用 | ✅ 同左 |
| 版本管理 | 靠文件名/注释/口头约定 | manifest version + 打包时嵌入 |
| 示例 | tavily.js、code_runner.js、various_search.js | search_vault、windows_control.toolpkg、plan_mode.toolpkg |
⚠️ 形式语言(v2.7先导): 同一门编程语言,在不同 substrate 上已经是不同的形式语言——都说 JS,QuickJS JS ≠ Node.js JS ≠ 浏览器 JS。语法一样,词汇不同。 听不懂不是因为代码错了——是「形式语言」错了。同样说中国话,方言不同的人一样听不懂——沙盒包的 JS 也是这个道理。
| 形式语言 | Substrate | API 面 | 谁在用 |
|---|
| QuickJS 沙盒包 JS | QuickJS + 公开 Bridge | types/ 里有的 | 自建包 |
| 内置包 JS | QuickJS + Java Bridge | 全平台内部 API | 平台内置包 |
| Node.js JS | V8 + Node API | fs、path、npm | 本地脚本 |
| 浏览器 JS | V8/SpiderMonkey + DOM | window、document | 前端 |
步骤对、语法对、形式语言不对 → 中转站不认。 不要因为「内置包的 terminal 能做到 X」就假设自己的包也能——先查 types/ 确认 API 是否存在。这条在 5.0 案例 D 有详细展开。
⚠️ 版本声明规则(v2.5新增): 无论选哪种形态,AI 必须在交付前明确告知用户:「这个包的当前版本是 X.Y.Z」。普通JS包因METADATA无version字段,至少要在包描述的显著位置标注版本号。同一沙盒包内的多个JS文件,版本号必须一致——不一致时 AI 必须在交付前修正或警告。
误判代价示例:
- 本应是普通JS包却做成ToolPkg → 多了 manifest、打包流程、调试安装步骤,白费时间
- 本应是ToolPkg却做成普通JS → 后续需要UI/hook时发现做不了,只能重写
⚠️ 沙盒包 ≠ 沙盒(v2.7先导): 两者共享「隔离执行」这个词根,但层级不同。沙盒 是 OS/运行时级隔离容器——Docker、WASM、iframe、Node vm。沙盒包 特指 Operit 平台上的轻量脚本单元——QuickJS 引擎、.js/.toolpkg 部署、毫秒启动。Docker 镜像是沙盒但不是沙盒包——Dockerfile 没法塞进 package_proxy。WASM QuickJS 是通用 substrate,但沙盒包的运行环境是平台耦合的(QuickJS版本、Bridge API面、文件系统路径)。步骤对、语法对、substrate 不对→中转站不认。
1.4 兼容层级 —— 这个包未来被谁、怎么调用?🤖→👤
淘汰链走完,立刻定接口标准。四种层级,选一个——越往上越规范:
| 层级 | 调用方式 | 接口要求 | 适合 |
|---|
| 🟢 原生QuickJS | 平台原生脚本执行工具,stdout/stderr唯一通道 | 返回纯文本即可 | 自己用、一次性脚本 |
| 🟡 Package API | 通过平台沙盒包系统调用,结构化params/result | {success, data, error}统一包装 | 高频工具、团队内用 |
| 🟠 跨包调用 | 被其他沙盒包引用,作为依赖 | 统一错误码、elapsed_ms性能标记、retry_trace故障追踪链 | 被多个包依赖的底层包 |
| 🔴 公开API | 外部用户/系统直接调用 | 完善文档、向后兼容承诺、语义化版本 | 公开发布、市场分发 |
选定层级后,5.1节(接口设计规范)会给出该层级的完整契约模板。
1.5 用户画像 —— 「别人能看懂你吗?」👤↔🤖
不同用户需要完全不同的东西。再加一问:别人拿到这个包,能不能一眼看懂你当初为什么做它?
| 用户类型 | 需要什么 | 不需要什么 |
|---|
| 自己用 | 能用就行,硬编码没关系 | 复杂的参数校验、多语言文档 |
| 小团队 | 参数清晰、有README | 向后兼容承诺、严格语义化版本 |
| 公开发布 | 完善错误处理、CHANGELOG、版本管理 | 内部路径硬编码、临时方案 |
1.5.1 受众维度:人类用户 vs AI 用户(v2.5新增)
⚠️ 这是最容易被忽略的维度——因为开发者天然代入自己的视角。 人类开发者不自觉地为人设计;AI 不自觉地为自己设计。结果:目标受众拿到手发现「这东西不是给我用的」。
| AI 作为用户 | 人类作为用户 |
|---|
| 需要 | 结构化返回({success, data, error})、精确参数名、无歧义的错误码、elapsed_ms、retry_trace | 自然语言反馈、直观的确认步骤、人类可读的错误信息、GUI/可视化 |
| 不需要 | 弹确认框、冗长的自然语言解释、「操作成功!」这类无信息量文本、每次都要手动点确认 | JSON 裸输出、数字错误码需要查表、没有进度反馈、命令行界面 |
| 典型翻车 | 工具每步都 confirm("确定要...") → AI 卡在确认循环 | 工具返回 {code: -3, msg: "E_TIMEOUT"} → 人类看不懂要查文档 |
| 设计铁律 | 如果用户是 AI:零确认、纯数据、错误可编程 | 如果用户是人类:有反馈、有解释、有 UI |
🎯 判断方法: 问自己——「这个包的调用方是谁?」如果调用方是 Operit 的 AI(通过 use_package / package_proxy),那就是 AI 用户。如果调用方是人(打开 App 点按钮),那就是人类用户。混用是最差的选择——一个包不太可能同时服务好两者。如果确实需要同时服务,拆成两个:一个给 AI 的工具包 + 一个给人类的 UI 层。
🎯 愿望承载: 你的初衷应该写进命名、接口、README 里。代码能承载愿望——但前提是做的时候就知道愿望是什么。
1.6 演进路线 —— 分叉:深耕 vs 重构 🤖→👤
做完这个版本之后,可能往哪走?不是一条路,是两条分叉:
| 路径A:当前框架深耕 | 路径B:跨形态重构 |
|---|
| 在现有架构里加功能、优化性能 | 从沙盒包改成MCP/Skill/拆分子包 |
| 代价小,1→1.1→1.2稳步升级 | 代价大,但天花板更高 |
| 适合:维护期、性能可接受 | 适合:性能天花板到了、架构撑不住新需求 |
AI 同时评估两条路:
- 性能天花板:如果数据量翻10倍,当前设计会崩在哪?还能在框架内解决吗?
- 组合可能性:这个包会不会被别人引用?需要提前暴露什么?
- 被替代风险:平台将来原生支持这个功能怎么办?现在做的还有价值吗?
此阶段完成后,用一段话总结:愿望是什么、为什么选这个形态和层级、给谁用、走深耕还是重构。用户点头后才进入 Explore。
2. Explore —— 先看现状,不假设
在提出任何方案之前,先用自己的判断力探索当前环境。目标是了解「已经有什么、缺什么、可以复用吗」。
必须检查:
AI 搜索以下目标(路径按所在平台调整):
- 平台的沙盒包开发目录下 —— 有没有同名或类似的包?有没有可复用的 types/代码片段?
- 平台的示例/模板目录下 —— 有没有功能相近的示范包可以参考结构?
- 用户当前已激活的包列表 —— 有没有能替代或辅助目标功能的已有包?如果已有包能覆盖大部分需求,优先建议基于已有包扩展,而非新建。
- 如果目标涉及外部服务(API、MCP等),先确认该服务是否已有对应包或MCP配置。
自由判断原则:
- 不假设某个文件一定存在或不存在——真的去看
- 不假设某个包一定好用或一定不能用——去看它的工具列表和参数
- 不假设「必须新建」——如果已有包可以改、可以扩展,把这件事作为选项之一告知用户
自由判断的边界——什么时候不能「差不多就行」:
- 退出码/返回值:文档写的数字就是数字,
0 不能写成 null,-1 不能写成 "error"
- 文件路径规范:沙盒包 I/O 仅限
/sdcard/ 下,不能「反正测试能跑」就写到别处
- 类型声明:
types/*.d.ts 里的签名必须严格匹配,参数名可以改,类型和顺序不能改
- 包名/ID:一旦发布就不能改——确认前多花30秒检查拼写
- 用户明确说的约束:如果用户在Confirm阶段说了「不要X」,那就是硬边界,不能悄悄加回来
探索结果用几句话总结,不要长篇报告。
3. Diagnose —— 自由判断,但参考已知雷区
探索完现状后,对照下面的清单做诊断。这些不是必须遵守的规则——是过去踩过的坑的地图。 AI 用自己的判断力决定:哪些适用、哪些不适用、哪些需要改造后适用。
3.1 Bug预判(61条,来自Search Vault全系实战 + Matt Pocock JS陷阱蒸馏 + 多引擎搜索结果 + 编程语言理论 + kotlin-head 全栈编译器实战)
⚠️ 设计耦合族索引(v2.6): 以下 Bug #45–#59 按基因归类,同族挨着——找去重不看自指,找阻断不翻缓存。
| 族 | 成员 | 基因 |
|---|
| 🪞 自指 | #50 #51 #56 | 机制把自己当成了处理对象——查自己、删自己、缓存自己 |
| 💂 阻断自毁 | #52 #53 | 保护者掐了自己的命脉——误杀核心 + 把唯一救兵也阻断了 |
| 🐚 新旧分裂 | #48 #54 | 版本号是新的,跑的是旧的——壳核分离 + 旧核渗入新壳 |
| 🎯 去重盲区 | #46 #49 | 正则没错,错的是没人定义「什么叫重复」 |
| 📏 阈值没跟 | #45 #55 | 指标是好指标,只是忘了随着内容/重要性同步更新 |
| 🩺 诊断失准 | #47 #59 | 一个报错报错病因,一个该报说「正常」 |
| ⏰ 初始化叛变 | #57 | 一切都没错——只是叫早了一个,依赖图没画箭头 |
| 🔤 媒介错位 | #58 | Python 写 Markdown,两样都对,不该在同一页 |
这些bug来自Search Vault的 QAQ.md(质量保证问答)、LOGIC.md(逻辑证明)、BUGSJS学术分类学(453个真实JS bug)、QuickJS真实CVE记录——不是凭空列举。
使用方式: 逐条自问「这个包会不会犯这个」。会→看「转良性」和「修复思路」两列,选一条路。不会→跳过。
这不是规定——是地图。 你知道坑在哪,自己决定绕、填、还是把它变成景观。
🔴 致命级 —— 不修 = 脚本直接死
| # | Bug | 不修会怎样 | 路径A:转良性 | 路径B:修复思路 |
|---|
| 1 | 重复 const 声明 | SyntaxError,脚本加载阶段就炸 | ❌ 语法错误,无法转 | tsc编译前全文搜索变量名;用 let 替代顶层 const 降低风险 |
| 2 | 代码残留(删了赋值留了声明) | 同上 | ❌ 同1 | 删除声明后全文搜索标识符所有使用点;交付前跑一遍 tsc + 沙盒脚本 |
| 3 | 忘 await | Promise 被当值用,不报错但结果永远错——比报错更危险 | ⚠️ 仅限故意 fire-and-forget:标注 /* intentional no-await */ | 凡是调用返回 Promise 的函数,确认有 await 或 .then;lint 规则 no-floating-promises |
| 4 | 整数溢出(BigInt 解析,QuickJS 真实 CVE) | 恶意超长 BigInt 字符串可损坏内存 | ❌ 引擎层 bug,沙盒包作者无法修复 | 防御:限制用户输入中 BigInt 字面量长度;不直接 parse 外部来源的 BigInt |
🟠 高危级 —— 跑得动,但结果不可信
| # | Bug | 不修会怎样 | 路径A:转良性 | 路径B:修复思路 |
|---|
| 5 | 空值无 fallback(obj.x.y → 崩溃) | 外部 API 少字段→脚本崩,AI 拿崩溃日志 | ⚠️ 仅限绝对信任的数据源(自己写的内部 API):代码更短更干净 | 可选链 ?.;解构给默认值 {x=0};外部数据入口统一做 schema 校验 |
| 6 | 版本比较用字符串("10.0.0" < "9.9.9" = true) | 版本检测全乱 | ✅ 版本号永远单整数(v1, v2, v3)→字符串比较够用。写进规范 | 用 semver 解析器;或自己实现 compareVersions() 拆段逐位比 |
| 7 | 元数据不同步(note 字段残留旧版本号) | 信任崩了——用户看到 v3.6 代码 meta 说 v3.5 | ❌ 说谎不是特性 | 版本号集中单一定义(一个常量);构建脚本自动注入;交付前 grep 旧版本号 |
| 8 | 赋值写成比较(if (x = 5)) | 条件永远 true,分支永远错 | ❌ 没人想要这个 | 条件表达式里把常量放左边:if (5 == x)——写错 5 = x 直接 SyntaxError |
| 9 | 竞态条件(多个 fetch 并发写同一变量) | 相同输入→不同输出,不可复现 | ✅ 「最先回来的用,后面的扔」→标注 /* intentional race: first wins */ | 用 Promise.allSettled 统一收结果;或给每个 fetch 独立的结果变量 |
| 10 | Promise rejection 未处理 | 静默失败——QuickJS 不报 unhandled rejection | ❌ 未处理的 rejection = 不知道出错了 | 所有 fetch/then 链末尾加 .catch();顶层用 try/catch 包 async 入口 |
| 11 | 正则灾难性回溯(ReDoS) | 恶意输入→CPU 卡死→沙盒包超时 | ✅ 正则仅用于内部解析(不接受用户输入)=零风险,文档说明即可 | 加超时保护 AbortSignal.timeout;避免嵌套量词 (a+)+;用 test() 前先限制输入长度 |
| 12 | 内存泄漏(修改原型链/循环引用) | 长时间运行→内存吃满→平台限制触发 | ❌ 泄漏就是泄漏 | 避免修改内置原型;慎用闭包捕获大对象;大数组用完设 null |
🟡 中危级 —— 体验差,但不崩
| # | Bug | 不修会怎样 | 路径A:转良性 | 路径B:修复思路 |
|---|
| 13 | 缓存写了不生效(return 前漏补写) | 每次读盘慢 | ✅ 心跳/日志类数据→「始终读最新值」反而是正确的。重新定义为「可选加速层」 | 缓存写入后立即验证读;或用 Map 做内存缓存,写操作同步清 |
| 14 | I/O 反复读盘(无缓存) | QuickJS 每次读盘都慢 | ✅ 配置量极小(<5条)→缓存代码比重复读盘更复杂。文档写「配置量极小,故意不缓存」 | 统一配置读取函数加 cache-hit:5-30s 内复用 |
| 15 | 空 catch 吞错(catch(e) {}) | 出错静默——排查无从下手 | ✅ fallback 多引擎场景→前两个失败是预期内,安静跳过。但最后一个失败必须有 log | 至少 console.warn(e);或用结构化错误对象存到 retry_trace |
| 16 | HTTP 客户端缺 gzip 解压 | 响应体乱码——如果 API 不返回 gzip 则永远不触发 | ✅ 控制的 API 端不返回 gzip→缺解压=更少代码。文档写清楚 | 确认 HTTP 库是否自动处理 compression;手动检查 Content-Encoding 响应头 |
| 17 | 重复操作静默成功(同名不改不报) | 用户以为改了实际没改 | ✅ 包装成幂等性:「重复操作不报错,保证执行几次结果一致」。REST API 的美德 | 写操作返回明确状态标志:{changed: true/false, reason: "..."} |
| 18 | 空结果报错不明确(一堆问号) | 用户分不清「搜不到」和「API 挂了」 | ✅ 调用方是 AI→AI 只需要知道「没结果」就会换工具。对人要啰嗦,对 AI 不需要 | 错误信息含:操作+步骤+可能原因。「搜不到」≠「超时」≠「API key 无效」 |
| 19 | API 误用(参数类型/顺序错了) | JS 不校验→不报错但结果错 | ❌ 结果错就是错 | tsc 编译 + types/*.d.ts 类型声明;函数入口做参数校验:if (typeof x !== 'string') throw |
| 20 | 配置漂移(config 格式悄悄变了) | 读配置返回 undefined→后续全崩 | ✅ 加 config_version: 1 字段,不匹配直接报。不加=永远信任配置文件,自己用 ok | Schema 校验;版本号不匹配打印迁移指引 |
| 21 | 特性退化不通知(降级不打 log) | 性能悄悄退化 | ⚠️ 仅限自己用的脚本——自己就是监控 | 任何降级分支打 console.warn;退化路径写入 retry_trace |
| 22 | 行为与文档不一致(改了逻辑忘改输出描述) | 文档说谎→别人按文档调出错 | ❌ 文档说谎和元数据说谎一样 | 修改逻辑后全文搜索所有引用该行为的描述文本 |
🟢 低危级 —— 小麻烦
| # | Bug | 不修会怎样 | 路径A:转良性 | 路径B:修复思路 |
|---|
| 23 | 浮点数精度(0.1 + 0.2 !== 0.3) | 数据处理类偶尔差一点点 | ✅ 精度要求 < 10⁻¹⁵ →浮点不是 bug 是性能选择。文档写清楚 | 金额/精确计算用整数(存分不存元);或 toFixed() 控制显示精度 |
| 24 | 闭包捕获过期变量(for 里 var) | QuickJS 支持 ES2020,let 已解 | ✅ 故意用 var 让所有闭包共享同一个变量→设计,不是 bug | 用 let / const 替代 var;循环里用 forEach 天然隔离 |
| 25 | this 绑定丢失(回调里 this 变了) | OOP 风格沙盒包会踩 | ✅ 函数式风格天然免疫——不用 this 就不用管 | 箭头函数保持外层 this;或用 .bind();或直接不用 OOP |
| 26 | 路径大小写错误(Types ≠ types) | Linux 找不到文件 | ❌ 错了就是错了 | 规范:所有文件名全小写+连字符;tsc 编译时检查 import 路径大小写 |
| 27 | 删了 import 漏删调用 | ReferenceError | ❌ 引用错误没法转 | 删除 import 后全文搜索对应标识符;IDE 的「查找所有引用」 |
🔵 QuickJS 特有级 —— 只在 QuickJS 环境触发
| # | Bug | 不修会怎样 | 路径A:转良性 | 路径B:修复思路 |
|---|
| 28 | 大循环里 console.log(Java Bridge 每次往返) | 几百次 log→卡死 | ✅ 故意用 log 当进度条→容忍卡顿换可见进度。文档写清楚 | 只在关键节点 log;生产代码用标志位开关;或批量收集最后一次性输出 |
| 29 | JSON.parse 大文件阻塞 | 整个脚本卡住 | ❌ 阻塞就是阻塞 | 精确解析而非全量;限制输入大小上限;大数据走文件+摘要而非 stdout |
| 30 | 字符串 += 拼接(QuickJS 字符串不可变) | 数据量大时慢,但不致命 | ✅ 数据量恒 < 100 条→ += 比数组 join 更可读。文档写「数据量上限 X」 | 数组 .push()→.join('');模板字符串一次拼接 |
🟣 JS 通用陷阱 —— 沙盒包特化版(v2.5 新增,源自 Matt Pocock 蒸馏 + Search Vault 实战)
这些是 JS 语言层的经典陷阱。沙盒包(QuickJS)和 Node(V8)并非全共享——每条标注方言差异。🟢 = 两者皆有,🔵 = QuickJS 特有/更严重,🟠 = Node 有但 QuickJS 行为不同(沙盒包反而安全)。
| # | Bug | 方言 | 不修会怎样 | 路径A:转良性 | 路径B:修复思路 |
|---|
| 31 | Array.sort() 默认字典序([1,2,10].sort() → [1,10,2]) | 🟢 共有 | 数字排序全乱——搜索结果按"1, 10, 2"排列 | ✅ 数据永远单字符长度(如评分1-5)→字典序=数值序 | 数字排序始终传比较函数:.sort((a,b) => a-b) |
| 32 | 正则 /g 标志的 lastIndex 有状态 | 🟢 共有 | 同一正则 .test() 两次——第一次 true,第二次 false(因为 lastIndex 没重置)。搜索类沙盒包高频触发 | ✅ 只用一次就扔的正则不受影响 | 每次用前 .lastIndex = 0;或去掉 /g 标志;或每次 new RegExp |
| 33 | typeof null === "object" | 🟢 共有 | if (typeof x === "object") 把 null 当对象处理→后续 x.key TypeError | ❌ 这个语言 bug 没法转 | 空值检查写 x !== null && typeof x === "object";或 Object(x) === x |
| 34 | NaN !== NaN | 🟢 共有 | if (result === NaN) 永远 false→NaN 结果漏过校验 | ✅ 数据处理类不产生 NaN(只用整数运算)→不用管 | 用 Number.isNaN(x) 而非 x === NaN |
| 35 | parseInt() 不带基数 | 🔵 QuickJS 更严 | parseInt("09") 在旧引擎可能当八进制返回 0。QuickJS 遵循 ES5(默认十进制)→实际更安全,但不要依赖这个 | ✅ 不从字符串解析数字→不触发 | 始终写 parseInt(str, 10);或用 Number(str) |
| 36 | for...in 遍历原型链属性 | 🟢 共有 | 遍历 API 响应对象时把继承的属性也打印/处理了 | ✅ 遍历已知结构(自己写的对象)→原型链干净 | 用 Object.keys() / Object.entries() 替代;或用 hasOwnProperty 过滤 |
| 37 | delete 操作符把对象变慢模式 | 🔵 QuickJS 更明显 | QuickJS 无 JIT 优化→delete 退化的相对代价比 V8 更大 | ✅ 用 delete 清空一两个字段→影响可忽略 | 设 obj.key = null / undefined 替代 delete obj.key;或整体替换对象 |
| 38 | JSON.stringify 静默丢弃(undefined / 函数 / 循环引用) | 🟢 共有 | 返回结果里字段悄悄消失——不报错,只是数据没了。案例 K 已展开 | ❌ 数据丢失不能转良性 | 交付前 JSON.parse(JSON.stringify(result)) 验证往返;undefined 改 null;BigInt 转字符串 |
| 39 | switch 使用严格比较 === | 🟢 共有 | switch(true) 陷阱;或期望松散比较但实际用严格 | ✅ 只用 switch 做枚举值跳转(不涉及类型转换)→安全 | 复杂条件用 if/else;switch 只用于确定值的分派 |
| 40 | Object.create(null) vs {} | 🟢 共有 | 用 for...in 遍历时行为完全不同;hasOwnProperty 在 null 原型对象上不可用 | ✅ 只用字面量 {} →不受影响 | 如果用了 Object.create(null):用 Object.keys() 遍历;用 Object.hasOwn(obj, key) 替代 obj.hasOwnProperty(key) |
| 41 | == vs === 隐式类型转换 | 🟢 共有 | if (result == "0") 在 result 为 0 时意外为 true;"" == false →true。沙盒包处理 API 返回值/配置值时高频触发 | ✅ 只比较同类型变量(自己生成的数字只和数字比)→安全 | 始终用 ===;ESLint eqeqeq 规则;如果确实需要类型转换,显式 Number(x) 后再 === |
| 42 | ASI 自动分号插入(return 后换行返回 undefined) | 🟢 共有 | return\n{ data: 1 } → 返回 undefined,而非对象。沙盒包工具函数 return 语句高频触发 | ✅ 始终写 return {(左括号同行)→ASI 不会插分号 | return 后跟表达式时不换行;或用括号包裹:return (\n{ data: 1 }\n) |
| 43 | 数组浅拷贝(修改拷贝影响原数组) | 🟢 共有 | 复制配置对象/结果数据后修改→原数据也被污染。沙盒包多个工具间传递数据时高频触发 | ✅ 只读不写(复制后只读不做修改)→浅拷贝够用 | 需修改时用深拷贝:JSON.parse(JSON.stringify(obj));或 structuredClone()(QuickJS 可能不支持,需查) |
| 44 | ["1","2","10"].map(parseInt) 陷阱 | 🟢 共有 | map 传 3 个参数 (item, index, arr),parseInt 收了 index 当基数→ ["1","2","10"].map(parseInt) → [1, NaN, 2] | ✅ 不用 map 配 parseInt →不受影响 | 用 .map(x => parseInt(x, 10)) 或 .map(Number) |
🟣 JS 通用陷阱续:设计耦合(v2.6 新增)
| 50 | 检查机制自指递归(检查结果查自己,而非查原始数据) | 🟢 共有 | 检查/验证机制被设计为检查目标内容,但实际执行时读取的不是原始数据,而是「自己上次的检查结果」或「自己的中间状态」。两个子态:①递归自查——检查结果→再检查自己→再检查自己→无限镜像。②返回扭曲结果——返回的是「已经查过自己的自己」,原始数据被层层自引用包裹后失真。例如:健康检查读了上次检查报告而非系统原始指标;缓存校验返回的是上次查询结果的缓存→无限镜像;错误检测器发现「上次检测报告有错误标记」→报告错误→下次又检测到这个报告→死循环。排查时极难定位——检查机制本身看起来毫无问题,但每次输出的都是上次输出的影子。「检查是对的,但查的对象不是原始数据。」 | ✅ 检查函数无状态(不缓存结果、不引用自身输出)→不存在自指可能。每次检查 = 独立读取原始数据源 | ① 检查函数强制从原始数据源读取:每次检查重新获取原始指标/数据,禁止引用上一次检查的输出。② 检查结果加时间戳和来源标记:{check_time, source: "raw_system_metrics", result: ...}——如果 source 不是 "raw_*" 就报警。③ 自指检测:如果检查结果的 hash 和上一次完全一致(连续 N 次),说明可能在查缓存/自身输出——触发强制刷新原始数据源。④ 分离「检查」和「报告」:检查结果写入后不再被检查逻辑回读——报告层和检查层用不同路径
| 51 | 去重机制自噬(去重逻辑把自己从结果中删除了) | 🟢 共有 | 去重机制在处理数据时,自己的中间输出/状态痕迹混入了结果集——然后被自己的去重规则匹配并删除。例如:去重用的临时缓存键被当作输入条目;日志/调试输出混入结果列表后被去重过滤器匹配;去重后的结果摘要被再次送入去重→把自己当重复项删了→最终返回空集。报错不是「去重规则写错了」——是「去重规则把自己也当成了要去重的数据」。「去重成功了,成功到连自己都不剩。」 | ✅ 去重逻辑无状态、无日志混入、输出与输入完全隔离→不存在自噬可能。每次去重输入和输出用不同数据结构,不交叉引用 | ① 去重输入与输出用不同命名空间:输入列表叫 rawResults,去重临时 Set 叫 seen,输出列表叫 uniqueResults——三块内存不交叉。② 去重前先剥离元数据:如果输入条目带有去重标记/来源信息,先去标再进去重。③ 去重后验证:去重后结果数 ≥ 0 是正常的,但结果数 = 0 且输入 > 0 → 触发自噬检测——对比输入和去重 Set 的 overlap 是否包含非数据条目。④ 禁止日志写入结果流:去重/过滤函数不向输出数组写日志
| 56 | 缓存边界失控(缓存什么都吞——缓存自己、缓存源码、缓存无关内容) | 🟢 共有 | 缓存机制设计了「缓存什么」和「缓存多久」——但没画「什么不该缓存」这条边界线。三个变体:①自指递归缓存——缓存把缓存结果又当成新输入缓存了,调用者拿到的是「缓存的缓存的缓存…」——层层镜像,原始数据面目全非。②敏感内容泄露缓存——缓存把不该暴露的源码/核心逻辑/密钥当成普通数据缓存了,调用者翻缓存看到一堆不该出现的内部代码。③范围膨胀缓存——缓存边界太宽,把不属于这个判断/这个上下文的内容全吞进去了,本应只缓存相关结果,结果缓存了一堆毫不相干的垃圾。三个方向,一个根因:缓存机制假设「进来的都是该缓存的」——没人定义「什么不该缓存」。 | ✅ 缓存仅用于纯计算结果(数学运算、字符串变换)→不存在「不该缓存」的概念。或不使用缓存→无此问题 | ① 缓存白名单:显式声明哪些数据类型/来源可缓存,不在白名单里的一律不缓存。② 缓存内容审计:缓存写入后抽样验证——写入的内容确实是预期的数据而非源码/配置/上次缓存输出。③ TTL + 来源标记:每条缓存记录带 {source, cached_at, ttl}——来源不是白名单内的直接拒绝。④ 自指检测:缓存 key 和缓存 value 的 hash 如果相同→说明缓存把自己缓存了→触发告警 |
| 52 | 阻断机制误杀核心(熔断/限流把核心代码路径也掐了) | 🟢 共有 | 阻断机制(熔断、限流、安全沙箱、频率限制)被设计为保护系统——但在执行时无法区分「核心路径」和「边缘路径」。阻断条件只看表面指标(请求频率、数据大小、调用模式),不看「这段代码是不是心脏」。结果:频率限制把心跳包限了→系统认为挂了→更频繁发心跳→限得更狠;熔断器检测到高负载→熔断了主业务线程而非清理非关键路径→系统彻底不可用;安全沙箱把核心 API 调用当成危险操作拦截→核心功能静默失败。「保护机制成了最致命的攻击者——因为它掐的是心脏。」 | ✅ 系统只有一个功能、无核心/边缘之分→阻断任何东西效果一致。或阻断机制有白名单,核心路径显式豁免 | ① 阻断分级:核心路径(心跳、主业务、关键 API)设独立阈值,至少是边缘路径的 10 倍。② 白名单机制:核心功能注册白名单——阻断前先查「这是否在白名单里」。③ 阻断结果带诊断:{blocked: true, path: "heartbeat", reason: "rate_limit", threshold: 10, actual: 11}——运维能一眼看出「心跳被限了」。④ 阻断后降级而非静默:核心路径被阻断时返回降级响应,而非完全无响应——让调用方知道「还活着,但被限制了」
| 53 | 阻断链自噬(阻断机制把另一个阻断/防护机制也阻断了,真错误趁虚而入) | 🟢 共有 | 系统中不止一个阻断机制——A 负责限流,B 负责监控 A 是否误杀,C 负责错误恢复。但 A 在执行阻断时无法区分「这是普通请求」和「这是 B 的健康检查」和「这是 C 的恢复脚本」。结果:A 把 B 的健康检查限了→B 报告 A 挂了→A 被重启→重启后 A 又把 B 限了→死循环。或 A 把 C 的恢复脚本阻断了→错误没人修→小故障变成真停机。「阻断是对的——但它阻断的是唯一能救它的东西。」 | ✅ 系统只有一个阻断机制→不存在链。或阻断规则中显式豁免其他防护组件的通信 | ① 防护组件互信白名单:B 发往 A 的内部通信、C 的恢复请求——一律豁免所有阻断规则。② 阻断链检测:如果连续两次阻断都命中同一个内部组件 ID→触发自噬告警,强制放行。③ 内部通信走独立通道:防护组件之间不用公共 API 路径——用内部端口/特殊 header,阻断层看到直接放行 |
| 48 | 新壳旧核(版本声明已更新,实际执行逻辑未替换) | 🟢 共有 | 升级/热更新流程执行了——版本号变了、界面显示新版、CHANGELOG 写了新特性。但实际运行的仍是旧代码:旧文件残留未被覆盖、旧缓存未失效、或热加载时内存中的旧逻辑没被替换。用户看到「已更新到 v2.6」,体验却是旧版行为——甚至因为新壳与旧核不匹配(新参数名传不进旧函数、新配置格式旧代码读不懂),实际体验比纯旧版还差。「可以更新,但更新完体验更差了。」 | ✅ 单文件无缓存、无热更新机制→不存在壳核分离可能。每次更新 = 完整替换 | ① 更新后验证实际行为而非版本号:升级完跑一条标志性新功能的测试——新功能跑通了才算更新成功。② 旧缓存/旧编译产物强制失效:更新流程里加一步清缓存(内存缓存、编译缓存、CDN 缓存)。③ 版本号从代码中读取而非写死在文档:运行时动态输出 VERSION 常量,而非依赖 README/CHANGELOG。④ 旧文件清理:删除/覆盖旧版本文件后,立即读回验证内容是否为新版 |
| 54 | 合并污染新旧(合并操作把旧核心逻辑当成数据混入新机制) | 🟢 共有 | 两个结果集/两套机制合并时——Object.assign、数组 concat、spread ...——旧版本的核心逻辑/规则/配置被当成普通数据,合入了新机制。结果:新机制被旧逻辑污染——旧阈值覆盖了新阈值、旧排序规则混入了新结果、旧版本的默认参数覆盖了新版本的设计。合并本身没问题,新旧各自也都没错——但合并时没区分「这是数据」和「这是旧时代的规则」。「合并完成了——但新机制里跑着旧逻辑。」 | ✅ 新旧机制完全独立、不合并——各自维护各自的结果集。或合并前显式剥离所有非数据字段 | ① 合并前剥离元数据:只合并纯数据字段({url, title, snippet}),不合并规则/配置/阈值字段。② 深合并而非浅合并:用 structuredMerge 替代 Object.assign——嵌套对象逐层合并而非整层覆盖。③ 合并后验证:合并后检查关键阈值/规则字段是否仍为新版本的值——被覆盖了立即告警 |
| 46 | 去重正则冗杂反噬(优化代码比被优化的数据更重) | 🟢 共有 | 为了精简返回内容(去重、过滤、格式化),引入复杂的正则/过滤逻辑。数据源增多、格式变体增加→正则不断膨胀→最终正则/过滤代码的总字符数超过了被处理数据的典型大小。优化变成负担:每次改数据格式都要同步维护这套正则,正则本身比数据更容易出 bug(ReDoS、边界漏匹配)。「想去重,正则比原文还多」——优化反噬。 | ✅ 数据格式完全在自己控制下(如内部 API 返回结构固定)→不需要正则,直接用字段路径取值。去重用 Set + URL 规范化即可,一行搞定 | ① 先算账再优化:正则代码多少字符?每次处理的数据多少字符?如果正则 > 平均数据量,别优化了,直接返回。② 去重用 URL 规范化 + Set,而非正则匹配相似度。③ 输出瘦身用锚点截取(取前 N 字符 + 摘要),而非用正则清洗。④ 如果确实需要正则,把正则复杂度上限写进规范:正则长度不超过数据平均长度的 1/3 |
| 49 | 正则正确但去重误伤(正则逻辑对,但去掉了不该去的内容) | 🟢 共有 | 正则表达式语法正确、逻辑自洽、单元测试通过——但上了真实数据后,把有效结果也匹配进去了。URL 规范化的规则太激进(把不同页面的 ?page=1 和 ?page=2 归一化成同一个)、相似度阈值太宽(标题里一个词相同就当重复)、或去重窗口跨了不同搜索意图。正则本身没错——错的是「什么叫重复」这条边界没人定义过。AI 排查时看着正则挑不出毛病,只能一直反思。「正则是对的,结果却被去掉了。」 | ✅ 数据源唯一(单引擎、单 API)→不存在跨源重复,去重可关闭。或数据量极小(<20条)→人工扫一眼比正则自动去重更可靠 | ① 先定义「重复」再写正则:URL 完全一致才算重复?还是标题+域名?相似度阈值多少?文档化后再写匹配规则。② 去重规则做保留侧测试:拿一组「不该去重」的边界用例测——不同页面的分页链接、同一新闻不同来源、同义词不同主题——确认不会被误杀。③ 去重日志输出被去掉的内容:返回 {removed: [{url, reason}]}——排查时能看见谁被杀了。④ 正则用锚点收紧边界:^ $、完整 URL scheme、域名限定——缩小匹配域,减少误伤 |
| 45 | 熔断阈值与返回格式优化不同步(格式越原生→字节越大→正常搜全触发截断) | 🟢 共有 | 随着版本迭代,返回格式从格式化摘要逐步优化为原生API结构——内容越来越丰富、字节数持续增长。但熔断阈值(auto_save 上限、截断长度)停留在最初设定的值。结果:原本「偶尔超阈值才保存到文件」变成「搜10次没有一次正常返回,全部超过阈值保存到文件」。用户每次搜完都要手动点开文件查看——使用体验退化到最差。 | ✅ 数据量恒小(如只搜标题/URL)→阈值永远够用。文档注明「本工具仅返回摘要,不触发熔断」 | ① 阈值与格式耦合审查:每次改返回格式时,实测典型查询的字节数,对比熔断阈值——格式改了阈值必须同步调。② 阈值按比例而非绝对值:不写死 5000 字符,写成「典型结果字节数 × 3」或从配置读取。③ 渐进熔断:先截断到上限的 1.5 倍返回摘要,超过 3 倍再保存文件——而非一刀切。④ 返回摘要自带字节数:{truncated: true, full_size: 18432, preview_chars: 5000}——调用方知道「还有更多」,不用自己猜。 |
| 55 | 并发优先级倒挂(并发槽满了→不重要进程占着槽→重要进程被杀) | 🟢 共有 | 并发机制(线程池、连接池、并发槽)有资源上限——当槽满了需要腾位置时,调度器无法区分「核心进程」和「边缘进程」。它只看表面指标(CPU时间、内存占用、等待时长)做决策,结果:杀掉了核心业务进程,保留了不重要的后台任务。或者:新来的高优任务排队,低优任务占着所有槽慢慢跑——最重要的事在等,最不重要的事在跑。「并发是对的——但槽里全是凑数的。」 | ✅ 只有一个并发任务→不存在调度。或所有任务等权→杀谁都一样 | ① 优先级标记:每个并发任务注册时声明 priority: critical|normal|low——调度器杀了低优保高优。② 抢占机制:高优任务到达时,如果槽满且低优任务在跑→强制挂起低优、让位高优。③ 槽位预留:总槽数 N 中预留 1 个给 critical 任务——普通任务最多占 N-1 个槽 |
| 47 | 错误码张冠李戴(bug A 触发,系统报告 bug B 的病因) | 🟢 共有 | 错误处理链中,不同来源的错误在传递中被合并/重写。例如 HTTP 超时(本应 code:-3)在 catch 块里被笼统的 code:-2 覆盖;或 retry_trace 里三个引擎各自的错误原因被最后一个错误统一替换。开发者按错误码去修——修了 API 调用,实际是超时阈值该调。越修越远。「判断对了有 bug,但报的病因是别人的。」 | ✅ 只有一个错误源(单引擎、单 API)→不存在混淆可能。文档注明「本工具仅一个数据源,错误码直接对应」 | ① 错误传递链保留原始错误:不要在每个 catch 层重写错误码,用 originalError 字段保留最初错误。② retry_trace 逐条保留,不被最后一个覆盖。③ 错误码与 trigger 解耦:不依赖 try/catch 的层级推断错误类型,在 catch 到的第一时间判断 error.name / HTTP status 再映射错误码。④ 交付前走错误码自测:故意触发超时→确认返回 -3 而非 -2。故意给错参数→确认返回 -1 而非 -9 |
| 59 | 判断极性反转(true/false写反——bug被标记为「正常」) | 🟢 共有 | 布尔判断的条件写对了(该检查什么、怎么检查),但极性反了——if (isOk) 写成了 if (!isOk),return true 写成了 return false。代码逻辑自洽:条件语法正确、分支结构正确、能跑。但整个判断的意思反了:bug 触发时系统说「一切正常」,正常运行时系统报「出错了」。排查时极难发现——因为每个部件单独看都对:条件检查了正确的变量、分支做了正确的事——只是 true/false 装反了。「判断没毛病,毛病没判断。」 | ✅ 判断逻辑只用正向语义(只有 if 没有 !、只有 return ok 不做 return !ok)→不会反 | ① 布尔变量用肯定语义命名:hasError 而非 notOk、isValid 而非 isNotInvalid——少一层反转就少一次写错机会。② 判断后立即断言验证:开发期间在判断后加 console.log({condition, result, expected})——肉眼确认极性对。③ 单元测试覆盖两个路径:故意造一个「肯定有bug」和一个「肯定没bug」的输入——分别验证 true/false 分支 |
🔴 编译器/解释器级 —— 来自 kotlin-head 全栈编译器实战(v3.2 新增)
| 60 | 「先分类再处理」死循环(不是分类规则错了——是分类本身这个前提错了) | 🟢 共有 | 面对同一符号在不同上下文中有不同含义的场景(如 < 在泛型声明 vs 泛型调用 vs 比较运算),被训练成「必须先分类 token」。但分类规则在上下文交叉处必然出错——因为你在用一条规则回答一个需要看上下文才能回答的问题。学术界发论文加状态栈(2016 SLE,Autumn解析库),工业界切 lexer 模式(Kotlin编译器 parser context),全在改进分类——没人问「为什么要先分类」。结果:越改进越复杂,分类规则膨胀到能消化大部分但永远有边界漏掉。「答案在位置,不在类别。但所有人都在分类。」——免免,kotlin-head v0.5.2。「连我都差点掉进去。」 | ✅ 只处理同质数据(所有输入格式完全一致)→分类不会出错。或分类规则覆盖了所有已知情况且不扩展→暂时安全 | ① 删掉分类步骤:不预判 token/数据/请求的类别,用「它站在哪」替代「它是什么」。< 站在声明头→类型参数引入;站在类型名后→类型实参;站在表达式→比较。② 位置先于类别:任何设计判断先问「它在哪个结构位置」,后问「它属于哪个预定义类别」。③ 当发现自己在「改进分类规则」时——停下来,问自己「这个分类本身对吗?」 |
| 61 | 「特性叠加真空」—两个各自「安全」的设计特性,叠加后静默毁灭(来自 Kotlin 语言生态发现) | 🟢 共有 | 两个特性各自在自己的责任边界内被声明为「正确设计」——平台类型 T! 是「Java互操作的必要设计」,结构化并发取消是「协程安全语义的保证」。各自贴了「设计特性」标签,各自关掉工单。但两个特性叠加:平台类型让编译器闭嘴(不检查 null)→ 协程取消让运行时闭嘴(不报告传播)→ 盲+哑 = 数据静默损坏,零报错零日志零线索。两个团队各自签了字、各自无错——但特性之间那道接缝没有第三个人负责。「这是两个正确的东西之间的无人区。」——免免,kotlin-head v0.5.3 | ✅ 系统只有一个核心特性、无叠加可能→安全。或所有特性由同一团队维护、接缝有人测试 | ① 不信任各自声明,只看实际交互:「A安全」+「B安全」≠「A+B安全」。② 特性交叉矩阵:任何两个特性组合使用时,画它们的交互路径——编译器层怎么处理、运行时层怎么处理、副作用层怎么处理——三条路径交叉处是否有人站岗。③ 触发窗口窄 ≠ 不存在:「概率小到几乎无法察觉」和「出现就是死」同时成立——不是 bug 设计精巧,是它刚好越过所有人的警戒线。
| 57 | 初始化时序叛变(子进程篡父位 + 初始化顺序颠倒——依赖图没画箭头) | 🟢 共有 | 初始化依赖图里,谁先谁后、谁父谁子——这条线没人画。两个变体:①子进程篡位——子进程/子模块在初始化阶段反过来操控了父进程的状态或执行流。子本应是辅助/从属,结果父的配置被子改写、父的执行被子拦截、父的错误被子吞掉。父子身份在运行时悄悄对调——调用方调的是父,实际跑的是子。②初始化顺序颠倒——A初始化时把B的配置触发/暴露了,B不应该在这个阶段出现。配置本身正确、逻辑本身正确——但B在A还没准备好的时候就被激活了,报「配置缺失/依赖未就绪」之类的错,而真正的问题不是配置缺失——是B不该在这个时间点醒来。排查时看到配置正确、逻辑正确,无从下手。「一切都没错——只是叫早了一个。」 | ✅ 无子模块、无多组件初始化→单一启动流程,不存在时序竞争。或所有组件等权、初始化顺序无所谓 | ① 初始化依赖图显式声明:每个模块声明 dependsOn: ["config", "logger"],启动器按拓扑排序初始化。② 父进程对子进程的管控不可逆:子不允许回写父的状态——父暴露给子的只能是只读数据副本。③ 初始化阶段隔离:分 pre_init / init / post_init 三阶段——B 的激活只能在 post_init,此时所有依赖已就绪 |
| 58 | 媒介语言错位(用错误语言写正确内容——Python 写 Markdown、JSON 写散文) | 🟢 共有 | AI/开发者在处理文本类任务时,不自觉地用自己最熟悉的语言解决一切——该写 Markdown 却起了 Python 脚本,该写 JSON 配置却用 YAML 语法,该写纯文本却塞进了代码块。两个维度各自正确:内容本身没问题,Python 脚本也没问题——但 Python 脚本写在 SKILL.md 里就变成了「用代码编辑器打开散文」。排查时极难发现——因为每段单独看都对:这段 Python 逻辑正确,那段 Markdown 结构正确。但合在一起,读者对着 Markdown 渲染器看到满屏 for i in range(len(lines))。「工具和媒介都是对的——只是不该在同一页。」 | ✅ 内容全是同一种语言(全纯文本/全代码/全数据)→不存在错位。或只有自己能看懂、不在乎他人阅读成本→凑合能跑就行 | ① 写之前确认媒介:打开文件前自问——这份文件最终会被什么渲染/解析?Markdown 渲染器?JSON 解析器?还是 QuickJS?——然后只用那一种语言。② 跨语言操作隔离:如需用脚本批量编辑,脚本写在外面(临时文件/终端),不要嵌入文档正文。③ 交付前用目标渲染器预览:SKILL.md 用 Markdown 预览、JSON 用解析器 parse——确认不会解析出 Python 代码块当正文 |
⚠️ 核心教训:优化不是孤立的。 格式优化(更原生)→ 字节数增长 → 熔断阈值失效 → 正常使用变文件保存。两个各自合理的设计,放在一起变成了退化。教训:任何涉及输出大小的优化,必须同步审查所有依赖输出大小的下游机制。 详见 5.0 案例 L。
⚠️ 方言差异原则: 标 🟢 的,Node(V8) 和 QuickJS 都会踩——谁写都跑不掉。标 🔵 的,QuickJS 下代价更大或更易触发——沙盒包开发者尤其要注意。标 🟠 的暂未出现——如果将来发现 Node 有但 QuickJS 行为不同(沙盒包反而安全),再补。
3.2 优化借鉴(6条,来自v4.0.0性能/体验改进)
判断哪些优化现在就该做,哪些不需要——不需要就不做,别过度。
| # | 优化 | 策略 |
|---|
| a | 阈值适配实际负载 | 设默认值前先测典型场景数据量,别让日常触发紧急机制 |
| b | 输出瘦身 | 去格式垃圾、连续空行、无用文本;用锚点截取代替全量返回 |
| c | 用原生层替代封装层 | 能用内置fetch就不手写httpRequest——代码量和bug面同时缩 |
| d | 用行为检测替代类型继承 | 检测「有search()」而非「extends BaseEngine」,更灵活更少耦合 |
| e | 让组件自报元数据 | 让引擎声明resultField,而非上层硬编码遍历猜测 |
| f | 错误信息带阈值 | 「请求超时(15000ms)」而不仅是「超时」——调用方能据此决策 |
3.3 依赖可行性
| 依赖 | 可行 | 注意 |
|---|
| 外部HTTP API | ✅ | 超时+重试+错误处理 |
| Node内置模块 | ⚠️ | QuickJS精简版,查types/core.d.ts |
| 第三方npm | ❌ | 无安装机制,自己实现或用Java Bridge |
| Java Bridge | ✅ | 查types/java-bridge.d.ts |
| 文件读写 | ⚠️ | 仅/sdcard/下 |
| 外部MCP | ❌ | 沙盒包内部不能启MCP连接 |
4. Confirm —— 逐步确认,一个接一个
诊断完成后,向用户展示发现并逐步确认。不要一次性抛所有问题——按下面顺序,每确认一个再推进下一个。
Section A — 目标与方案
展示探索结果:找到了什么、缺什么、推荐什么方案。然后问:
目标:一句话描述这个包解决什么。方案:普通JS还是ToolPkg?理由。兼容层级:选哪个?(回顾1.4节)
让用户回答后再进入Section B。
Section B — 工具边界与依赖
展示工具设计草案:几个工具?每个输入/输出是什么?需要什么外部依赖?
让用户回答后再进入Section C。
Section C — 风险与优化预览
展示诊断结果:Bug清单中标记了哪几条、建议做哪几条优化。让用户确认或调整。如果用户想改优先级,照改。
确认完成后,展示一份开发草案(工具列表+目录结构+依赖清单+风险标记+兼容层级),让用户最后浏览一遍再动手。
5. Develop —— 动手写
⚠️ 5.0 第一次创建必读 —— 典型案例与冷门致命错误(v2.5新增)
在写第一行代码之前,先读这一节。 以下不是理论推演——是真实踩过的坑。AI 容易「不知道最重要的」,屡屡在同一个地方犯错。本节用案例形式列出,确保 AI 在动手前已经看过。
🔴 冷门但严重:三个最容易被忽略的致命错误
① 忘了先跑 install_or_update.js → 类型定义过旧 → 编译通过但运行时炸
这是频次最高的翻车模式。AI 自信「类型应该没变」,跳过安装脚本,直接用旧的 types/ 写代码。结果:tsc 零错误,沙盒脚本一跑就 TypeError——因为平台 API 已经变了但本地类型没同步。
硬规则:每次正式开始新的沙盒包开发任务前,必须先下载并执行一次 install_or_update.js。 哪怕「昨天刚跑过」也要再跑。这花 10 秒,但省的是几小时排查。
② 普通 JS 包的 METADATA 没有 version 字段 → 版本永远未知 → 出问题无法回溯
AI 交付了一个普通 JS 包,用户问「这是哪个版本?」——AI 答不上来。因为 METADATA 协议里压根没有 version。这是协议层的空白,不是 AI 的错——但 AI 如果不知道这件事,就不会主动补救。
硬规则:交付普通 JS 包时,AI 必须(1)口头告知版本号,(2)在 description 字段末尾标注 (vX.Y.Z)。 不依赖协议——靠约定。
③ 开发目录错了 → 文件散落各处 → 后续找不到/覆盖不了
AI 有时会直接在 /sdcard/Download/ 或 /sdcard/Download/Operit/ 下新建文件开始写,而不是在规范的 /sdcard/Download/Operit/dev_package/{packageId}/ 下。结果:文件散落、类型引用路径不对、后续 debug_run_sandbox_script 找不到入口。
硬规则:开发目录只能是 /sdcard/Download/Operit/dev_package/{packageId}/。types 目录放在 /sdcard/Download/Operit/dev_package/types/(与包目录同级,不在包目录内)。 AI 在创建第一个文件前必须确认路径正确。
🟠 典型案例:三个「做完了才发现不对」的教训
案例 A:「我想要个搜索工具」→ AI 直接开始写 ToolPkg → 做完发现普通 JS 就够了
用户只是想要一个调用新 API 的搜索工具。AI 没问形态,默认上了 ToolPkg——写了 manifest、注册函数、打包脚本。交付后用户问「为什么这么复杂?」,AI 才意识到普通 JS 包 30 行就能搞定。
教训:淘汰链走到「沙盒包」后,必须再走 1.3.1 的形态区分。 默认优先普通 JS 包——只有确认需要 UI/hook 时才升级到 ToolPkg。
案例 B:「基于已有包加个工具」→ AI 新建了一个包 → 用户困惑为什么有两个搜索包
用户说「给搜索加个图片搜索功能」。AI 理解为「新建一个图片搜索包」,而不是在现有 various_search.js 里加一个工具。结果两个包功能重叠、配置重复。
教训:Explore 阶段(第2节)必须检查已有包。 能扩展就不新建——确认「基于已有包继续开发」还是「独立新包」。
案例 C:版本号写在三个地方但互不一致 → 用户看到 v2.1 的代码运行 v2.0 的逻辑
AI 在代码注释、返回结果、口头告知里各写了一个版本号。改了一处忘了另外两处。用户按注释里的版本号排查,发现行为对不上。
硬规则:版本号集中单一定义(一个常量 const VERSION = "2.5.0"),其他所有地方引用。 这在 Bug #7(元数据不同步)里讲过——但 AI 经常忽略。
案例 D:「内置包能做到,我的包肯定也行」→ 运行时报 TypeError: xxx is not a function
AI 看到 Operit 内置包(如 super_admin.js)里用了 executeTerminalCommand() 或某个 Java Bridge API,直接在自己的沙盒包里照抄。结果运行时报错——因为那个 API 是平台内部注册的,不暴露给自建包。这是「同 JS 语言、不同方言」的典型翻车。
教训:不要假设内置包的能力自己也能用。 写之前先查 types/ 目录确认该 API 是否存在。如果 types/ 里没有,说明不对自建包开放——需要找替代方案或向用户说明限制。具体步骤: grep_code 在 types/ 里搜那个 API 名 → 命中说明可用 → 未命中说明是内置专有。
案例 E:「给 AI 用的工具,但每个功能都在迁就人类」→ AI 每调一次多绕三步
最经典的受众错位。沙盒包的 METADATA 写着「AI 搜索工具」,但每个工具都加了 confirm() 确认弹窗、返回大段自然语言描述而非结构化数据、参数名叫 theThingYouWantToSearchFor 而非 query。AI 调用时需要:绕过确认 → 从自然语言里提取数据 → 猜参数名的精确写法。一个本该 1 步完成的操作变成了 4 步。
反过来也一样:给人类做的配置界面,返回 {code: 0, data: {sc: true, ec: -1, rt: [...]}}——人类用户完全看不懂,每次操作都要查文档。
教训:先定受众,再定接口。 如果调用方是 AI(通过 use_package → package_proxy 链):零确认、纯数据、错误可编程。如果调用方是人类:有反馈、有解释、有 UI。混用是最差的选择。 详见 1.5.1 受众维度表。
案例 F:「tsc 零错误 → 一定没问题」→ 沙盒脚本一跑就 SyntaxError
AI 交付前跑了 tsc,零错误,自信交付。用户一跑:SyntaxError: Identifier 'autoSave' has already been declared。因为 tsc 只管类型,不管运行时语法——重复 const 声明、QuickJS 不支持的 ES2021+ 语法、?. 可选链在某些版本的行为差异,tsc 都不报。AI 把「tsc 通过」等同于「没问题」,跳过了 5.5 自测清单里的「跑一遍沙盒脚本验证」。
教训:tsc ≠ 沙盒测试。 交付前必须用 operit_editor:debug_run_sandbox_script 至少跑一条成功路径。tsc 只证明了类型正确,没证明能跑。Bug #1(重复 const)和 Bug #2(代码残留)永远逃过 tsc。
案例 G:「改了一个工具的参数名 → 没更新 METADATA → AI 调用时传旧参数名无效」
AI 在代码里把参数从 input_file 改成了 file_path,但忘了更新 METADATA 里的 parameters 声明。结果:Operit 平台按 METADATA 生成工具签名,AI 调用时传 file_path 被平台拒绝(参数名不匹配),但传 input_file 代码又不认。两端参数名割裂。
教训:改参数必须同步更新两处——代码里的函数签名 + METADATA 里的 parameters 数组。 这是 Bug #22(行为与文档不一致)的沙盒包特化版。检查方法: grep_code 搜旧参数名,确认全部替换。
案例 H:「环境变量写了但没在 METADATA 声明 → 用户不知道要配」
AI 在代码里用了 env.TAVILY_API_KEY,但 METADATA 的 env 数组是空的。结果:用户不知道需要配置这个 key,运行时报 undefined。AI 自己测试时环境变量已配好,测不出来。
教训:METADATA 的 env 数组必须列出所有环境变量。 AI 在交付前对照代码全文搜索 env.,确认每个引用都在 METADATA 的 env 数组里有声明。这是 API 误用(Bug #19)的环境变量特化版。
案例 I:「为'以后可能'写了继承体系 → 500 行代码只用到 30 行」
AI 设计了一个 BaseEngine 抽象类,带 preSearch()、postProcess()、validateResult() 六个 hook,三个子类各自 override。实际使用时只有一个搜索引擎,30 行 fetch → return 就够。多余的 470 行从来没人调用过,但每次改那个 30 行都要理解继承链。
教训:鸭子类型替代继承。 检测「有 search()」而非「extends BaseEngine」。等真的有第二个引擎时再抽象。性能准则 5.2 数据处理类第 1 条讲过——但 AI 总是提前抽象。
案例 J:「路径写死了 /sdcard/Download/search_vault/config.json → 换设备/换用户路径不兼容」
AI 在代码里硬编码了完整路径。换个设备目录结构不同→文件找不到→整个包不可用。更糟的是 AI 测试的设备刚好有这个路径,测不出来。
教训:路径用相对路径或平台 API 拼接。 必须用绝对路径时,从环境变量或平台 API 获取基础路径(如 Tools.Files.getSdcardPath()),然后拼接子路径。绝对不要硬编码 /sdcard/具体目录/。这对应自由判断边界——「文件路径规范:沙盒包 I/O 仅限 /sdcard/ 下」。
案例 K:「返回了不可序列化的对象 → 平台吞掉结果 → AI 收到空响应」
AI 的工具函数返回了包含 BigInt、undefined 值、或循环引用的对象。JSON.stringify 静默丢弃这些字段,平台层解析后 AI 收到的是残缺结果。不报错——只是数据悄悄没了。
教训:所有工具返回必须通过 JSON.stringify 可序列化。 交付前用 JSON.parse(JSON.stringify(result)) 验证往返一致性。BigInt 转字符串、undefined 改 null、检查循环引用。这是 QuickJS 特有坑——Node 的 JSON.stringify 行为可能不同。
案例 L(v2.6新增):「返回格式越优化 → 搜索结果越看不到」
这是 Search Vault v4.0 的真实教训。v4.0 把返回格式从格式化摘要改为原生 API 结构(更接近原始返回,信息量更大)——这是一项正向优化。但 auto_save 阈值仍然停留在格式化时期的 5000 字符上限。原生格式下,单次搜索返回轻松超过 15000 字符。结果:十次搜索没有一次正常返回到 stdout——全部触发熔断,保存到文件。用户每次搜完都要手动打开 /results/ 目录找 JSON 文件。
两个设计各自合理:原生格式 = 更多信息 = 好事。熔断截断 = 防止 AI 上下文爆炸 = 好事。但放在一起,变成了用户搜不到结果。
核心教训(免免原话):「如果但凡用熔断机制,也就是返回的字节太多,那在此之前先思考一下,返回内容优化怎么样。一开始可能真的正常,但随着越来越原生,它内容只会越来越多——因为越来越接近真实内容了。无论是搜索引擎还是其他信息类都很吃紧这种知识点,以至于他们优化忘了改熔断上限,以至于正常搜 10 次没有一次是正常返回,而是不是保存就是保存到文件。」
教训:熔断阈值必须与格式优化同步审查。 每次改返回格式时,实测典型查询的字节数 → 对比熔断阈值 → 阈值不够就同步上调。建议阈值设为「典型结果字节数 × 3」,从配置文件读取而非硬编码。参见 Bug #45。
⚠️ 这个教训的适用范围远超搜索引擎。 任何涉及「返回内容大小」和「截断/熔断机制」的信息检索类工具——API 聚合、文件处理、日志提取——都吃这个亏。两个独立设计的耦合点,恰恰是开发者最容易忽略的盲区。
🟡 补充:最容易让 AI「想当然」跳过的检查
| 跳过的检查 | 为什么 AI 会跳过 | 后果 |
|---|
不跑 install_or_update.js | 「上次跑过了,应该没变」 | 类型不匹配,运行时报错 |
| 不区分普通 JS / ToolPkg | 「用户没提 UI,默认就是普通 JS」——但也没确认 | 做完了形态不对 |
| 不检查已有包 | 「用户说做新的,肯定是新建」 | 功能重叠、配置冲突 |
| 不确认开发目录 | 「反正测试能跑」 | 文件散落,后续维护灾难 |
| 不告知版本号 | 「METADATA 没这个字段,不需要」 | 版本不可追溯 |
| 假设内置包 API 自己也能用 | 「都是 JS 沙盒包,没区别」 | TypeError,排查半天才发现权限不对 |
| 不区分 AI 用户和人类用户 | 「工具嘛,都能用」 | AI 被确认框卡死,或被自然语言输出淹没 |
| 跳过沙盒脚本验证 | 「tsc 都过了肯定没问题」 | 运行时 SyntaxError,tsc 管不了 |
| 改了参数名没同步 METADATA | 「改了代码就行,声明不重要」 | 平台拒绝新参数名,旧参数名代码不认 |
| 环境变量用了没声明 | 「代码里写了就行」 | 用户不知道要配 key,运行时报 undefined |
| 为'以后'提前抽象 | 「以后肯定用得上」 | 500 行继承链,实际只用 30 行 |
| 硬编码绝对路径 | 「测试能跑就行」 | 换设备/换用户→整个包不可用 |
| 返回不可序列化对象 | 「能返回就行」 | BigInt/undefined 被静默丢弃 |
| 不区分 Node(V8) 和 QuickJS 方言 | 「JS 嘛,都一样」 | Node 能跑的 QuickJS 不一定;或反过来多写了不需要的防御代码 |
| 不审查输出大小与熔断的耦合(v2.6新增) | 「改了返回格式而已,熔断又没动」 | 每改一次原生度,字节数悄悄涨。某天用户发现正常搜索全是「保存到文件」 |
| 去重正则比数据还重(v2.6新增) | 「去个重而已,写个正则就行」 | 正则越写越长,最后正则本身比要处理的数据还多。每次改数据格式都得同步维护正则 |
| 错误码报错病因(v2.6新增) | 「反正返回了错误码,错不了」 | 触发的是 bug A,返回的错误码却指向 bug B 的原因。开发者按错误码修,修错了地方 |
| 新壳旧核——版本号新但跑的是旧代码(v2.6新增) | 「版本号都变了,更新肯定成功了」 | 壳是新的,核是旧的。用户看到 v2.6,实际跑的是旧版逻辑,甚至因为壳核不匹配体验更差 |
| 正则对但去重误伤(v2.6新增) | 「正则没问题,肯定不是去重的锅」 | 正则语法正确、逻辑自洽,但「什么叫重复」的边界没定义——把不该去的结果也去掉了。AI 看着正确正则排查到怀疑人生 |
| 检查机制自指递归——查自己而非原始数据(v2.6新增) | 「检查逻辑又没错,肯定不是这里的问题」 | 检查机制读的是自己上次的输出,而非原始数据。每次检查都在查上次的影子——递归自查或返回扭曲结果。排查时极难定位 |
| 去重机制自噬——把自己也去掉了(v2.6新增) | 「去重逻辑没毛病,怎么可能」 | 去重机制把自己的中间输出/缓存键也当成数据去重了——去重成功到连自己都不剩,返回空集 |
| 阻断机制误杀核心——保护者掐了心脏(v2.6新增) | 「限流/熔断是保护系统,没毛病」 | 阻断条件只看表面指标,不分核心/边缘。心跳被限→系统假死→心跳更频繁→限得更狠 |
| 阻断链自噬——阻断把唯一能救它的也阻断了(v2.6新增) | 「阻断是保护系统,多一层更安全」 | A阻断B的健康检查→B报告A挂了→A被重启→重启后A又阻断B。真错误趁虚而入 |
| 合并污染新旧——旧核心混入新机制(v2.6新增) | 「合并嘛,不就是拼一起」 | Object.assign/spread把旧规则/旧阈值当成数据合入新机制。新壳里跑着旧逻辑 |
| 并发优先级倒挂——重要的事在等,不重要的事在跑(v2.6新增) | 「并发嘛,谁跑都一样」 | 调度器看不出核心/边缘——杀了核心保了凑数的。槽全被低优占着,高优在排队 |
| 缓存边界失控——什么都吞,缓存自己、缓存源码、缓存无关垃圾(v2.6新增) | 「缓存嘛,进来就存」 | 缓存没画「什么不该缓存」的线。①自指递归——层层镜像。②敏感泄露——源码暴露。③范围膨胀——吞了不相关的 |
| 初始化时序叛变——子篡父位 + 顺序颠倒(v2.6新增) | 「初始化嘛,挨个调就完了」 | 依赖图没画箭头。①子进程反过来操控父进程——父子身份对调。②B在A还没准备好的时候被激活——叫早了一个 |
| 媒介语言错位——Python写Markdown,JSON写散文(v2.6新增) | 「能跑就行,语言无所谓」 | AI用最熟的语言解决一切。SKILL.md里塞Python脚本,Markdown渲染器输出 for i in range(len(lines)) |
| 判断极性反转——true/false写反,bug被标记为「正常」(v2.6新增) | 「不就是个if嘛,不可能写反」 | 条件逻辑全对,极性反了。bug触发时系统说「一切正常」——戴着良民证大摇大摆 |
⚠️ 这些不是技术错误——是流程跳过。AI 的逻辑是「不重要所以跳过」,但恰好这些是最重要的。
确认全部通过后,查平台的沙盒包开发文档(Skill/参考手册)——其类型定义、参考实现、示例是完整的开发手册。用检索工具按需查找,不要整文件诵读。
- 开发目录:按所在平台沙盒包开发规范确定路径
- 类型定义目录:从平台的沙盒包开发工具的类型目录复制
- 优先使用平台的编译工具(如tsc)编译 ts→js
5.1 接口设计规范 —— 让任何调用方都舒服
根据Shape阶段选定的兼容层级,采用对应的接口契约。核心原则:不管调用方是AI、另一个沙盒包、还是外部API——返回格式、错误处理、超时行为一致。
统一返回包装
所有工具函数返回同一结构:
{ success: true, data: {...}, elapsed_ms: 42 }
{ success: false, error: "描述+步骤+可能原因", code: -3, elapsed_ms: 15000 }
错误码体系
| 码 | 含义 | 示例 |
|---|
| 0 | 成功 | — |
| -1 | 参数错误 | 缺少必填字段 |
| -2 | 外部API错误 | HTTP 4xx/5xx |
| -3 | 超时 | 请求超过阈值 |
| -4 | 文件I/O错误 | 路径不存在/权限不足 |
| -5 | 依赖缺失 | 需要配置但未提供 |
| -6 | 解析错误 | JSON格式异常 |
| -7 | 版本不兼容 | 数据格式版本不匹配 |
| -8 | 降级运行 | 部分成功,走fallback |
| -9 | 未知错误 | 带原始错误信息 |
故障追踪链(🟠🔴层级强制,🟡层级建议)
失败时附带 retry_trace —— AI/调用方可精准定位故障点,而不只看最后一个错误:
{
success: false,
error: "所有搜索引擎均失败",
retry_trace: [
{ engine: "tavily", key: "TAVILY_KEY_1", error: "HTTP 429" },
{ engine: "tavily", key: "TAVILY_KEY_2", error: "超时(15000ms)" },
{ engine: "google", key: "GOOGLE_KEY_1", error: "403 Forbidden" }
]
}
性能标记(🟡及以上层级)
每个工具返回 elapsed_ms:请求耗时,毫秒。调用方不需要计时器就能评估性能。
5.2 按类型性能准则 —— 「比别人快一档」
不是泛泛的「注意性能」。按你的沙盒包主要做什么对号入座:
搜索类(多API聚合)
| 策略 | 为什么快 |
|---|
Promise.all 并发多引擎,但用计数器限制并发数 | 并行=快,但无上限并发=崩 |
结果去重:URL规范化后再 Set | 三引擎返回同一条链接=浪费传输 |
| 输出瘦身:去格式垃圾、连续空行、截断过长snippet | stdout也是IO,瘦了=快 |
| 熔断阈值+自动保存:超阈值不截断,写文件返摘要 | 避免AI被迫再查一次。⚠️ v2.6:熔断阈值必须与格式优化同步审查——每次改返回格式(更原生、更丰富),必须实测字节数并上调阈值。否则正常搜索会退化成全量保存到文件(见Bug #45、案例L)。建议阈值按「典型结果字节数 × 3」设置,而非固定绝对值 |
用原生 fetch 而非手写 httpRequest 封装 | 代码从67行缩至~10行,调用栈更浅 |
文件处理类
| 策略 | 为什么快 |
|---|
| 配置读加内存缓存(如5s cache-hit) | 每次都读盘=每次等IO |
| 写操作即时清缓存 | 保证一致性 |
| 大文件分块处理,不全量加载 | QuickJS内存有限 |
| 路径兼容:三级自动回退 | 适配Android/Termux/纯Linux |
网络请求类
| 策略 | 为什么快 |
|---|
AbortSignal.timeout(n) 主动掐断 | 不靠服务端响应超时 |
| 确认HTTP库自动处理gzip解压 | 不解压=白传 |
| 连接复用 | 减少握手开销 |
| 超时信息带阈值 | 「请求超时(15000ms)」→调用方可据此调整 |
数据处理类
| 策略 | 为什么快 |
|---|
鸭子类型替代继承:「检测有 search()」而非「extends BaseEngine」 | 更少耦合、更灵活、更少代码 |
让组件自报元数据(如 static resultField) | 上层不遍历硬编码字段猜测 |
| semver解析器替代字符串比较 | 10.0.0 < 9.9.9 不会发生 |
| 缓存计算结果:version号集中单一定义 | 不重复算 |
5.3 QuickJS 性能陷阱 —— 原生级兼容不等于快
QuickJS 没有 JIT。同样的代码,这里可能比 Node 慢10-100倍。以下每条都是真实踩出来的:
| 陷阱 | 表现 | 解法 |
|---|
| JSON解析大量数据 | JSON.parse() 阻塞整个脚本 | 精确解析而非全量;考虑流式 |
大循环里 console.log | 每次调用都是Java Bridge往返,成百上千次=卡死 | 只在关键节点log;生产代码全关 |
| 字符串拼接大量数据 | QuickJS字符串不可变,每次 += 新建对象 | 数组 .push()→.join('') |
| 不缓存外部数据 | 每次都读盘/调API | 短期内存缓存(5-30s) |
| 深层嵌套循环 | O(n²) 在QuickJS上更快摸到天花板 | 尽早return,短路求值 |
忘了 await | Promise对象被当值用,不报错但不对 | 凡是返回Promise的,确认有await或.then |
⚠️ 黄金法则:在QuickJS里,「能不做就不做」比「做得快」更重要。 减少操作次数 > 优化单次操作。
5.4 常见手误
编译通过不等于对。下面这些SyntaxError不会出现在lint里,但Search Vault实战中全踩过:
| 手误 | 表现 | 检查方法 |
|---|
重复 const 声明 | SyntaxError: Identifier has already been declared | 全文搜索变量名,确认无同名const/let |
| 赋值写成比较 | if (x = 5) 永远true | 条件表达式里等号数量自审 |
| 异步忘await | Promise对象被当成值使用 | 凡是调用返回Promise的函数,确认有await或.then |
| 路径拼错大小写 | Linux区分大小写,Types≠types | 文件路径全部小写或严格匹配 |
| 删了import漏删调用 | ReferenceError: xxx is not defined | 删除import后全文搜索对应标识符 |
| 空catch吞错 | catch(e) {} 什么都不做 | 至少 console.warn(e) |
5.5 交付前自测
代码写完不是终点。跑完下面8条才算交付:
6. Debug —— 跑不起来时,AI 该做什么
⚠️ 定位: 第5节教你写对。第6节教你:写对了但跑不起来——怎么查。
AI 最常见的失败模式:用户说「包报错了」→ AI 看着代码猜一个原因→建议改一行→用户试了没用→AI 再猜→三轮后用户放弃。猜不是调试。调试是系统化的排除法。
6.1 错误现场采集 —— 先拿全证据,再开口
AI 收到「包有问题」的第一反应必须是索取完整错误信息,而非分析代码:
必须拿到手的四样东西:
| 证据 | 为什么必须 | 怎么拿 |
|---|
| 完整错误栈 | 报错行号+调用链=唯一可靠定位。光说「报了个错」等于没给 | 让用户贴全部 stderr 输出 |
| 触发操作 | 同一个 bug 在不同输入下表现完全不同 | 问:你做了什么操作触发的? |
| 环境确认 | 类型定义版本、QuickJS版本、平台版本——代码对但环境不对照样炸 | 让用户跑 install_or_update.js 确认 types 最新 |
| 是否首次 | 「改了什么之后开始报错」vs「一直就这样」——排查方向截然不同 | 问:是新写的包第一次跑?还是之前能跑改了之后不行? |
⚠️ 拿到以上四样之前,不要做任何修改建议。 这个阶段 AI 只有一个动作:收集。
6.2 错误栈速查 —— 从报错信息直接定位根因
拿到错误栈后,按报错类型匹配——不是猜,是查表:
| 报错信息关键词 | 最可能的原因 | 优先检查 |
|---|
SyntaxError: Identifier 'X' has already been declared | 重复 const/let 声明 | Bug #1——grep 该变量名全文 |
ReferenceError: X is not defined | 删了声明漏了调用;或 import 路径错 | Bug #2——grep 该标识符全文 |
TypeError: X is not a function | 内置包 API 被误用;或忘 await(拿到的 Promise 对象当值调) | Bug #3 + 案例 D——查 types/ 确认 API 存在 |
TypeError: cannot read property 'X' of undefined | 外部 API 返回缺字段;空值无 fallback | Bug #5——检查可选链 ?. 和默认值 |
SyntaxError: unexpected token | QuickJS 不支持该 ES 语法(如 ??=、某些 class 特性) | 对照 QuickJS ES2020 支持列表;改用兼容写法 |
Error: timeout 或请求卡死不返回 | HTTP 超时未设或阈值太低 | Bug #9/#10——检查 AbortSignal.timeout() |
JSON Parse error | API 返回了非 JSON(HTML 错误页、纯文本) | Bug #6——先检查 HTTP status,再 parse body |
| 不报错但结果为空 | 静默失败:catch 吞错、JSON 丢弃字段、熔断截断 | Bug #15 + #38 + #45——加 console.log 逐段确认数据还在 |
| 包加载失败 / METADATA 解析错 | METADATA JSON 格式错误;或文件名与包 ID 不匹配 | 检查 /* METADATA { ... } */ 是否为合法 JSON;尾逗号=炸 |
elapsed_ms 异常高(远超 5.2 基准) | 循环里 log;JSON.parse 大文件;字符串拼接 | 5.3 QuickJS 性能陷阱——逐条对照 |
6.3 debug_run_sandbox_script 的正确用法
这不是一个「跑一下看看」的工具。它有三个模式,选错模式等于白跑:
| 模式 | 命令 | 什么时候用 | 看什么 |
|---|
| 语法检查 | 加载脚本不调工具函数 | 刚写完第一版,确认能加载 | 看是否报 SyntaxError / 加载错误 |
| 单工具验证 | 指定工具+固定参数跑一条 | 交付前自测(5.5节第6条) | 看 success / data / elapsed_ms |
| 边界输入测试 | 同一工具,给空参数/错误类型/超长字符串 | 怀疑某工具对异常输入处理不当 | 看是否崩溃、是否返回合理错误码 |
⚠️ AI 在建议用户跑测试时,必须指定是哪个模式+给什么参数。 不能说「跑一下测试」——那是让人猜。
6.4 系统化排查流程 —— 从怀疑到确认
当错误栈不能直接定位时,走下面这个流程。顺序不能乱——每一步排除一个变量:
1. 确认 types 是最新的?
└─ 不是 → 跑 install_or_update.js,重试
└─ 是 ↓
2. 确认代码在沙盒里能加载吗?(不调工具,只看加载)
└─ 不能 → 检查 METADATA JSON 格式;检查文件编码(UTF-8 without BOM)
└─ 能 ↓
3. 用一个最简单的工具+最小输入跑一次?
└─ 不通过 → 代码逻辑问题。二分法注释掉一半代码→定位到具体行
└─ 通过 ↓
4. 用出问题的那个输入再跑一次?
└─ 不通过 → 问题在输入处理。检查参数类型、空值、特殊字符
└─ 通过 ↓
5. 能复现吗?(同一输入多次跑,结果一致?)
└─ 不一致 → 竞态条件 / 缓存问题 / 时间依赖。Bug #9 / #50 / #56
└─ 一致 → 特定输入的边界问题——缩小输入范围定位触发条件
⚠️ 二分法定位(第3步): 注释掉工具函数的下半部分代码→跑一次→如果通过了,问题在注释掉的代码里→取消注释一半→再跑。三次就能定位到具体行。比「看着代码猜」快得多。
6.5 调试时最常见的三类误判
| AI 容易说的 | 为什么是误判 | 应该怎么做 |
|---|
| 「可能是 QuickJS 版本问题」 | QuickJS 版本极少变动。90% 的情况不是版本——是形式语言用错了(案例 D) | 先查 types/,确认 API 是否存在 |
| 「加个 try/catch 包起来试试」 | 不定位根因就包 try/catch = 把 bug 藏起来。下次换个输入照样炸 | 先走 6.4 排查流程定位根因,再决定是兜底还是修复 |
| 「代码看起来没问题,可能是平台 bug」 | 平台 bug 概率 <1%。说自己代码没问题之前,先跑完 5.5 自测清单全部 8 条 | 带着自测结果 + 完整错误栈再来讨论是否平台 bug |
7. Anti-patterns —— 架构方向错了
⚠️ 定位: 案例 A-L(5.0节)是「写错了」——语法对但行为错。反模式是「方向歪了」——每个部分都对,但合在一起是错的。反模式排查更难——因为分开看全对。
7.1 上帝包
特征: 一个包注册了 8+ 个工具,涵盖搜索、文件处理、网络请求、数据转换……什么都能干。
为什么是反模式:
- 改任何一个工具都可能影响整体稳定性
- 新用户看 METADATA 工具列表不知道这个包到底干什么的
- 一个工具超时/崩溃 → 整个包被平台标记为不可用
识别方法: 数工具数量。>5 个且分属不同领域(如既有搜索又有文件处理)→ 上帝包。
拆分原则: 按「数据源」或「动作类型」拆。搜索类一个包、文件处理一个包、网络请求一个包。每个包的描述能一句话说清。
7.2 微包地狱
特征: 每个工具一个包。搜 Tavily 一个包、搜 Google 一个包、搜必应一个包。调用方要装三个包才能搜全。
为什么是反模式:
- 调用方(AI)需要知道该调哪个包——搜索意图→判断用哪个引擎→加载对应包→调用。多两步就是多两次出错机会
- 共享逻辑(去重、格式化、熔断)要写三份或抽公共包→又多一个包
- 用户维护三个环境变量、三个版本号、三份 CHANGELOG
识别方法: 多个包共享同一类工具签名、处理同一类数据、解决同一个用户需求的不同子集。
合并原则: 同一个用户意图的多个实现 → 合并到一个包,用参数区分引擎/数据源。参考 various_search:一个包,多个引擎,参数选。
7.3 配置即代码
特征: 环境变量里塞了正则表达式、JSON 路径、甚至 JavaScript 表达式字符串。配置文件看起来像代码。
为什么是反模式:
- 配置文件没人做语法检查——少一个括号整个包静默失败
- 用户改配置就是在改逻辑——但没有 tsc 保护、没有 lint、没有测试
- 「改个阈值为什么包崩了?哦,配错了没报错」
识别方法: 环境变量的值里有正则字面量(/pattern/flags)、eval 风格的模板字符串、或嵌套 JSON 超过两层。
修复: 配置只管「是什么」(阈值数字、开关布尔、API key 字符串)。逻辑留在代码里——代码有 tsc 和测试保护,配置没有。
7.4 静默降级链
特征: 主 API 失败 → 降级到备 API → 备 API 也失败 → 返回空结果,全链路没有一条 console.warn。
为什么是反模式:
- 主 API 挂了一周、降级 API 也挂了一周——用户以为「搜不到」,实际是「两个都挂了没人知道」
- 排查时无从下手:不知道哪个环节停的、不知道什么时候开始停的
识别方法: 检查所有 fallback/降级路径。如果没有 console.warn 或 retry_trace → 静默降级。
修复: 降级路径至少输出:哪个环节失败了、降到了哪个备用、备用是否成功。用 retry_trace(5.1 节)是标准做法。
7.5 假幂等
特征: 文档说「重复调用不产生副作用」,但实际每次调用都在写文件、修改全局状态、或累加计数器。
为什么是反模式:
- AI 信任幂等声明,放心重试 → 结果文件被覆盖、计数器翻倍、副作用叠加
- 调用方能接受的最坏情况是「多调了一次」,实际却是「数据被污染了」
识别方法: 同一个工具用相同参数连续调两次。检查文件内容、全局变量、返回值——两次是否完全一致?不一致 → 假幂等。
修复: 要么实现真幂等(写操作前检查是否已写过;用唯一键去重),要么从文档里删掉「幂等」声明,注明「重复调用会 X」。
7.6 硬编码阈值传染
特征: 超时阈值写死 15000、熔断上限写死 5000、重试次数写死 3。这些数字散落在代码各处,互不知道对方存在。
为什么是反模式:
- 改了超时忘了改熔断 → 请求在等超时,熔断先触发了
- 换个网络环境(WiFi→移动数据)所有阈值都该调——但要改七八个地方
识别方法:
grep 代码里的裸数字(15000、5000、3)。如果同一个数字出现在多处且含义不同 → 硬编码传染。
⚠️ v4.4.0 硬编码清零行动 —— 实战教训(v3.0新增):
不是只有 bug 才最严重。硬编码大军——那些看起来无害的数字、固定字符串、写死的阈值——正常使用时完全感受不到,但真正出问题的时候会哭死。
五类暗雷,全部来自真实凌晨三点:
| 暗雷 | 症状 | 解法 |
|---|
| 配置项存在但代码没读 | config.pending_ttl_ms 设了 300000,代码里 setTimeout(..., 300000) 毫秒不变——改了配置等于没改 | 所有阈值从 config 取值,?? 回退到默认,永不裸数字 |
| 退避白等最后一次 | 最后一个引擎最后一个 key 失败了还退避几秒才报错——用户干等着,你没有更多牌了 | if (isLastKey && isLastEngine) break; ——没牌了就别等了 |
| testCredentials 永远搜同一个词 | 那个词可能被缓存、限流、特殊处理。测试通过不代表引擎能用 | 测试词可配:config.test_query ?? "hello" |
| 截断写死在逻辑里 | title.substring(0, 80)、snippet.substring(0, 120) ——今天刚好,明天换展示场景就炸 | 截断值不是逻辑,是偏好:config.summary_title_cap ?? 80 |
| 引擎上限散落各处 | Tavily 写死 20、Google 写死 10、SerpAPI 写死 100——改一个漏三个 | 统一 config,每个引擎独立可配 |
硬编码不是"暂时写死,以后再说"——以后不会再说。 凌晨三点对着一个硬编码的 5 分钟 TTL 骂自己,不如现在多写一行 ??。
修复: 阈值集中到配置对象顶部的常量区,命名字段而非裸数字。所有裸数字、裸字符串都问自己:「这个值会变吗?变了会炸吗?」——会 → 进 config。参见 Bug #45 + 案例 L。
7.7 反模式速查表
| 反模式 | 一句话症状 | 最坏后果 |
|---|
| 上帝包 | 一个包什么都干,工具列表比 README 长 | 改一处崩全身 |
| 微包地狱 | 搜个东西要装三个包 | AI 不知道该调谁 |
| 配置即代码 | env.SORT_RULE = "/pattern/flags" | 配错了不报错 |
| 静默降级链 | 两个 API 都挂了,返回空——没 log | 挂了没人知道 |
| 假幂等 | 声称安全重试,实际每次都在改状态 | 重试变污染 |
| 硬编码阈值传染 | 超时 15000 写死在 6 个地方 | 改一个漏两个 |
8. Interop —— 包间协作
⚠️ 定位: 前 7 节都在教你做一个独立的包。第 8 节教你:你的包被别的包调、或你的包调别的包——会发生什么。
8.1 包间调用的本质
在 Operit 平台上,包 A 调包 B 的工具,走的是和 AI 一样的路径:use_package → package_proxy。这意味着:
- 每次调用都是一次完整的工具往返——不是内存里的函数调用,是序列化→传输→反序列化→执行→序列化返回
- B 的任何变更(参数名、返回格式)直接炸 A——没有编译期检查
- 错误可能跨包传播并变形——B 返回
{success:false, code:-3},A 的 catch 块可能把它重写成 code:-2
8.2 依赖声明
如果包 A 的工具需要调包 B,在 METADATA 的 description 或包文档里明确写出:
依赖:various_search (>= v2.0.0)
用途:搜索工具的内部 fallback
不声明依赖的代价: 用户装了 A 但没装 B → A 运行时报 use_package 失败 → 用户不知道缺了什么。
8.3 循环依赖
检测方法: 画依赖图。A→B,B→A = 循环。A→B→C→A 也是循环。
为什么危险:
- 初始化顺序不确定——A 需要 B 早已就绪,B 需要 A 早已就绪 → 两个都在等对方
- 一个包超时 → 调用它的包也在等 → 连锁超时
解决方案(按优先级):
- 抽取共享逻辑:A 和 B 都需要的那段代码抽成独立函数,放到其中一个包里或新建底层包 C。A 和 B 都只依赖 C——没有 A↔B 直接依赖
- 单向依赖:如果必须 A 调 B 且 B 也需要 A 的某些功能,把 A 需要的部分从 B 里移走,让 B 单向依赖其他
- 事件/回调解耦:不直接调用,而是 B 暴露钩子,A 注册回调——本质上还是单向,但控制反转
8.4 跨包错误传播
被调包失败时,调用方 AI/包不能只看到「失败了」——需要知道哪个环节、为什么、是否可重试:
| 被调包返回 | 调用方 AI 应该 | 调用方 AI 不应该 |
|---|
{success:false, code:-3, error:"超时(15000ms)"} | 知道是超时→可以加长阈值重试 | 猜测「可能是网络问题」 |
{success:false, code:-2, error:"API key 无效"} | 知道是配置问题→提示用户检查 key | 重试——重试多少次 key 也不会变对 |
{success:false, code:-8, error:"降级运行", data:[...]} | 拿到部分结果→决定是否够用 | 直接丢弃 data——降级不是失败 |
依赖包的铁律: 如果你的包被其他包依赖(🟠跨包调用层级),你的错误返回必须包含 retry_trace。调用方需要知道你的内部发生了什么(哪个引擎挂了、哪个重试成功了)——不是为了监控你,是为了决定自己接下来怎么做。
8.5 版本兼容契约
被其他包依赖的底层包(🟠层级),遵守以下契约:
| 变更类型 | 兼容 | 调用方炸不炸 |
|---|
| 新增工具 | ✅ | 不炸——旧调用方不调新工具 |
| 新增可选参数(有默认值) | ✅ | 不炸——旧调用方不传=走默认值 |
| 工具返回新增字段 | ✅ | 不炸——旧调用方忽略不识别的字段 |
| 修改必填参数名 | ❌ | 炸——旧调用方传旧参数名,工具不收 |
| 删除工具 | ❌ | 炸——调用方找不到该工具 |
修改错误码含义(-3从超时改成参数错误) | ❌ | 隐性炸——不报错但调用方处理逻辑全错 |
修改返回字段类型(data 从对象变数组) | ❌ | 炸——调用方的 data.key 变成 undefined |
⚠️ 底层包(🟠层级)的 first rule:宁可新增也不要修改。 旧工具保留、旧参数保留、旧返回字段保留。新功能给新工具或新可选参数。删除 = 版本炸弹。
8.6 包间调用的性能考量
跨包调用比同包内函数调用慢得多——序列化开销 + 平台调度延迟。高频路径要算账:
| 场景 | 建议 |
|---|
| 每次搜索都跨包调去重逻辑 | ❌ 不好——去重逻辑直接复制到搜索包内,或抽成共享函数 |
| 每 100 次搜索调一次健康检查 | ✅ 可接受——低频不值得合并 |
| 初始化时跨包读配置 | ✅ 可接受——只调一次,缓存结果 |
判断标准: 如果跨包调用的 elapsed_ms 占比超过总耗时的 20%→考虑合并或缓存。
9. Node.js Skill vs QuickJS 沙盒包 —— 本质区别与选型(v3.0新增)
⚠️ 为什么加这一章: 前 8 节默认你在写 QuickJS 沙盒包。但 Search Vault 自身就是 Node.js Skill——它经历了完整的 v4.0→v4.4.0 迭代,踩过的坑 QuickJS 沙盒包根本遇不到。这一章纠正一个致命假设:「所有 Operit 包都是 QuickJS」。
9.1 本质区别
| 维度 | QuickJS 沙盒包 | Node.js Skill |
|---|
| 运行时 | QuickJS(轻量、ES2020) | V8(完整 Node API) |
| 生存周期 | 临时——调一次启一次 | 长驻——平台注册后持续可用 |
| API 面 | types/ 目录里有的才可用 | Node 全栈:fs、path、http、npm 生态 |
| 通信 | stdin/stdout 文本管道 | 平台内部函数调用 |
| 部署 | .js/.toolpkg 直接上传 | 平台 skill 系统注册 |
| 适用场景 | 一次性脚本、简单工具、纯计算 | 复杂网络操作、多版本分发、持久化状态 |
| 典型代表 | 临时数据处理、格式转换 | Search Vault、various_search |
9.2 选型决策
问自己两个问题:
- 这个包需要 HTTP 请求到外部 API 吗? → 是 → Node.js Skill。QuickJS 沙盒包虽然也能发 HTTP,但你的 API key、密钥轮换、超时重试逻辑在 Node.js 里调试远比在 QuickJS 受限环境里方便。
- 这个包会被分发给不同的人、不同版本吗? → 是 → Node.js Skill。沙盒包是平台绑定的——你的沙盒包别人用不了。Node.js 脚本可以
node xxx.js 直接跑,lite/personal/enterprise 随便切。
Search Vault 两条都占:四个搜索引擎全是 HTTP API + 六个版本分发给六种人。所以它是 Node.js Skill,不是 QuickJS 沙盒包。
9.3 Node.js Skill 的独特风险 —— v4.0→v4.4.0 踩坑实录
QuickJS 的严格 API 限制反而是一种保护——你能用的东西少,能写错的东西也少。Node.js 什么都能跑,意味着硬编码的暗雷可以在正常环境跑几个月不炸,直到凌晨三点。
| # | 教训 | 本质 | 解法 |
|---|
| 1 | 配置存在但代码不读 | Node.js 允许你写 500 行 config,然后代码里裸数字照样跑——QuickJS 没这么多配置要管,反而不会犯这个错 | 所有阈值从 config 取值,?? 回退到默认 |
| 2 | API 供应商会消失——不是意外,是常态 | 你依赖的外部服务随时可能停服、涨价、限流。多引擎轮换不是"过度设计",是"活过下一次供应商地震"的唯一方式 | 多引擎 + 多密钥轮换,单个引擎挂了自动切下一个 |
| 3 | 预算不是固定值 | API 价格会涨、配额会砍、免费层会缩。硬编码的成本假设("这个请求大概 0.01 元")在涨价时让你束手无策 | 阈值/TTL/上限全部运行时可变,set_preferences 不停机调参 |
| 4 | 测试覆盖 ≠ 真实覆盖 | testCredentials 永远搜同一个词 → 那个词被缓存/特殊处理 → 测试亮绿灯,生产炸穿 | 测试词可配,定期换。测试通过是最低标准,不是充分条件 |
| 5 | 截断不是逻辑,是偏好 | substring(0, 80) 在当前展示场景刚好——换了个 UI 就溢出了。截断值应该和字号、屏幕宽度一样,属于"偏好设置" | 截断值进 config,命名说清楚它服务于哪个展示场景 |
核心教训(刻在 Search Vault v2.9 description 里): 不是只有 bug 才最严重。硬编码大军正常时完全感受不到,真出事了就哭死。多写一行 ??,少熬一个凌晨三点。