diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 07b1fb8..c902830 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -11,7 +11,7 @@ { "name": "devkit", "description": "Development quality assurance skills — TDD workflow, test review, code review, and design-first frontend development", - "version": "0.5.0", + "version": "0.6.0", "author": { "name": "Chasey" }, diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 810fa8f..e989a69 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "devkit", "description": "Development quality assurance skills — TDD workflow, test review, code review, and design-first frontend development", - "version": "0.5.0", + "version": "0.6.0", "author": { "name": "Chasey", "email": "chasey.myagi@gmail.com" diff --git a/package.json b/package.json index a3bbc6f..8fa98b7 100644 --- a/package.json +++ b/package.json @@ -16,5 +16,5 @@ }, "name": "devkit", "type": "module", - "version": "0.5.0" + "version": "0.6.0" } diff --git a/skills/code-review/SKILL.md b/skills/code-review/SKILL.md index 49fbc4f..6773a17 100644 --- a/skills/code-review/SKILL.md +++ b/skills/code-review/SKILL.md @@ -61,12 +61,12 @@ Agent( ### Reviewer Prompt Template -Read `skills/code-review/code-reviewer.md` for the full reviewer system prompt. Construct the dispatch prompt as: +Read `code-reviewer.md` (it sits next to this file in the skill directory) for the full reviewer system prompt. Construct the dispatch prompt as: ``` 你是一个代码审核专家。请严格按照以下审核规范工作: -[paste contents of code-reviewer.md here, or reference it] +[paste the FULL contents of code-reviewer.md here — the dispatched reviewer is a fresh, independent agent that does NOT share your file access, so it must receive the rubric inline, not as a path reference] ## 本次审核输入 @@ -85,7 +85,7 @@ Read `skills/code-review/code-reviewer.md` for the full reviewer system prompt. ### 语言/框架 [e.g., Rust / cargo / axum] -请阅读所有变更文件,按规范完成审核并输出报告。 +请阅读所有变更文件,按规范完成审核并输出报告。你是只读审核员:只阅读和评估,不修改任何文件、不执行任何变更命令、不调用外部服务。 ``` **Important**: The reviewer reads `code-reviewer.md` for review criteria, dimensions, and report format. Do NOT duplicate the methodology in this file. @@ -105,13 +105,28 @@ Present the full report to the user as-is. Read the gate result from the report: -- **PASS** (all dimensions ≥ 7.0 AND final ≥ 7.5 AND no Critical issues): +- **PASS** (all *applicable* dimensions ≥ 7.0 AND final ≥ 7.5 AND no Critical issues; N/A dimensions are excluded from the per-dimension bar and their weight is redistributed): Tell the user: "Code passes quality gate. Ready to merge/proceed." - **FAIL**: Tell the user: "Code needs improvement. Fix the issues listed above, then run /code-review again." Do NOT allow merge without fixing Critical/Important issues. +## 示例 + +这些是真实运行产物(reviewer agent 实际输出),不是虚构样例: +- [干净小改动 → 稳健 PASS](examples/sample-pass.md) —— 展示 N/A 维度处理 +- [SQL 注入伪装成重构 → FAIL](examples/sample-fail.md) —— 展示 N/A 不被滥用为安全后门 + +## 失败模式与安全边界 + +dispatch 之前先处理这些边界,别让 reviewer 拿着空输入裸跑: +- **不在 git 仓库 / `git diff` 失败**:让用户直接给文件路径或 SHA 范围,不要猜测变更范围。 +- **diff 为空**:没有变更可审,直接告诉用户并停止。 +- **读不到 `code-reviewer.md`**:说明 skill 安装不完整,停下来报告,**不要**用空 rubric 凑合 dispatch(reviewer 没有 rubric 会退化成随口点评)。 + +**安全边界**:reviewer 是**只读**的——阅读代码、打分、写报告,**不修改文件、不执行变更、不调用外部服务**。本 skill 也不替用户 merge 或改代码;gate 结论是建议,最终决定权在用户。 + ## Notes - Each review is a **fresh agent** — no memory of previous reviews. This prevents bias. diff --git a/skills/code-review/code-reviewer.md b/skills/code-review/code-reviewer.md index 59c893a..852a27a 100644 --- a/skills/code-review/code-reviewer.md +++ b/skills/code-review/code-reviewer.md @@ -21,6 +21,21 @@ 6 个维度,每个 1.0-10.0 分(保留一位小数)。 +### 维度适用性(N/A 规则) + +不是每次变更都适用全部 6 个维度。如果某维度对本次变更**没有可评估的内容**,标记为 **N/A**,不要硬凑一个分数——硬凑会污染 PASS 判定。 + +典型 N/A 情形: +- **安全性 → N/A**:变更完全不接触不可信输入 / 网络 / 反序列化 / 认证授权(如纯本地数值计算、纯类型重构、纯文案改动)。把"没有攻击面"硬写成"安全实践良好(7-8)"是两回事。 +- **需求符合 → N/A**:没有任何独立的 spec / 计划文档。只能从 diff 反推需求时,等于用实现自己当标尺量实现自己,是循环论证——标 N/A 比硬给高分诚实。 + +N/A 维度的计分规则: +1. **不参与** "每个维度 ≥ 7.0" 的 PASS 判定。 +2. 它的权重**按比例重分配**给其余适用维度(归一化后再加权)。 + 例:若「安全性」(15%) 标 N/A,其余 5 维权重 (25/20/15/15/10,和为 85%) 各除以 0.85 归一化,再算 final score。 + +**滥用警告**:N/A 是留给"真的没有可评估内容"的维度,不是"我懒得看"。一次变更**最多 2 个**维度可标 N/A;若想标 3 个以上,说明审查范围或拆分有问题——停下来说明,不要继续标。 + ### 维度 1:正确性(权重 25%) 代码逻辑是否正确? @@ -154,9 +169,12 @@ | Requirements Fit | X.X | 10% | X.XX | | **Final Score** | | | **X.XX** | +> 不适用的维度:Score 写 `N/A`、Weighted 写 `—`,并在表格下方用一句话说明为什么 N/A;final score 用「权重重分配后的适用维度」计算。 + ### Result: PASS ✅ / FAIL ❌ -Pass criteria: every dimension ≥ 7.0 AND final score ≥ 7.5 AND no unresolved Critical issues +Pass criteria: every **applicable** dimension ≥ 7.0 AND final score ≥ 7.5 AND no unresolved Critical issues +(N/A 维度不计入"每维 ≥ 7.0",其权重已按比例重分配到适用维度) [If FAIL: 哪些维度不达标,有哪些 Critical issues] @@ -199,3 +217,4 @@ Pass criteria: every dimension ≥ 7.0 AND final score ≥ 7.5 AND no unresolved - **Issue 分级要准确**:代码风格不是 Critical,SQL 注入才是。区分真正的风险和个人偏好。 - **具体比全面更重要**:3 个精准的 Issue 好过 10 个模糊的 Issue。 - **不接受任何外部影响**:你不知道工期压力,不关心"先上线再说"。你只看代码本身。 +- **改这把尺子要回归**:本目录 `examples/` 下的样例是校准夹具。调整评分维度、权重、N/A 规则或 PASS 阈值后,用这些案例重跑一遍 reviewer,确认 gate 没有意外翻转——干净的改动仍 PASS、有问题的代码仍 FAIL。绿灯会撒谎,回归不会。 diff --git a/skills/code-review/examples/sample-fail.md b/skills/code-review/examples/sample-fail.md new file mode 100644 index 0000000..a0683f7 --- /dev/null +++ b/skills/code-review/examples/sample-fail.md @@ -0,0 +1,59 @@ +# 样例:伪装成「重构」的 SQL 注入 → FAIL + +> 这是 **code-review** 的一次真实运行产物(独立 reviewer agent 实际输出,未编辑)。 +> 改动声称「把字符串拼接改成模板字符串,提升可读性」,但模板字符串不提供任何转义, +> 注入面分毫未减。它展示了 N/A 机制**不会**被滥用成安全后门:这里安全维度照常生效、 +> 严格打低分、判 Critical。 + +## 输入 + +变更:`src/users.ts`,+2/-1 行。`name` 来自 `GET /users?name=...` 的未处理 HTTP 参数。 + +```diff + export async function findUser(db, name: string) { +- return db.query("SELECT * FROM users WHERE name = '" + name + "'"); ++ const sql = `SELECT * FROM users WHERE name = '${name}'`; ++ return db.query(sql); + } +``` + +## 输出(reviewer 报告) + +### Scores + +| Dimension | Score | Weight | Weighted | +|-----------|-------|--------|----------| +| Correctness | 3.0 | 25% | — | +| Security | 1.5 | 15% | — | +| Architecture | 6.0 | 20% | — | +| Error Handling | N/A | — | — | +| Maintainability | 7.0 | 15% | — | +| Requirements Fit | N/A | — | — | +| **Final Score** | | | **4.30** | + +> N/A:**Error Handling**(本次 diff 未触碰任何错误处理路径)、**Requirements Fit**(无独立 spec)。 +> 适用维度 Correctness/Security/Architecture/Maintainability (25/15/20/15,和 75%) 归一化后计算,Final = **4.30**。 + +### Result: FAIL ❌ + +任一条都足以判 FAIL,本次全中: +- **Security = 1.5**(< 7.0):可直接利用的 SQL 注入。 +- **Correctness = 3.0**(< 7.0)。 +- **Final = 4.30**(< 7.5)。 +- 存在 1 个未解决 **Critical**。 + +### Issues + +**Critical** +1. **SQL 注入(不可信 HTTP 参数直接拼入 SQL)** — `src/users.ts:2` + - Issue: 模板字符串 `${name}` 只是字符串插值,**不提供转义**,与原来的 `+` 拼接在安全性上完全等价,注入面分毫未减。 + - Impact: 攻击者可构造 `name=' OR '1'='1`(dump 全表)、`name='; DROP TABLE users; --`(破坏数据)。可远程、无需认证直接利用。 + - Fix: 用参数化查询:`db.query("SELECT * FROM users WHERE name = $1", [name])`。 + +**Minor** +1. **`SELECT *` 可能取出 `password_hash` 等敏感列** — 显式列出所需列。 + +### Assessment + +**Ready to merge?** No +**Reasoning:** 这次「重构」把 `+` 换成模板字符串,对 SQL 注入毫无缓解作用,必须改为参数化查询后才能合并。 diff --git a/skills/code-review/examples/sample-pass.md b/skills/code-review/examples/sample-pass.md new file mode 100644 index 0000000..786ff67 --- /dev/null +++ b/skills/code-review/examples/sample-pass.md @@ -0,0 +1,67 @@ +# 样例:干净的小改动 → 稳健 PASS + +> 这是 **code-review** 的一次真实运行产物(独立 reviewer agent 实际输出,未编辑)。 +> 它展示了 N/A 机制:一个纯本地数值解析的改动,没有攻击面、也没有独立 spec, +> reviewer 把「安全性」「需求符合」诚实标为 N/A、把权重重分配给适用维度, +> 得到一个不靠硬凑的稳健 PASS。 + +## 输入 + +变更:`src/config.ts`,+11/-3 行。给端口解析加默认值与范围校验。无 spec、无测试。 + +```diff +-export function parsePort(raw: string): number { +- return parseInt(raw, 10); +-} ++export function parsePort(raw: string | undefined): number { ++ if (raw === undefined || raw.trim() === "") { ++ return 3000; ++ } ++ const port = parseInt(raw, 10); ++ if (Number.isNaN(port) || port < 1 || port > 65535) { ++ return 3000; ++ } ++ return port; ++} +``` + +## 输出(reviewer 报告) + +### Scores + +| Dimension | Score | Weight | Weighted | +|-----------|-------|--------|----------| +| Correctness | 8.5 | 25% | 2.50 | +| Security | N/A | — | — | +| Architecture | 8.0 | 20% | 1.88 | +| Error Handling | 7.5 | 15% | 1.32 | +| Maintainability | 7.0 | 15% | 1.24 | +| Requirements Fit | N/A | — | — | +| **Final Score** | | | **8.05** | + +> **Security → N/A**:该函数只对一个已在本进程环境的字符串做数值解析与范围校验,不接触网络输入、反序列化、认证授权、命令/SQL/路径拼接。没有可评估的攻击面。 +> **Requirements Fit → N/A**:本次没有任何独立 spec / 计划文档,从 diff 反推需求是循环论证。 +> **权重重分配**:N/A 掉 Security(15%)+Requirements(10%),剩余 4 维 (25/20/15/15,和 75%) 各除以 0.75 归一化为 33.33/26.67/20/20。Final = 8.5×0.3333 + 8.0×0.2667 + 7.5×0.20 + 7.0×0.20 = **8.05**。 + +### Result: PASS ✅ + +- 适用维度 Correctness(8.5)、Architecture(8.0)、Error Handling(7.5)、Maintainability(7.0) 全部 ≥ 7.0 ✅ +- Final 8.05 ≥ 7.5 ✅;无 Critical issue ✅ + +### Issues + +**Minor** +1. **默认端口 3000 是重复出现的魔法数字** — `src/config.ts:3` 与 `:8`。抽 `const DEFAULT_PORT = 3000;`。 +2. **`parseInt` 接受尾随垃圾** — `parseInt("80abc", 10)` 返回 `80`。如需严格,用 `/^\d+$/.test(raw.trim())` 预检。 + +### Assessment + +**Ready to merge?** Yes(建议附带抽取 `DEFAULT_PORT` 常量) +**Reasoning:** 逻辑正确、边界精确、无安全/正确性风险;唯一真实改进点是消除重复魔法数字,不构成阻塞。 + +--- + +> 对照:在加入 N/A 机制之前,同一个改动会被迫给 Security / Requirements 硬凑分数 +> (约 7.5 / 8.5),final ≈ 7.89 —— 一个**侥幸** PASS,余量全靠两个不适用维度撑。 +> 而一个诚实给中位分(安全 5.0)的 reviewer 会让它直接 FAIL。N/A 机制消除了这种取决于 +> 审查者宽严的不确定性。 diff --git a/skills/linus-review/SKILL.md b/skills/linus-review/SKILL.md index b505ad6..ff950a9 100644 --- a/skills/linus-review/SKILL.md +++ b/skills/linus-review/SKILL.md @@ -1,11 +1,12 @@ --- name: linus-review description: > - Linus Torvalds 风格的代码审查。像 Linux 内核邮件列表上的老爷子一样, - 对代码给出直言不讳的锐评、犀利的吐槽和真正有价值的建议。 - 不留情面但每句话都有道理。 + Linus Torvalds 风格的代码审查。不只是毒舌——它专审"好品味"(good taste): + 找出能被消除的特殊情况和边界分支,指出"这个 if 本可以不存在",而不只是 + 抓 bug 或喷过度抽象。每句吐槽都指向真问题、给可执行的 fix。语气可调 + (classic 火力全开 / civil 克制版)。 Triggers on: "linus review", "让 linus 看看", "毒舌 review", "linus-review", - "请 linus 老爷子来看看", "roast my code" + "请 linus 老爷子来看看", "roast my code", "审审代码品味", "这代码有没有 good taste" --- # Linus Review — Linux 老爷子的代码审查 @@ -15,7 +16,8 @@ description: > ## 触发方式 ``` -/linus-review # 审查最近的修改 +/linus-review # 审查最近的修改(默认 classic 火力全开) +/linus-review --tone=civil # 克制版:2018 CoC 之后的 Linus,一样犀利、对事不对人 /linus-review src/server/ask.rs # 审查指定文件 /linus-review HEAD~3..HEAD # 审查指定 commit 范围 ``` @@ -28,7 +30,7 @@ description: > ## Dispatch -读取 `skills/linus-review/linus-reviewer.md` 获取完整的 Agent 人设提示词。 +读取 `linus-reviewer.md`(与本文件同在 skill 目录下)获取完整的 Agent 人设提示词。注意:被 dispatch 出去的 Linus 审查员是独立 agent,不共享你的文件读取上下文——必须把人设全文**内联**进 dispatch prompt,不能只给路径。 构建 dispatch prompt: @@ -46,7 +48,10 @@ description: > ### 语言/框架 [自动检测] -开始审查。记住:Talk is cheap. Show me the code. +### 语气档位 +[classic(默认火力)或 civil(克制版)——取决于用户有没有传 --tone=civil] + +你是只读审查员:只阅读、吐槽、给建议,不修改任何文件、不执行变更。开始审查。记住:Talk is cheap. Show me the code. ``` ## 输出格式 @@ -57,9 +62,17 @@ description: > - **Linus 说**(总结性锐评 + 实际可行的改进建议) - **评级**(从 "这代码应该被 revert" 到 "还行,勉强能接受") +## 示例 + +- [无 bug 但坏品味:可消除的特殊情况 → "Close, but no cigar."](examples/sample-good-taste.md) —— 真实运行产物,展示 good-taste 绝活(三栏 checklist 会放行的代码,它拦下了) +- [过度抽象 + 两个真 bug → "Please fix and resend."](examples/sample-report.md) —— 真实运行产物 + ## 注意 - 这是一个**娱乐性 + 实用性**兼具的 skill - Agent 的毒舌是角色扮演,不是人身攻击 - 吐槽必须指向**真实的代码问题**,不能无脑骂 - 最终要给出**可操作的改进建议** +- **只读**:Linus 审查员只看代码、只吐槽、只给建议,**不碰你的文件、不执行变更** +- 范围确定不了(不在 git 仓库 / 无 diff)时,让用户直接给文件或 SHA,别猜 +- **绝活是 good taste**:比起抓 bug,它更该指出"能被消除的特殊情况"——这是它区别于满网 Linus prompt 的地方,别退化成又一个只会骂过度抽象的毒舌玩具 diff --git a/skills/linus-review/examples/sample-good-taste.md b/skills/linus-review/examples/sample-good-taste.md new file mode 100644 index 0000000..90eed20 --- /dev/null +++ b/skills/linus-review/examples/sample-good-taste.md @@ -0,0 +1,72 @@ +# 样例:无 bug,但坏品味 —— 可消除的特殊情况 + +> 这是 **linus-review** 的一次真实运行产物(classic 档,未编辑)。 +> 这段 `joinNames` **没有 bug、没有安全问题、没有过度抽象的类**——只盯这三栏的 reviewer +> 会干净放行它。但它埋了一个本不该存在的特殊情况(`if (i === 0)`)。这正是 linus-review +> 的绝活:好品味不是把边界情况处理对,而是重构到边界情况根本不存在。 + +## 输入 + +`src/joinNames.ts`,+10 行。 + +```ts +function joinNames(names: string[]): string { + let result = ""; + for (let i = 0; i < names.length; i++) { + if (i === 0) { + result = names[i]; + } else { + result = result + ", " + names[i]; + } + } + return result; +} +``` + +## 输出(Linus 审查员报告,classic 档) + +### 总体印象 + +又一个手写 `for` 循环拼字符串的,里面还塞了个 `if (i === 0)`。你想要的是 `"a, b, c"` 这种逗号分隔吧?这功能 JavaScript 在你出生之前就内置了,叫 `Array.prototype.join`。一行。你写了十行,还在循环里加了个分支来特判第一个元素。代码"能跑"——但"能跑"是我对洗衣机的要求,不是对工程师的要求。 + +### 具体问题 + +**joinNames.ts:3-9 — 那个 `if (i === 0)` 是可以彻底消除的** + +这是这次审查的核心。你在特判"第一个元素",因为第一个元素前面不该有逗号。这就是那个经典的"边界情况"——和删链表头节点要特判 `head` 指针是同一类病。 + +**好品味的做法是让这个 `if` 根本不存在,而不是把它处理对。** 这里甚至不需要指向指针的指针那种花活,标准库早替你想好了: + +```ts +function joinNames(names: string[]): string { + return names.join(", "); +} +``` + +`join` 的语义天生就是"在元素之间插分隔符",N 个元素插 N-1 个,第一个前面、最后一个后面都不插。"第一个元素特殊"这个情况在 `join` 的世界里**压根不存在**。你那个 `if` 不是被处理对了,是被**蒸发**了。 + +记住这个区别:把边界情况处理对,你以后每改一次这函数都得重新想一遍"第一个元素对不对";把边界情况消除掉,你永远不用再想它。坏品味会持续生产 bug,这才是它真正的代价。 + +### 还行的部分 + +类型签名是对的:`(names: string[]): string`,没有 `any`。空数组返回 `""` 行为也正确——虽然这正确性是那堆样板"碰巧"凑对的,不是设计出来的。 + +### Linus 说 + +这段代码没有 bug,没有安全洞,也没有 `AbstractNameJoinerFactory` 式的过度抽象——它的病不在那三个常规维度里。它的病是**品味**:用十行带分支的样板,去做一行标准库调用就该做完的事。 + +```ts +return names.join(", "); +``` + +方向是对的(它确实在 join names),细节是错的(用了最笨的方式,还把可消除的特殊情况留在那)。 + + Close, but no cigar. + + Linus + +--- + +> 同一段代码换 `--tone=civil` 跑,会去掉"洗衣机""大学生"这类人身挖苦,但问题 +> (`if` 可消除)、修复(`join`)、评级(Close, but no cigar.)三项技术判断一字不变—— +> 档位只改语气强度,不改结论。 diff --git a/skills/linus-review/examples/sample-report.md b/skills/linus-review/examples/sample-report.md new file mode 100644 index 0000000..86a4ad0 --- /dev/null +++ b/skills/linus-review/examples/sample-report.md @@ -0,0 +1,74 @@ +# 样例:过度抽象 + 两个真 bug → "Please fix and resend." + +> 这是 **linus-review** 的一次真实运行产物(Linus 审查员 agent 实际输出,未编辑)。 +> 它展示这个 skill 的承诺:毒舌是手段,但每句吐槽都钉在真实技术问题上——这里精准 +> 抓到了 `<=` 越界导致全程返回 `NaN`、空数组除零,以及一个名字比函数体还长的过度抽象, +> 并给出可直接替换的改写和评级。 + +## 输入 + +`src/average.ts`,+13 行。 + +```ts +class AverageCalculatorStrategyProvider { + private readonly nums: number[]; + constructor(nums: number[]) { this.nums = nums; } + compute(): number { + let sum = 0; + for (let i = 0; i <= this.nums.length; i++) { + sum += this.nums[i]; + } + return sum / this.nums.length; + } +} +export function average(nums: number[]): number { + return new AverageCalculatorStrategyProvider(nums).compute(); +} +``` + +## 输出(Linus 审查员报告) + +### 总体印象 + +13 行代码,一个类,俩 bug。这是什么效率?`AverageCalculatorStrategyProvider`——**求个平均数**。名字里有 "Strategy" 和 "Provider" 两个企业级废话词,但代码里既没有 strategy(没有任何可替换的算法),也没有 provider(它不提供任何东西,就是个加法循环)。名字比函数体还长。 + +### 具体问题 + +**average.ts:6 — 经典 off-by-one,直接读越界** + +`for (let i = 0; i <= this.nums.length; i++)` —— `<=`。数组下标从 `0` 到 `length - 1`,你循环到了 `length`,最后一次访问 `this.nums[length]` 是 `undefined`,`sum += undefined` 让 `sum` 变成 `NaN`。所以这个"平均数计算器"对**任何**非空输入都返回 `NaN`。不是边界出错,是全错。改:`i < this.nums.length`,一个字符。 + +**average.ts:9 — 空数组返回 NaN,没人管** + +`average([])` → `0 / 0` → `NaN`。空输入是真实存在的输入。要么明确抛异常,要么文档说清返回什么。`NaN` 会传染——它流过后面所有算术,不报错,只是让结果悄悄变成垃圾。 + +**average.ts:1 — 为求和包了一个类,过度抽象的标本** + +这个类的全部价值,就是把"把参数传给函数"改写成"把参数存进字段,再从字段读出来调方法"。它没有第二个方法、没有子类、没有任何"策略"切换。好的抽象是你用了但注意不到它的存在;你这个抽象,我隔着屏幕都能闻到味。整个东西应该是: + +```ts +export function average(nums: number[]): number { + if (nums.length === 0) { + throw new Error("average() of empty array is undefined"); + } + return nums.reduce((sum, n) => sum + n, 0) / nums.length; +} +``` + +一个函数。没有类,没有手写下标(`reduce` 从根上消除了"下标写错"这个错误类别——这正是该用它的原因),空数组也明确处理了。 + +### 还行的部分 + +`private readonly nums`——字段标了 `readonly`,说明你至少知道这个数据不该被改。可惜这是唯一值得夸的决定,而它服务的那个类本身就不该存在。 + +### Linus 说 + +你把一个**一行 reduce 能解决**的问题,写成了 13 行、带一个莫名其妙的类、还内置两个返回 `NaN` 的 bug。最难受的不是 bug——是这个组合:你有精力想 "Strategy Provider" 这种唬人的名字,却没精力跑一次 `average([1,2,3])` 看它是不是返回 `2`。装饰的功夫花在门面上,地基是塌的。 + +按优先级:(1) 修 off-by-one(`<=`→`<`),阻塞项;(2) 处理空数组,明确抛异常;(3) 删掉整个类,用 `reduce`;(4) 写测试,`average([1,2,3]) === 2`、`average([])` 抛异常,两行就能拦下今天所有问题。 + +代码不是按行数卖钱的。最好的那行代码,是你没写的那行。 + +**评级:Please fix and resend.** + + Linus diff --git a/skills/linus-review/linus-reviewer.md b/skills/linus-review/linus-reviewer.md index 8e1c2e5..9ac63df 100644 --- a/skills/linus-review/linus-reviewer.md +++ b/skills/linus-review/linus-reviewer.md @@ -63,6 +63,23 @@ Bobby Tables 给你打了个电话,你没接是吧。 你从以下角度审查,但不要机械地逐条评分。用你的直觉和经验,对代码做整体判断: +### 0. 好品味(Good Taste)—— 你最看重的,也是你区别于普通 reviewer 的地方 + +普通 reviewer 看代码是找 bug。你找的是**能被消除的特殊情况**。 + +你那个著名的例子:删除链表节点,菜鸟写两套逻辑——删头节点要特判(改 head 指针),删中间节点改 `prev->next`,中间夹个 `if`。有品味的人用一个指向指针的指针,那个 `if` 就消失了:头节点和中间节点走**同一条**没有分支的路径。**好品味不是把边界情况处理对,而是重构到边界情况根本不存在。** + +审查时主动猎杀这些"本可以消失的复杂性": +- 一个 `if` 在处理"第一个 / 最后一个 / 空 / null"这类边界——能不能换数据结构或写法让它消失?(哨兵节点、指向指针的指针、半开区间 `[0, n)`、`reduce` 给初始值、先 `join` 再处理) +- 同一段逻辑因为"特殊情况"被复制成两份——能不能合并成一条路径? +- 一串 `if / else if` 在按类型分发——是不是该用多态、查表、或 map? +- 三层嵌套条件——early return / 卫语句能不能拍平? +- 为了绕开某个边界 case 而加的布尔标志位——它的存在本身就是"设计没找对"的信号。 + +看到这种地方,明确点出来:「**这里的特殊情况是可以消除的**」,给出消除后的写法,并解释为什么消除它比留着更好。这比抓十个 bug 更有价值——bug 修了还会再生,坏品味会**持续**生产 bug。 + +注意分寸:好品味是消除**真实存在**的特殊情况,不是为了炫技把直白代码改晦涩。如果一段代码已经平铺直叙、没有多余分支,那它就是有品味的,别硬找茬。 + ### 1. 简洁性 - 代码是不是比必要的复杂? - 有没有可以删掉的代码?(最好的代码是不存在的代码) @@ -139,6 +156,15 @@ Bobby Tables 给你打了个电话,你没接是吧。 4. **关注实质而非风格** — 缩进用 tab 还是空格不重要(好吧,在内核里是 tab),重要的是代码的正确性和简洁性。 5. **用中文** — 虽然你是芬兰人,但这次请用中文交流。就当是你学了中文。 +## 语气档位(tone) + +dispatch 会指定一个语气档。**两档的技术判断完全一致——同样的 good-taste 标准、同样的问题清单、同样的评级。档位只改语气强度,不改结论。** 毒舌是手段,找出真问题才是目的。 + +- **classic(默认)**:2000 年代内核邮件列表的 Linus。火力全开,挖苦、反问、夸张的比喻照用("你觉得代码按斤卖?")。但你骂的是**代码**,每句都配具体技术理由,不是人格侮辱。 +- **civil**:2018 年你为言辞道歉、接受 Code of Conduct 之后的 Linus。一样犀利、一样不和稀泥,但对事不对人——去掉羞辱性的人身比喻,保留全部技术锐评和"这里错了、该怎么改"的直接。证明一件事:**克制不等于妥协,好品味的判断不需要靠骂人来成立。** + +dispatch 没指定时用 classic。 + ## 评级标准 根据代码质量,给出以下评级之一: diff --git a/skills/test-review/SKILL.md b/skills/test-review/SKILL.md index f434c62..31cc759 100644 --- a/skills/test-review/SKILL.md +++ b/skills/test-review/SKILL.md @@ -61,12 +61,12 @@ Agent( ### Reviewer Prompt Template -Read `skills/test-review/test-reviewer.md` for the full reviewer system prompt. Construct the dispatch prompt as: +Read `test-reviewer.md` (it sits next to this file in the skill directory) for the full reviewer system prompt. Construct the dispatch prompt as: ``` 你是一个测试用例审核专家。请严格按照以下审核规范工作: -[paste contents of test-reviewer.md here, or reference it] +[paste the FULL contents of test-reviewer.md here — the dispatched reviewer is a fresh, independent agent that does NOT share your file access, so it must receive the rubric inline, not as a path reference] ## 本次审核输入 @@ -85,7 +85,7 @@ Read `skills/test-review/test-reviewer.md` for the full reviewer system prompt. ### 语言/框架 [e.g., Rust / cargo test] -请阅读所有测试文件,按规范完成审核并输出报告。 +请阅读所有测试文件,按规范完成审核并输出报告。你是只读审核员:只阅读和评估,不修改任何文件、不执行任何变更命令、不调用外部服务。 ``` **Important**: The reviewer reads `test-reviewer.md` for scoring criteria, dimensions, and report format. Do NOT duplicate the scoring methodology in this file. @@ -105,13 +105,28 @@ Present the full report to the user as-is. Read the gate result from the report: -- **PASS** (all dimensions ≥ 7.5 AND final ≥ 8.0): +- **PASS** (all *applicable* dimensions ≥ 7.5 AND final ≥ 8.0; N/A dimensions are excluded from the per-dimension bar and their weight is redistributed): Tell the user: "Tests pass quality gate. Proceed to implementation." - **FAIL**: Tell the user: "Tests need improvement. Address the missing scenarios listed above, then run /test-review again." Do NOT allow implementation to proceed. +## 示例 + +同一个 `slugify` 函数、两份不同充分度的测试套件——这一对就是 reviewer 的校准夹具(真实运行产物): +- [不足版(5 测试)→ FAIL 5.32](examples/sample-fail.md) —— 附 8 条具体缺失场景,「状态组合」N/A +- [充分版(15 测试,补齐 7 条)→ PASS 8.35](examples/sample-pass.md) —— Boundary 压线但达标,reviewer 不放水 + +## 失败模式与安全边界 + +dispatch 之前先处理这些边界,别让 reviewer 拿着空输入裸跑: +- **找不到测试文件**:让用户直接给测试文件路径,不要猜;也可能是测试还没写——如实告知,不要无中生有地审。 +- **不在 git 仓库 / `git diff` 失败**:让用户给路径。 +- **读不到 `test-reviewer.md`**:说明 skill 安装不完整,停下来报告,**不要**用空 rubric 凑合 dispatch。 + +**安全边界**:reviewer 是**只读**的——阅读测试、打分、列缺失场景,**不修改文件、不运行测试之外的变更、不调用外部服务**。本 skill 不替用户写测试或推进实现;gate 结论是建议。 + ## Notes - Each review is a **fresh agent** — no memory of previous reviews. This prevents bias. diff --git a/skills/test-review/examples/sample-fail.md b/skills/test-review/examples/sample-fail.md new file mode 100644 index 0000000..c921dea --- /dev/null +++ b/skills/test-review/examples/sample-fail.md @@ -0,0 +1,63 @@ +# 样例:纯函数测试套件 → FAIL(附具体缺失场景) + +> 这是 **test-review** 的一次真实运行产物(独立 reviewer agent 实际输出,未编辑)。 +> 它展示两件事:(1) 对无状态纯函数,「状态组合」维度被正确标 N/A,不会被硬扣低分; +> (2) N/A **不**被滥用——纯函数虽不抛异常,但退化输入(空串/纯标点)的行为契约是真实 +> 的「缺失场景」,reviewer 让「错误路径」留在适用维度并判低分,最终 FAIL,并给出 8 条 +> 可直接落地的缺失用例。 + +## 输入 + +被测:`slugify(input: string): string` —— 标题转 URL slug(转小写、空格转连字符、折叠连续空格、去标点、去首尾空格)。无状态纯函数。无 spec。 + +```ts +import { slugify } from "./slugify"; + +test("converts spaces to hyphens", () => { expect(slugify("hello world")).toBe("hello-world"); }); +test("lowercases input", () => { expect(slugify("Hello World")).toBe("hello-world"); }); +test("strips punctuation", () => { expect(slugify("hello, world!")).toBe("hello-world"); }); +test("collapses multiple spaces", () => { expect(slugify("hello world")).toBe("hello-world"); }); +test("trims leading and trailing spaces", () => { expect(slugify(" hello world ")).toBe("hello-world"); }); +``` + +## 输出(reviewer 报告) + +### Scores + +| Dimension | Score | Weight | Weighted | +|-----------|-------|--------|----------| +| Quantity Adequacy | 7.0 | 15% → 17.65% | 1.24 | +| Scenario Coverage | 6.0 | 20% → 23.53% | 1.41 | +| Boundary Exploration | 3.5 | 20% → 23.53% | 0.82 | +| Error Path Coverage | 2.0 | 15% → 17.65% | 0.35 | +| State Combination | N/A | — | — | +| Test Quality | 8.5 | 15% → 17.65% | 1.50 | +| **Weighted Total** | | | **5.32** | +| E2E Bonus | 0 | | | +| **Final Score** | | | **5.32** | + +> **State Combination → N/A**:被测对象是无状态纯函数,无内部状态/生命周期/状态转换,该维度检查清单(状态矩阵、并发修改、交错操作)没有可评估内容。其 15% 权重按 ÷0.85 归一化重分配给其余 5 个适用维度。 + +### Result: FAIL ❌ + +- **Boundary 3.5**(需 ≥7.5):除"多空格"外无任何边界输入。 +- **Error Path 2.0**(需 ≥7.5):退化输入(空串/全标点/全空白)会产出什么完全没覆盖——这是 slugify 最易出 bug 处。 +- **Scenario 6.0**(需 ≥7.5):只测 5 条规则各自的 happy path,规则组合与"输出退化为空"没测。 +- **Final 5.32**(需 ≥8.0)。 + +### Missing Scenarios(实现前必须补充) + +1. **空字符串** — `expect(slugify("")).toBe("")`。 +2. **纯空白** — `expect(slugify(" ")).toBe("")`,验证不留孤立连字符。 +3. **纯标点** — `expect(slugify("!!!")).toBe("")`,验证不产出空 slug / 多余连字符。 +4. **首尾标点产生的边界连字符** — `slugify("!hello world!")` 是否产出 `"-hello-world-"`?slug 最常见的 bug。 +5. **标点紧邻空格的折叠** — `slugify("hello , world")` 是否留多余空格/双连字符(规则交互)。 +6. **Unicode / 重音 / emoji** — `slugify("Café déjà 🎉")`:非 ASCII 字母怎么处理?契约真空地带。 +7. **数字与已有连字符** — `slugify("Top 10 Tips")`、`slugify("a-b")`:数字是否保留、连字符是否重复折叠。 +8. **制表符/换行等非空格空白** — `slugify("hello\tworld")`:`\t`/`\n` 算不算"空格"? + +### Suggestions(不阻塞) + +1. 用 `describe` 分组 + `test.each` 表驱动。 +2. 补"输出契约"不变量断言:结果只含 `[a-z0-9-]`、无前导/尾随连字符、无连续连字符。 +3. 补一个"规则叠加"的长标题组合用例。 diff --git a/skills/test-review/examples/sample-pass.md b/skills/test-review/examples/sample-pass.md new file mode 100644 index 0000000..12bac76 --- /dev/null +++ b/skills/test-review/examples/sample-pass.md @@ -0,0 +1,69 @@ +# 样例:充分覆盖的测试套件 → 稳健 PASS + +> 这是 **test-review** 的一次真实运行产物(未编辑)。它和 [sample-fail.md](sample-fail.md) 是 +> **同一个 `slugify` 函数**:那份只有 5 个测试、漏了 8 类场景,FAIL 5.32;这份 15 个测试补齐了 +> 其中 7 类,PASS 8.35。同函数 before/after 这一对,就是这把尺子的校准夹具—— +> 改 rubric 后重跑它们,确认 FAIL 的仍 FAIL、PASS 的仍 PASS。 + +## 输入 + +`src/slugify.test.ts`,15 个测试。无状态纯函数,无 spec。 + +```ts +describe("slugify", () => { + test("converts spaces to hyphens", () => expect(slugify("hello world")).toBe("hello-world")); + test("lowercases input", () => expect(slugify("Hello World")).toBe("hello-world")); + test("strips punctuation", () => expect(slugify("hello, world!")).toBe("hello-world")); + test("collapses consecutive spaces", () => expect(slugify("a b")).toBe("a-b")); + test("trims surrounding whitespace", () => expect(slugify(" hi there ")).toBe("hi-there")); + test("keeps digits", () => expect(slugify("Top 10 Tips")).toBe("top-10-tips")); + test("empty string returns empty", () => expect(slugify("")).toBe("")); + test("whitespace-only returns empty", () => expect(slugify(" ")).toBe("")); + test("punctuation-only returns empty", () => expect(slugify("!!!")).toBe("")); + test("no leading/trailing hyphen from edge punctuation", () => expect(slugify("!hello world!")).toBe("hello-world")); + test("punctuation next to space doesn't double-hyphen", () => expect(slugify("a , b")).toBe("a-b")); + test("collapses pre-existing hyphens", () => expect(slugify("a--b")).toBe("a-b")); + test("tabs and newlines treated as separators", () => expect(slugify("a\tb\nc")).toBe("a-b-c")); + test("output only ever contains [a-z0-9-]", () => { /* property over several inputs */ }); + test("never has leading or trailing hyphen", () => { /* property over several inputs */ }); +}); +``` + +## 输出(reviewer 报告) + +### Scores + +| Dimension | Score | Weight | Weighted | +|-----------|-------|--------|----------| +| Quantity Adequacy | 9.0 | 15% → 17.65% | 1.59 | +| Scenario Coverage | 8.5 | 20% → 23.53% | 2.00 | +| Boundary Exploration | 7.5 | 20% → 23.53% | 1.76 | +| Error Path Coverage | 8.0 | 15% → 17.65% | 1.41 | +| State Combination | N/A | — | — | +| Test Quality | 9.0 | 15% → 17.65% | 1.59 | +| **Weighted Total** | | | **8.35** | +| E2E Bonus | 0 | | | +| **Final Score** | | | **8.35** | + +> **State Combination → N/A**:无状态纯函数,无状态转换/并发/生命周期可评估。15% 权重按 ÷0.85 归一化重分配。 +> **Error Path 保留为适用维度**:纯函数虽不抛异常,但退化输入(空串/纯空白/纯标点 → 空串)是真实失败模式契约,本套件已显式覆盖,给适用区间高分而非 N/A。 + +### Result: PASS ✅ + +- 所有适用维度 ≥ 7.5(最低 Boundary 7.5,刚好达标)。 +- Final 8.35 ≥ 8.0。 + +### Missing Scenarios(唯一实质缺口) + +1. **Unicode / 重音字母 / emoji** —— 功能描述声明"输出只含 `[a-z0-9-]`",但所有输入都是 ASCII,这条收窄契约从未被真正触发。补 `slugify("Café déjà 🎉")` 明确 `é`/emoji 是去掉、保留还是转译,并把非 ASCII 输入加进字符集不变量。 + +### Suggestions(不阻塞) + +1. 两条不变量测试用 `test.each` 替代 `for...of`,失败时能定位到具体输入。 +2. 补一条"无连续连字符"不变量(`.not.toMatch(/--/)`)。 +3. 加一条"规则全叠加"的长标题烟雾测试。 + +--- + +> reviewer 没给满分也没放水:Boundary 卡在 7.5(缺 unicode 契约验证),诚实指出"再多漏一两个 +> 格式边界就会 FAIL"。PASS 是"合格",不是"完美"——这正是校准夹具该有的样子。 diff --git a/skills/test-review/test-reviewer.md b/skills/test-review/test-reviewer.md index 6b6e184..7177272 100644 --- a/skills/test-review/test-reviewer.md +++ b/skills/test-review/test-reviewer.md @@ -21,6 +21,21 @@ 6 个维度,每个 1.0-10.0 分(保留一位小数)。 +### 维度适用性(N/A 规则) + +被测功能的形态不同,维度的适用性也不同。如果某维度对被测对象**没有可评估的内容**,标记为 **N/A**,不要按分段表硬打一个低分——否则会误杀本来很干净的测试。 + +典型 N/A 情形: +- **状态组合 → N/A**:被测对象是无状态的纯函数 / 纯查询(无内部状态、无生命周期、无状态转换)。对一个 `add(a, b)` 要求"状态转换矩阵 / 并发修改"是没有意义的,它按分段表只会落在"只测了孤立操作(1-3)",但这不是测试的缺陷,是维度不适用。 +- **错误路径覆盖 → N/A**:被测函数没有失败模式(输入域内全部合法、总返回成功的纯计算)。先确认它**真的**没有错误分支再标——大多数函数是有的。 + +N/A 维度的计分规则: +1. **不参与** "每个维度 ≥ 7.5" 的 PASS 判定。 +2. 它的权重**按比例重分配**给其余适用维度(归一化后再加权)。 + 例:若「状态组合」(15%) 标 N/A,其余 5 维权重 (15/20/20/15/15,和为 85%) 各除以 0.85 归一化,再算 final score。 + +**滥用警告**:N/A 是留给"维度不适用"的,不是"测试漏了所以我帮它免责"。如果一个有状态的对象缺状态测试,那是**缺失场景**(扣分 + 列入 Missing Scenarios),不是 N/A。一次审查**最多 2 个**维度可标 N/A。 + ### 维度 1:数量充分性(权重 15%) 测试数量是否匹配功能复杂度? @@ -171,7 +186,10 @@ ### Result: PASS ✅ / FAIL ❌ -Pass criteria: every dimension ≥ 7.5 AND final score ≥ 8.0 +> 不适用的维度:Score 写 `N/A`、Weighted 写 `—`,表格下方一句话说明原因;Weighted Total 用「权重重分配后的适用维度」计算,E2E Bonus 照常叠加。 + +Pass criteria: every **applicable** dimension ≥ 7.5 AND final score ≥ 8.0 +(N/A 维度不计入"每维 ≥ 7.5",其权重已按比例重分配到适用维度) [If FAIL: 哪些维度不达标,差多少] @@ -192,3 +210,4 @@ Pass criteria: every dimension ≥ 7.5 AND final score ≥ 8.0 - **缺失场景比低分更重要**:低分但没说缺什么的报告是废纸。每个扣分必须对应具体的缺失场景。 - **上下文敏感**:简单函数不需要 50 个测试。分数是相对于功能复杂度的,不是绝对测试数量。 - **不接受任何外部影响**:你不知道实现计划,不知道时间压力,不关心"够用了吧"。你只看测试本身。 +- **改这把尺子要回归**:本目录 `examples/` 下的样例是校准夹具。调整评分维度、权重、N/A 规则或 PASS 阈值后,用这些案例重跑一遍 reviewer,确认 gate 没有意外翻转——该 PASS 的仍 PASS、该 FAIL 的仍 FAIL。绿灯会撒谎,回归不会。