@rowansenne/workflow-run 0.22.1 → 0.23.0
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/CHANGELOG.md +14 -0
- package/README.md +14 -10
- package/bin/lib/docsrepo.mjs +1 -1
- package/dev/SKILL.md.tpl +1 -1
- package/package.json +1 -1
- package/project-review/SKILL.md.tpl +6 -6
- package/project-review/references/diff-strategy.md +4 -2
- package/project-review/references/report.md.tpl +1 -1
- package/workflow/references/profile.md +1 -0
- package/workflow/scripts/locate.mjs +113 -0
- package/workflow/scripts/review-scope.mjs +359 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,20 @@
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.23.0] - 2026-10-19
|
|
8
|
+
|
|
9
|
+
### 新增
|
|
10
|
+
- project-review 的审查范围改由脚本决定(借鉴 alibaba/open-code-review 的做法):新脚本 `workflow/scripts/review-scope.mjs` 列出要审的文件和跳过的文件(删除、二进制、敏感文件、依赖目录、生成文件与锁文件、文档、本工具产物,各带原因;`--base` 含未提交和未跟踪的新文件),给出每个文件的风险级别、分批,以及按文件类型和改动内容该看审查清单的哪几节;报告里每个文件都要有交代。`rules <文件…>` 只打印这些小节的原文,团队在 DocNexus 改过的清单也适用。按改动内容判断风险和小节时不看注释和 JS / TS 的正则字面量;`*.md.tpl` 算文档;画像里写错的 glob(如没闭合的 `{`)按普通字符处理。
|
|
11
|
+
- 审查发现按原代码片段定位行号:新脚本 `workflow/scripts/locate.mjs` 读发现文件(每条带 `file` 和 `code`),在文件里查找片段算出起止行,标出是否落在本次改动上;找不到的要回去重读代码,仍找不到就降级为待确认。报告里的行号一律用它的结果。
|
|
12
|
+
- 画像新增可选的 `review` 字段:`exclude` / `include` 调整审查范围,`rules` 按路径 glob 给文件加项目规则,和审查清单一起检查。
|
|
13
|
+
- dev 自审用 `review-scope.mjs --base <主分支>`,Step 0 确认与本需求无关的文件用 `--exclude` 排除。
|
|
14
|
+
|
|
15
|
+
## [0.22.2] - 2026-10-19
|
|
16
|
+
|
|
17
|
+
### 文档
|
|
18
|
+
- 新增架构介绍页 `docs/architecture.html`:五层结构、dev 主线流程与规模分级、设计约定、8 个 skill 与依赖、产物与 DocNexus 知识闭环、运行反馈;README 快速开始里加了链接。
|
|
19
|
+
- 推荐的安装命令默认带 `--with-docnexus --with-codegraph`:README 快速开始和非交互示例、架构介绍页、`docs-repo` 在还没安装时的提示都改成全量安装;最小安装(只装 skills)作为示例保留。
|
|
20
|
+
|
|
7
21
|
## [0.22.1] - 2026-10-19
|
|
8
22
|
|
|
9
23
|
### 变更
|
package/README.md
CHANGED
|
@@ -13,28 +13,30 @@
|
|
|
13
13
|
> 本工具在项目里的产出(需求产物、DocNexus 工作区、项目模板、临时文件)都放在项目根的
|
|
14
14
|
> `workflow-run/`
|
|
15
15
|
> 一个目录里,不往项目的其他地方写;它可以随代码提交,也可以整个是团队的独立文档仓库(见「独立文档仓库」)。
|
|
16
|
-
> 安装只用 `npx @rowansenne/workflow-run
|
|
16
|
+
> 安装只用 npx,在项目根执行。推荐 `npx @rowansenne/workflow-run --with-docnexus --with-codegraph`,同时接入 DocNexus 文档库和 CodeGraph 代码图谱。
|
|
17
17
|
|
|
18
18
|
## 快速开始
|
|
19
19
|
|
|
20
|
-
需要 **Node.js >= 22
|
|
20
|
+
需要 **Node.js >= 22.13**(DocNexus 的要求;不接 DocNexus 时 >= 22 即可,code-graph 的 `impact` / `entry` 需 >= 22.5)。只用 npx 安装,在项目根执行(以当前目录为项目根;Windows 要在管理员终端里运行)。推荐全量安装:
|
|
21
21
|
|
|
22
22
|
```
|
|
23
|
-
npx @rowansenne/workflow-run
|
|
23
|
+
npx @rowansenne/workflow-run --with-docnexus --with-codegraph
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
按提示:选择环境(编号多选,回车 = 全部)→ 确认 / 填写数据库连接 →
|
|
26
|
+
按提示:选择环境(编号多选,回车 = 全部)→ 确认 / 填写数据库连接 → 自动生成。`--with-docnexus` 接入 DocNexus 文档库(开发约定与审查问题的沉淀和召回、团队定制的文档模板),`--with-codegraph` 接入 CodeGraph 代码图谱(改动影响到的接口、MQ、定时任务和测试);不需要哪一项就去掉对应参数。装完后在 agent 里运行 `/workflow profile` 填写项目画像。
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
全量安装的分步说明见图文教程 [docs/full-install.html](docs/full-install.html)(下载后用浏览器打开)。
|
|
29
|
+
|
|
30
|
+
整体架构(五层结构、dev 主线流程、设计约定、产物与知识闭环)见 [docs/architecture.html](docs/architecture.html)(下载后用浏览器打开)。
|
|
29
31
|
|
|
30
32
|
非交互示例(bash、zsh、PowerShell 写法一样):
|
|
31
33
|
|
|
32
34
|
```
|
|
33
|
-
npx @rowansenne/workflow-run --envs all --yes
|
|
34
|
-
npx @rowansenne/workflow-run --envs codex,qwen --skip-db
|
|
35
|
-
npx @rowansenne/workflow-run --envs
|
|
36
|
-
npx @rowansenne/workflow-run --envs
|
|
37
|
-
npx @rowansenne/workflow-run uninstall
|
|
35
|
+
npx @rowansenne/workflow-run --envs all --yes --with-docnexus --with-codegraph # 用现有数据库配置,装全部环境
|
|
36
|
+
npx @rowansenne/workflow-run --envs codex,qwen --skip-db --with-docnexus --with-codegraph # 跳过数据库
|
|
37
|
+
npx @rowansenne/workflow-run --envs all --skip-db --copy --with-docnexus --with-codegraph # 不用软链接,各环境独立拷贝
|
|
38
|
+
npx @rowansenne/workflow-run --envs claude --skip-db # 最小安装:只装 skills,不接 DocNexus 和 CodeGraph
|
|
39
|
+
npx @rowansenne/workflow-run uninstall # 按安装清单卸载
|
|
38
40
|
```
|
|
39
41
|
|
|
40
42
|
### 子命令
|
|
@@ -406,6 +408,8 @@ npx @rowansenne/workflow-run uninstall
|
|
|
406
408
|
| `workflow/scripts/artifacts.mjs [--branch b \| --all]` | 按分支汇总 `workflow-run/docs/requirements/` 下的开发方案、交付说明、审查报告、技术方案、上线方案及其 wiki 发布记录,给出下一步建议;分支取自文档元信息行,不另存记录。上线方案没传 `--tech` 时据此自动引用同分支的技术方案;project-review 据此对照本分支方案、识别复审 |
|
|
407
409
|
| `workflow/scripts/report.mjs [--since d] [--all-branches] [--json]` | 本地效果报告(`/workflow report`、`npx @rowansenne/workflow-run report`):审查发现与修复率、复核率、需求澄清、沉淀率、知识引用、各分支链路 |
|
|
408
410
|
| `workflow/scripts/feedback.mjs add … / summary [--json]` | 运行反馈:dev / project-review 交付后追加一行过程记录到 `workflow-run/feedback/<用户>.jsonl`;`summary` 按 skill 汇总,跳过的步骤和不清楚的规则按 SKILL.md 章节归类(`npx @rowansenne/workflow-run feedback`);`init` / `upgrade` 装了新版本时清掉本项目旧版本的记录 |
|
|
411
|
+
| `workflow/scripts/review-scope.mjs [--diff b...h \| --base b] [--exclude a,b]` / `rules <文件…>` | 审查范围:由脚本决定审哪些文件、跳过哪些(删除、二进制、敏感文件、依赖目录、生成文件、文档、本工具产物,各带原因)、每个文件的风险级别和分批,以及按文件类型和改动内容该看审查清单的哪几节;`--base` 含未提交和未跟踪的新文件。`rules` 打印这些小节的原文和画像 `review.rules` 里匹配的项目规则。project-review 第 1、3、4 步与 dev 自审使用 |
|
|
412
|
+
| `workflow/scripts/locate.mjs <发现.json> [--diff b...h \| --base b]` | 给审查发现定位行号:按每条发现引用的原代码片段在文件里查找(精确 → 忽略空白 → 单行部分匹配),多处时优先本次改动的行,输出起止行、是否落在本次改动上;找不到的标出来要求重读代码。project-review 第 6 步复核使用 |
|
|
409
413
|
| `workflow/scripts/tables.mjs [--diff b...h \| --base b \| --files a,b]` | 找出改动涉及哪些表:实体注解(`@TableName` / `@Table`,没写表名时按类名推断)、Mapper XML 与代码里的 SQL、DDL 脚本;标出 DDL、是否在 `schema.md` 里,并给出拉表结构的命令。dev 调研 / 自检与 project-review 审 SQL / DDL 时使用 |
|
|
410
414
|
| `workflow/scripts/template.mjs list / draft / check / save <模板>` | 修改 / 创建模板(`/workflow template`):把当前模板复制成草稿(或 `--from <样例>` 抽骨架),`check` 校验格式、列出章节变化、拿最近几份已生成文档试跑 lint 看影响,`save` 给出写入 DocNexus 知识库的 capture 指引,不直接写知识库;未接入 DocNexus 时 `draft` / `save` 提示先接入(默认模板只读) |
|
|
411
415
|
| `workflow/scripts/pr-desc.mjs [--branch b] [--base m] [--out f]` | 按本分支产物生成 PR / MR 描述;没有交付说明时退回用 git 记录 |
|
package/bin/lib/docsrepo.mjs
CHANGED
|
@@ -95,7 +95,7 @@ export function docsRepo(root, { url, project }) {
|
|
|
95
95
|
if (tracked) warn(`代码仓库里还跟踪着 ${HOME}/ 下的文件,运行 git rm -r --cached ${HOME} 后提交,代码仓库就不再管它`);
|
|
96
96
|
|
|
97
97
|
if (!installed) {
|
|
98
|
-
warn(`本项目还没安装 skills:先运行 npx @rowansenne/workflow-run,项目名之后用 docs-repo --project <项目名> 配置(或第一次落盘时 skill 会问)`);
|
|
98
|
+
warn(`本项目还没安装 skills:先运行 npx @rowansenne/workflow-run --with-docnexus --with-codegraph,项目名之后用 docs-repo --project <项目名> 配置(或第一次落盘时 skill 会问)`);
|
|
99
99
|
return 0;
|
|
100
100
|
}
|
|
101
101
|
const name = project || artifactsConfig(root).project || suggestProject(root);
|
package/dev/SKILL.md.tpl
CHANGED
|
@@ -97,7 +97,7 @@ description: "Implement a feature or fix a bug in this project end to end: under
|
|
|
97
97
|
|
|
98
98
|
### F7 — 自审
|
|
99
99
|
|
|
100
|
-
- 按 `project-review` skill
|
|
100
|
+
- 按 `project-review` skill 审本次改动,范围用 `node <skills>/workflow/scripts/review-scope.mjs --base <main_branch>`:分支已有的提交、未提交的改动和未跟踪的新文件一起列出。Step 0 确认不属于本需求的文件用 `--exclude <文件,文件>` 排除,它们不计入本次结论(见 Step 0「工作区状态」);之后 project-review 的 `rules`、`locate.mjs` 都带同样的参数。
|
|
101
101
|
- 只走 project-review 的第 1~6 步(范围与深度、建立理解、对比、过清单、影响面、复核);**跳过第 7 步落盘报告、第 8 步 DocNexus 询问和第 9 步运行反馈**——结论由 dev 在 F8 统一交付,沉淀只在 F8 问一次,反馈也只由 dev 记一行。用户要求单独的审查报告时,再按 project-review 的第 7 步和产物规范落盘。
|
|
102
102
|
- 交给 F8 的是:问题表(文件:行号、严重级别、触发条件、复核结果)、影响面、未覆盖与待确认。
|
|
103
103
|
- CRITICAL / HIGH 必须修掉后重新跑 F6;MEDIUM / LOW 修掉或写进交付说明。
|
package/package.json
CHANGED
|
@@ -7,7 +7,7 @@ description: "Review source code (any language; Java/Spring, Go, TypeScript, Pyt
|
|
|
7
7
|
|
|
8
8
|
## Workflow
|
|
9
9
|
|
|
10
|
-
1. **确定审查范围与深度**:范围是单文件 / 单方法、分支对比(如 `branch` vs `main
|
|
10
|
+
1. **确定审查范围与深度**:范围是单文件 / 单方法、分支对比(如 `branch` vs `main`),还是未提交改动(见 `references/diff-strategy.md`「未提交改动」)。分支对比和未提交改动都先运行 `node <skills>/workflow/scripts/review-scope.mjs --diff <基线>...<dev>`(未提交改动用 `--base <基线>`,含未跟踪的新文件;dev 自审时加 dev 传来的 `--exclude`),**审哪些文件以它的 `review` 为准**:`skipped` 是按规则跳过的(删除、二进制、敏感文件、依赖目录、生成文件、文档、本工具的产物,各带原因),不要自己再增减;确实要审被跳过的,在画像 `review.include` 里加(见 `<skills>/workflow/references/profile.md`)。深度按用户的说法定,没说就用「标准」,写进报告「审查方式」:
|
|
11
11
|
- **快审**(「快速看一下」「扫一眼」):只查正确性、空值、安全、事务与幂等,不查性能、测试、风格;「优化建议(LOW)」写「无」。CRITICAL / HIGH 照样复核。
|
|
12
12
|
- **标准**(默认):检查清单全部维度,**风格类(清单「一致性 / 风格」一节)不查**;「优化建议(LOW)」只写有实际收益的(性能、可维护性上的具体问题),没有就写「无」。
|
|
13
13
|
- **深审**(「仔细审」「全面审」,或资金 / 权限 / 对外接口的大改动合并前):全部维度,包括风格;MEDIUM 也复核。
|
|
@@ -18,14 +18,14 @@ description: "Review source code (any language; Java/Spring, Go, TypeScript, Pyt
|
|
|
18
18
|
- `latest.delivery` 存在(dev 的交付说明)→ 其中「已确认的决定」并入需求对照的依据;「配置清单」「SQL 脚本」和 diff 对照,diff 里有而交付说明漏列的配置 key / DDL 记为 MEDIUM(上线时会被漏掉)。
|
|
19
19
|
- `latest.review` 存在 → 这是**复审**:读上次报告的问题表,并用 `git log --oneline --since=<上次报告日期> <dev分支>` 看之后改了什么。本次报告对上次的每条问题标「已修复 / 仍存在 / 不再适用」(「已修复」要看到对应改动),本次新发现的标「新增」。
|
|
20
20
|
- **涉及数据库**:diff 里有 SQL、Mapper、实体或 DDL 时,按 `<skills>/workflow/references/tables.md` 运行 `node <skills>/workflow/scripts/tables.mjs --diff <基线>...<dev>`(未提交改动用 `--base <基线>`)找出涉及的表;数据库已配置(`resolve.mjs status` 的 `database.ok`)时用 `query.mjs --schema --tables <表> --indexes` 只读拉取实际字段、索引与估算行数,作为第 4、6 步判断索引支撑、字段存在与类型、DDL 锁表与滚动发布兼容的依据。查不了库时注明「未与实际库核对」写进「未覆盖与待确认」,这类发现不升到 HIGH 以上,除非 diff 本身就能证明。
|
|
21
|
-
3. **分支对比**:基线默认取 `workflow/profile.json` 的 `main_branch`(未配置则 `main
|
|
22
|
-
4.
|
|
21
|
+
3. **分支对比**:基线默认取 `workflow/profile.json` 的 `main_branch`(未配置则 `main`)。规模和分批用第 1 步 `review-scope.mjs` 的输出:`summary.size` 是「全量精读」就一次读完;「分批精读」按 `batches` 的顺序(P0 → P1 → P2)读,P0 不能跳过;「过大」时停下请用户缩小范围。审不到的文件写进报告(读法见 `references/diff-strategy.md`)。**深审且改动属于「更大」、环境又能派子 agent 时,先问用户要不要分角度并行查找**(`references/diff-strategy.md`「分角度并行查找」),用户同意才走,第 4 步改由各子 agent 按角度过清单。只审本次改动引入的问题,同时检查改动是否破坏既有调用方。
|
|
22
|
+
4. **逐项过检查清单**:每个文件只看 `review-scope.mjs` 给它的 `sections`(清单小节号,按文件类型和改动内容定),一批文件用 `node <skills>/workflow/scripts/review-scope.mjs rules <文件…>`(带上和第 1 步一样的 `--diff` / `--base`)打印这些小节的原文和画像 `review.rules` 里匹配到的项目规则,按它检查;整个改动再过一次 `overall_sections`(9 影响面、13 测试)。深审读整份清单(`node <skills>/workflow/scripts/resolve.mjs template project-review checklist.md` 取 `path`;退出码 2 时停下并按 `hint` 处理),包括风格。对每个发现给出 文件、**问题所在的原代码片段**(从文件里照原样复制,第 6 步靠它定位行号)、问题描述、严重级别、修改建议。
|
|
23
23
|
5. **影响面分析**:改动是否影响其他调用方/模块。先运行 `node <skills>/code-graph/scripts/cg.mjs impact --diff <基线>...<dev>`(code-graph skill;未提交改动用 `--base <基线>`):列出受影响的接口、MQ 消费者、定时任务、测试、表和配置 key,报告「影响面」按入口归纳并带文件:行号;退出码 2(没有索引等)时用 grep 或精读调用链确认。Spring 注入、AOP、MyBatis XML、MQ topic、配置 key 图谱看不到,照常 grep(见 `<skills>/workflow/references/codegraph.md`)。查不到调用方的符号不能当作安全,要明说"未确认"。
|
|
24
24
|
6. **复核**:交付前逐条复核 CRITICAL / HIGH(深审时 MEDIUM 也复核;其余 MEDIUM 至少做下面第 1 条)。审查结论会被用来决定能不能合并,一条误报会让人白改代码,也会让后面的真问题不被当回事。找出问题的人自己复核容易顺着原来的思路认同自己,所以**能交给独立的一方就交出去**:
|
|
25
25
|
- **独立复核(首选)**:当前环境能派子 agent / 子任务时(例如 Claude Code 的 Agent 工具、Codex / Qwen Code 的子 agent),每条发现派一个,互不相关的可以同时派。只给它:一句话结论、位置(文件:行号)、你写的触发条件、对比分支(或「未提交改动」)。**不给你的推理过程**,也不告诉它你认为成立。要它自己读代码,按下面 1~4 条检查,回答「成立 / 可能成立 / 不成立」并引用代码作为依据。
|
|
26
26
|
- **自行复核**:环境派不了子 agent 时自己做,先找它**不成立**的理由,找不到再确认成立。
|
|
27
27
|
检查项(独立复核和自行复核都按这 4 条):
|
|
28
|
-
1.
|
|
28
|
+
1. **定位**:把所有发现(包括 MEDIUM / LOW)写成 `workflow-run/tmp/review/findings.json`(数组,每条 `{ "file", "code"(原代码片段), "line"(你估的行号,可选), "title" }`),运行 `node <skills>/workflow/scripts/locate.mjs workflow-run/tmp/review/findings.json`(带上和第 1 步一样的 `--diff` / `--base`)。报告里的行号一律用它输出的 `start_line`~`end_line`,不用自己数的。`anchored: false` 的回去重读代码、照原样改片段再跑一次,还找不到就降为 MEDIUM 并标「⚠ 待确认」;`ambiguous` 时看 `candidates` 确认是哪一处;`in_change: false` 说明问题不在本次改动的行上,按第 4 条判断是不是本次引入的。
|
|
29
29
|
2. 写出**触发条件**:什么输入 / 状态下会出错、错成什么样。写不出来 → 降为 MEDIUM 并标「⚠ 待确认」。
|
|
30
30
|
3. 找有没有别处已经兜住:调用方校验、`@Validated` / 全局异常处理、数据库约束、框架默认行为、配置。涉及索引、字段、唯一约束、表大小的发现,以第 2 步拉到的实际表结构为准(复核列写「已与 <连接名> 核对」)。已兜住 → 删掉或降级,并在说明里写依据。
|
|
31
31
|
4. 确认是本次改动引入的:改动前就存在的问题移到「未覆盖与待确认」,不计入合并结论。
|
|
@@ -85,11 +85,11 @@ description: "Review source code (any language; Java/Spring, Go, TypeScript, Pyt
|
|
|
85
85
|
|
|
86
86
|
## Verification
|
|
87
87
|
|
|
88
|
-
-
|
|
88
|
+
- 每个结论都要能定位到具体文件+行号,并引用真实代码片段;行号来自 `locate.mjs`。
|
|
89
89
|
- 读不到的内容如实说明,不要臆测。
|
|
90
90
|
- 涉及跨分支对比时,确保 diff 基于正确的 base 分支(默认 `main`)。
|
|
91
91
|
- CRITICAL / HIGH 都经过第 6 步复核(能派子 agent 时独立复核),写得出触发条件;复核不了的标「⚠ 待确认」,不计入「不建议合并」的结论。
|
|
92
|
-
-
|
|
92
|
+
- `review-scope.mjs` 的每个文件都有交代:`review` 里的都审过,没精读的逐个列在「未覆盖与待确认」;`skipped` 按原因汇总写进同一节(例如「跳过 12 个:生成文件 8、文档 3、敏感文件 1」,敏感文件单独点名提醒)。
|
|
93
93
|
|
|
94
94
|
## 终端命令
|
|
95
95
|
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
## 1. 先量规模
|
|
6
6
|
|
|
7
|
+
`node <skills>/workflow/scripts/review-scope.mjs --diff <main>...<dev>` 已经按下面的规则算好:跳过哪些文件、`summary.size`(全量精读 / 分批精读 / 过大)、每个文件的风险级别和 `batches`。下面的命令和表是它的规则说明,脚本跑不了时手动照做:
|
|
8
|
+
|
|
7
9
|
```bash
|
|
8
10
|
git diff <main>...<dev> --shortstat
|
|
9
11
|
git diff <main>...<dev> --name-only # 再按 diff-collection-common.md 的排除规则去掉测试、生成代码、文档
|
|
@@ -34,7 +36,7 @@ git diff <main>...<dev> --name-only -G '@Transactional|synchronized|Lock\b|@Vers
|
|
|
34
36
|
|
|
35
37
|
## 3. 分批精读
|
|
36
38
|
|
|
37
|
-
- 每批不超过约 15 个文件或 800 行改动,P0 → P1 → P2
|
|
39
|
+
- 每批不超过约 15 个文件或 800 行改动,P0 → P1 → P2(`review-scope.mjs` 的 `batches` 就是这样分的,按它的顺序读)。
|
|
38
40
|
- 每个文件读 `git diff <main>...<dev> -- <文件>`,并用 `git show <dev>:<文件>` 读改动所在方法的上下文(调用方、判空、事务注解),不只看 hunk。
|
|
39
41
|
- 每批结束先把发现记下来(文件:行号 + 一句话)再进入下一批,防止后面的批次挤掉前面的结论;批次多时可以边审边把发现写进报告草稿。
|
|
40
42
|
- 时间或上下文不够时,**P2 可以只看 `--stat`**,但要在报告「未覆盖与待确认」里列出只扫过 `--stat` 的文件;P0 不能跳过。
|
|
@@ -72,4 +74,4 @@ git diff <main>...<dev> --name-only -G '@Transactional|synchronized|Lock\b|@Vers
|
|
|
72
74
|
|
|
73
75
|
## 5. 未提交改动
|
|
74
76
|
|
|
75
|
-
审查工作区(dev 自审或用户说「我刚改完,帮我看看」):`
|
|
77
|
+
审查工作区(dev 自审或用户说「我刚改完,帮我看看」):`review-scope.mjs --base <main>` 把分支已有的提交、已暂存和未暂存的改动、未跟踪的新文件合在一起列出来。读 diff 用 `git diff $(git merge-base <main> HEAD) -- <文件>`(PowerShell 先单独运行 `git merge-base <main> HEAD` 拿到提交号再代入);未跟踪的新文件没有 diff,直接读整个文件。
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
| `data_review` | 上线前是否需要数据团队评审库表(`true` / `false`) | 技术方案模块 7、上线方案 2.3 |
|
|
15
15
|
| `notes` | 其他项目约定,一条一句(如「启动需要本地 Redis」) | 全部 |
|
|
16
16
|
| `artifacts` | 可选:`{ "project": "order-service", "commit_prompt": true }`。`project` 是这个项目的产物目录名(产物在 `workflow-run/docs/requirements/<需求名>/<项目名>/`,随代码提交和独立文档仓库两种用法都一样):随代码提交时不配置就取 git 远端仓库名,第一次落盘时自动写进来;独立文档仓库时要先配置;`commit_prompt: false` 关闭写完后的提交提示。个人设置可写在 `workflow/profile.local.json`(不提交)覆盖 | 见 `references/requirements.md`,用 `requirement.mjs config` 修改 |
|
|
17
|
+
| `review` | 可选,审查范围与项目规则,如 `{"exclude": ["**/legacy/**"], "include": ["docs/api/**/*.md"], "rules": [{"path": "**/*Mapper.xml", "rule": "分页查询必须带 ORDER BY 主键"}]}`。`exclude` 不审;`include` 把默认跳过的生成文件、文档、本工具产物加回来(删除、二进制、敏感文件加不回来);`rules` 按 glob 匹配文件路径(不含 `/` 的模式匹配文件名,`**` 跨目录,`{a,b}` 多选),匹配到的规则和审查清单一起检查,不替换清单 | `review-scope.mjs`(project-review、dev 自审) |
|
|
17
18
|
| `docnexus_categories` | 可选,覆盖各 skill 写入 DocNexus 时的分类,如 `{"create-tech-md-doc": "技术方案"}`;不填用 skill 默认 | 见 `references/docnexus.md` |
|
|
18
19
|
|
|
19
20
|
其他 skill 读画像统一用 `node <skills>/workflow/scripts/resolve.mjs profile`:输出 `profile`(文件内容)、`empty`(空字段)、`complete`(`main_branch` / `build` / `test` 都已填写)、`env`(本机系统与终端,见下)。
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// 给审查发现定位行号:每条发现带上问题所在的原代码片段(code),由脚本在文件里找到它、算出起止行,不靠模型自己数行号。
|
|
3
|
+
// project-review 第 6 步复核前使用。片段先精确匹配,再按忽略空白匹配;单行片段还可以是某一行的一部分。
|
|
4
|
+
// 有多处匹配时优先本次改动的行,再取离模型给的 line 最近的一处,并标 ambiguous。找不到的标 anchored: false,要回去重读代码改片段。
|
|
5
|
+
// 范围参数和 review-scope.mjs 一样,决定读哪个版本的文件、哪些行算本次改动(in_change)。
|
|
6
|
+
//
|
|
7
|
+
// 用法(在项目根执行;--root 可指定项目根):
|
|
8
|
+
// node .agents/skills/workflow/scripts/locate.mjs <发现.json> [--diff <base>...<head> | --base <分支>]
|
|
9
|
+
// <发现.json> 是数组:[{ "file": "src/A.java", "code": "原代码片段(可多行)", "line": 42(可选,模型估的行号), …其他字段原样保留 }]
|
|
10
|
+
// 输出 JSON:{ ok, ref, anchored, total, findings: [{ …原字段, start_line, end_line, anchored, match, in_change, ambiguous? }] }。
|
|
11
|
+
// 退出码:0 正常(有没定位到的也是 0,看 anchored);1 参数错误、文件读不到或 git 失败
|
|
12
|
+
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { loadDiff, readAt } from './review-scope.mjs';
|
|
17
|
+
|
|
18
|
+
const loose = (s) => s.replace(/\s+/g, ' ').trim();
|
|
19
|
+
|
|
20
|
+
// 片段 → 行数组:去掉首尾空行、行尾空白;整段都是从 diff 里复制来的(每行以 + 或空格开头且至少一行是 +)时去掉这一列
|
|
21
|
+
export function snippetLines(code) {
|
|
22
|
+
let lines = String(code ?? '').replace(/\r/g, '').split('\n').map((l) => l.replace(/\s+$/, ''));
|
|
23
|
+
while (lines.length && !lines[0].trim()) lines.shift();
|
|
24
|
+
while (lines.length && !lines.at(-1).trim()) lines.pop();
|
|
25
|
+
const body = lines.filter((l) => l.length);
|
|
26
|
+
if (body.length && body.every((l) => /^[+ ]/.test(l)) && body.some((l) => l.startsWith('+'))) lines = lines.map((l) => l.slice(1));
|
|
27
|
+
return lines;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 在 fileLines 里找 snip 的所有位置(1 起的行号)
|
|
31
|
+
export function findAll(fileLines, snip) {
|
|
32
|
+
const n = snip.length;
|
|
33
|
+
if (!n) return { match: 'none', hits: [] };
|
|
34
|
+
const scan = (eq) => {
|
|
35
|
+
const hits = [];
|
|
36
|
+
for (let i = 0; i + n <= fileLines.length; i++) {
|
|
37
|
+
let ok = true;
|
|
38
|
+
for (let j = 0; j < n && ok; j++) ok = eq(fileLines[i + j], snip[j]);
|
|
39
|
+
if (ok) hits.push(i + 1);
|
|
40
|
+
}
|
|
41
|
+
return hits;
|
|
42
|
+
};
|
|
43
|
+
let hits = scan((a, b) => a.replace(/\s+$/, '') === b);
|
|
44
|
+
if (hits.length) return { match: 'exact', hits };
|
|
45
|
+
hits = scan((a, b) => loose(a) === loose(b));
|
|
46
|
+
if (hits.length) return { match: 'loose', hits };
|
|
47
|
+
// 单行片段:允许是某一行的一部分(至少 8 个非空白字符,避免太短的片段到处都能匹配)
|
|
48
|
+
if (n === 1 && loose(snip[0]).replace(/ /g, '').length >= 8) {
|
|
49
|
+
hits = fileLines.map((l, i) => (loose(l).includes(loose(snip[0])) ? i + 1 : 0)).filter(Boolean);
|
|
50
|
+
if (hits.length) return { match: 'partial', hits };
|
|
51
|
+
}
|
|
52
|
+
return { match: 'none', hits: [] };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const overlaps = (s, e, hunks) => hunks.some(([a, b]) => s <= b && e >= a);
|
|
56
|
+
|
|
57
|
+
export function locateOne(finding, { read, hunksOf }) {
|
|
58
|
+
const out = { ...finding };
|
|
59
|
+
const file = String(finding.file ?? '').replace(/\\/g, '/').replace(/^\.\//, '');
|
|
60
|
+
const text = file ? read(file) : null;
|
|
61
|
+
if (text == null) return { ...out, anchored: false, match: 'none', reason: file ? '文件读不到(路径写错或不在这个版本里)' : '缺少 file' };
|
|
62
|
+
const snip = snippetLines(finding.code);
|
|
63
|
+
if (!snip.length) return { ...out, anchored: false, match: 'none', reason: '缺少 code(问题所在的原代码片段)' };
|
|
64
|
+
const fileLines = text.replace(/\r/g, '').split('\n');
|
|
65
|
+
const { match, hits } = findAll(fileLines, snip);
|
|
66
|
+
if (!hits.length) return { ...out, anchored: false, match: 'none', reason: '片段在文件里找不到:回去重读代码,照原样复制片段' };
|
|
67
|
+
const hunks = hunksOf(file);
|
|
68
|
+
const span = snip.length - 1;
|
|
69
|
+
const inChange = hits.filter((s) => overlaps(s, s + span, hunks));
|
|
70
|
+
const pool = inChange.length ? inChange : hits;
|
|
71
|
+
const guess = Number(finding.line); // 模型可能写成字符串 "42"
|
|
72
|
+
const near = Number.isInteger(guess) && guess > 0 ? [...pool].sort((a, b) => Math.abs(a - guess) - Math.abs(b - guess)) : pool;
|
|
73
|
+
const start = near[0];
|
|
74
|
+
return {
|
|
75
|
+
...out, start_line: start, end_line: start + span, anchored: true, match,
|
|
76
|
+
in_change: overlaps(start, start + span, hunks),
|
|
77
|
+
...(pool.length > 1 ? { ambiguous: true, candidates: pool.slice(0, 10) } : {}),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function locate(root, findings, opts = {}) {
|
|
82
|
+
const d = loadDiff(root, opts);
|
|
83
|
+
if (!d.ok) return d;
|
|
84
|
+
const byPath = new Map(d.files.map((f) => [f.path, f.hunks]));
|
|
85
|
+
const cache = new Map();
|
|
86
|
+
const read = (f) => { if (!cache.has(f)) cache.set(f, readAt(root, d.ref, f)); return cache.get(f); };
|
|
87
|
+
const res = findings.map((f) => locateOne(f, { read, hunksOf: (p) => byPath.get(p) ?? [] }));
|
|
88
|
+
return { ok: true, ref: d.ref, range: d.range, total: res.length, anchored: res.filter((x) => x.anchored).length, findings: res };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function main(argv) {
|
|
92
|
+
let root = process.cwd();
|
|
93
|
+
const o = {};
|
|
94
|
+
const rest = [];
|
|
95
|
+
for (let i = 0; i < argv.length; i++) {
|
|
96
|
+
if (argv[i] === '--root') root = path.resolve(argv[++i] ?? '.');
|
|
97
|
+
else if (argv[i] === '--diff') o.diff = argv[++i] ?? null;
|
|
98
|
+
else if (argv[i] === '--base') o.base = argv[++i] ?? null;
|
|
99
|
+
else if (argv[i] === '--exclude') i++; // 和 review-scope.mjs 用同一组参数时带过来的,定位用不到
|
|
100
|
+
else rest.push(argv[i]);
|
|
101
|
+
}
|
|
102
|
+
if (rest.length !== 1) { console.error('用法: locate.mjs <发现.json> [--diff <base>...<head> | --base <分支>]'); return 1; }
|
|
103
|
+
let findings;
|
|
104
|
+
try { findings = JSON.parse(fs.readFileSync(path.resolve(root, rest[0]), 'utf8')); } catch (e) { console.error(`读不了 ${rest[0]}:${e.message}`); return 1; }
|
|
105
|
+
if (!Array.isArray(findings)) { console.error(`${rest[0]} 要是数组:[{ "file": …, "code": … }]`); return 1; }
|
|
106
|
+
const r = locate(root, findings, o);
|
|
107
|
+
console.log(JSON.stringify(r, null, 2));
|
|
108
|
+
return r.ok ? 0 : 1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export { main };
|
|
112
|
+
const invoked = process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
113
|
+
if (invoked) process.exit(main(process.argv.slice(2)));
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// 审查范围:由脚本决定审哪些文件、跳过哪些(带原因)、每个文件看审查清单的哪几节,不让模型自己挑着看。
|
|
3
|
+
// project-review 第 1 步与 dev 自审(F7)使用;报告里每个文件都要有交代(已审 / 跳过的原因 / 没审完)。
|
|
4
|
+
// - 范围:分支对比 `--diff <base>...<head>`(读 <head>),或 `--base <分支>`:相对合并基点的已提交 + 未提交 + 未跟踪改动(读工作区)
|
|
5
|
+
// - 跳过:删除、二进制、敏感文件(不读内容)、依赖目录、生成文件与锁文件、文档、本工具的产物;画像 review.include 可以把后三类加回来
|
|
6
|
+
// - 风险 P0 / P1 / P2 与分批:规则同 project-review/references/diff-strategy.md
|
|
7
|
+
// - 清单路由:按文件类型和改动内容给出审查清单的小节号;画像 review.rules 里按路径匹配的项目规则一起给出
|
|
8
|
+
// `rules <文件…>` 打印这些文件要看的清单小节原文和项目规则:清单用 resolveTemplate 取 project-review 的 checklist.md(团队在 DocNexus 改过的版本也适用)。
|
|
9
|
+
// 只有 project-review 和 dev 自审会调用 rules,project-review 没装时它报错退出(resolveTemplate 提示没有安装)。
|
|
10
|
+
//
|
|
11
|
+
// 用法(在项目根执行;--root 可指定项目根):
|
|
12
|
+
// node .agents/skills/workflow/scripts/review-scope.mjs --diff <base>...<head> [--exclude a,b]
|
|
13
|
+
// node .agents/skills/workflow/scripts/review-scope.mjs [--base <分支>] [--exclude a,b] # 缺省基线取画像 main_branch(再缺省 main)
|
|
14
|
+
// node .agents/skills/workflow/scripts/review-scope.mjs rules <文件> [<文件>…]
|
|
15
|
+
// 输出 JSON(rules 输出 Markdown)。退出码:0 正常;1 参数错误或 git 失败
|
|
16
|
+
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { spawnSync } from 'node:child_process';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import { readProfile, resolveTemplate } from './resolve.mjs';
|
|
22
|
+
|
|
23
|
+
const git = (root, args) => spawnSync('git', ['-c', 'core.quotepath=false', ...args], { cwd: root, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
|
|
24
|
+
const toPosix = (p) => p.split(path.sep).join('/');
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------- glob
|
|
27
|
+
// ** 跨目录、* 不跨 /、? 单个字符、{a,b} 展开(括号里同样支持这些写法);不含 / 的模式匹配文件名(同 .gitignore)。不区分大小写。
|
|
28
|
+
// 没有闭合的 { 按普通字符处理(画像里写错也不会卡住)
|
|
29
|
+
function globSource(glob) {
|
|
30
|
+
let re = '';
|
|
31
|
+
for (let i = 0; i < glob.length; i++) {
|
|
32
|
+
const c = glob[i];
|
|
33
|
+
const end = c === '{' ? glob.indexOf('}', i) : -1;
|
|
34
|
+
if (c === '*' && glob[i + 1] === '*') {
|
|
35
|
+
const slash = glob[i + 2] === '/';
|
|
36
|
+
re += slash ? '(?:.*/)?' : '.*';
|
|
37
|
+
i += slash ? 2 : 1;
|
|
38
|
+
} else if (c === '*') re += '[^/]*';
|
|
39
|
+
else if (c === '?') re += '[^/]';
|
|
40
|
+
else if (end > i) { re += `(?:${glob.slice(i + 1, end).split(',').map(globSource).join('|')})`; i = end; }
|
|
41
|
+
else re += c.replace(/[.+^$(){}|[\]\\]/g, '\\$&');
|
|
42
|
+
}
|
|
43
|
+
return re;
|
|
44
|
+
}
|
|
45
|
+
export const globToRegExp = (glob) => new RegExp(`^${globSource(glob)}$`, 'i');
|
|
46
|
+
export const matchGlob = (p, glob) => globToRegExp(glob).test(glob.includes('/') ? p : p.split('/').pop());
|
|
47
|
+
const matchAny = (p, globs = []) => globs.find((g) => matchGlob(p, g)) ?? null;
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------- 跳过规则
|
|
50
|
+
const SECRET = ['**/.env', '**/.env.*', '**/*.pem', '**/*.key', '**/*.p12', '**/*.pfx', '**/*.jks', '**/*.keystore', '**/id_rsa*', '**/id_dsa*', '**/id_ecdsa*', '**/id_ed25519*', '**/.ssh/**', '**/.netrc', '**/.npmrc', '**/.pypirc', '**/database-query/config.json', '**/wiki-config.json'];
|
|
51
|
+
const SECRET_OK = ['**/.env.example', '**/.env.sample', '**/.env.template'];
|
|
52
|
+
const DEPENDENCY = ['**/node_modules/**', '**/vendor/**', '**/bower_components/**', '**/target/**', '**/.gradle/**', '**/__pycache__/**', '**/.venv/**', '**/site-packages/**', '**/.next/**', '**/.nuxt/**'];
|
|
53
|
+
const GENERATED = ['**/package-lock.json', '**/yarn.lock', '**/pnpm-lock.yaml', '**/go.sum', '**/Cargo.lock', '**/poetry.lock', '**/composer.lock', '**/Gemfile.lock', '**/*.min.js', '**/*.min.css', '**/*.map', '**/*.pb.go', '**/*.gen.go', '**/*.generated.*', '**/generated/**', '**/__snapshots__/**', '**/*.snap'];
|
|
54
|
+
const DOCS = ['**/*.md', '**/*.md.tpl', '**/*.txt', '**/*.rst', '**/*.adoc', '**/LICENSE', '**/CHANGELOG*'];
|
|
55
|
+
const TOOLING = ['workflow-run/**', '.agents/skills/**', '.claude/skills/**', '.qwen/skills/**', '.codebuddy/skills/**', '.codegraph/**'];
|
|
56
|
+
const NOTE = {
|
|
57
|
+
deleted: '文件已删除,不审内容;影响面里确认没有调用方还在用它',
|
|
58
|
+
binary: '二进制文件',
|
|
59
|
+
secret: '敏感文件,不读内容;它出现在改动里就提醒用户确认是否误提交了凭据',
|
|
60
|
+
dependency: '依赖目录',
|
|
61
|
+
generated: '生成文件或锁文件;依赖变化看 pom.xml / package.json / go.mod 等清单文件',
|
|
62
|
+
docs: '文档',
|
|
63
|
+
tooling: 'workflow-run 的产物或已安装的 skills',
|
|
64
|
+
user_exclude: '用户指定不审(--exclude 或画像 review.exclude)',
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export function skipReason(file, { include = [], exclude = [] } = {}) {
|
|
68
|
+
if (file.status === 'D') return 'deleted';
|
|
69
|
+
if (file.binary) return 'binary';
|
|
70
|
+
const p = file.path;
|
|
71
|
+
if (matchAny(p, SECRET) && !matchAny(p, SECRET_OK)) return 'secret';
|
|
72
|
+
if (matchAny(p, exclude)) return 'user_exclude';
|
|
73
|
+
if (matchAny(p, include)) return null;
|
|
74
|
+
if (matchAny(p, DEPENDENCY)) return 'dependency';
|
|
75
|
+
if (matchAny(p, GENERATED)) return 'generated';
|
|
76
|
+
if (matchAny(p, DOCS)) return 'docs';
|
|
77
|
+
if (matchAny(p, TOOLING)) return 'tooling';
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------- 分类、风险、清单小节
|
|
82
|
+
const LANG = [
|
|
83
|
+
[/\.(java|kt|kts|scala|groovy)$/i, 'Java / Kotlin'], [/\.go$/i, 'Go'], [/\.(ts|tsx|js|jsx|mjs|cjs|vue)$/i, 'TypeScript / JavaScript'],
|
|
84
|
+
[/\.(py|pyi)$/i, 'Python'], [/\.sql$/i, 'SQL / MyBatis'],
|
|
85
|
+
];
|
|
86
|
+
const TEST = /(^|\/)(src\/test\/|tests?\/|__tests__\/)|(Test|Tests|Spec)\.(java|kt|scala|groovy|swift)$|[._-](test|spec)\.[jt]sx?$|_test\.(go|py|rs)$|(^|\/)test_[^/]*\.py$/i;
|
|
87
|
+
const CONFIG = /(^|\/)(application|bootstrap)[^/]*\.(ya?ml|properties)$|\.(properties|conf|ini|toml)$|(^|\/)config\/[^/]*\.(ya?ml|json)$|(^|\/)\.env\.(example|sample|template)$/i;
|
|
88
|
+
const BUILD = /(^|\/)(pom\.xml|build\.gradle(\.kts)?|settings\.gradle(\.kts)?|package\.json|go\.mod|requirements[^/]*\.txt|pyproject\.toml|Cargo\.toml|composer\.json|Gemfile)$/i;
|
|
89
|
+
// 迁移目录只认 SQL 和 Flyway / Liquibase 的变更集格式;db/ 下的代码文件照常按语言判断
|
|
90
|
+
const SQLFILE = /\.sql$|(^|\/)(db|migration|migrations|flyway|liquibase)\/(?:[^/]+\/)*[^/]+\.(xml|ya?ml|json)$/i;
|
|
91
|
+
const MAPPER = /mapper[^/]*\.xml$|dao[^/]*\.xml$/i;
|
|
92
|
+
const DATA_OBJ = /(^|\/)(entity|entities|dto|vo|po|bo|domain|model|models|pojo|converter|convert|assembler)(\/|$)|(Entity|DTO|Dto|VO|Vo|PO|BO|Converter|Assembler|Mapping)\.(java|kt|ts|go|py)$/;
|
|
93
|
+
const ENTRY = /(^|\/)(controller|controllers|api|apis|endpoint|resource|resources|handler|handlers|router|routes)\/|Controller\.(java|kt)$/i;
|
|
94
|
+
const P1_PATH = /(^|\/)(service|services|impl|biz|manager|job|jobs|task|tasks|listener|consumer|consumers|handler|handlers|domain|entity|dto|vo|converter|repository|dao)\//i;
|
|
95
|
+
|
|
96
|
+
// 只看增删的行。MyBatis 的 ${} 不放这里(JS 模板字符串也是 ${}),Mapper XML 和 .sql 按文件类型已经是 P0
|
|
97
|
+
const KW = {
|
|
98
|
+
p0: /@Transactional|synchronized|\.(?:try)?lock\(|ReentrantLock|@Version|BigDecimal|amount|balance|@KafkaListener|KafkaTemplate|RabbitTemplate|RocketMQ|@PreAuthorize|@Secured|ALTER TABLE|CREATE TABLE|DELETE FROM|UPDATE\s+\S+\s+SET/i,
|
|
99
|
+
concurrency: /synchronized|\.(?:try)?lock\(|ReentrantLock|Atomic[A-Z]|ConcurrentHashMap|volatile|@Async|ThreadPool|Executor|CompletableFuture|go func|sync\.(Mutex|RWMutex|WaitGroup)|@Version|setnx|redisson/i,
|
|
100
|
+
mq: /@KafkaListener|@RabbitListener|@RocketMQMessageListener|@JmsListener|KafkaTemplate|RabbitTemplate|RocketMQTemplate|\.send\(|callback|notify|@Transactional/i,
|
|
101
|
+
sql: /\b(SELECT|INSERT|UPDATE|DELETE)\b.+\b(FROM|INTO|SET|WHERE)\b|@Select|@Update|@Insert|@Delete|createQuery|nativeQuery|jdbcTemplate/i,
|
|
102
|
+
config: /@Value\s*\(|@ConfigurationProperties|@NacosValue|@ApolloConfig|getProperty\(|os\.Getenv|process\.env/,
|
|
103
|
+
security: /@PreAuthorize|@Secured|permission|auth|token|password|secret|role/i,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// 赋值、参数、对象值等位置后面的 /…/flags 换成占位,避免模式里的关键字被当成改动内容
|
|
107
|
+
// 行注释(// 前要有空白或在行首,避免把 http:// 当成注释)和以 * 开头的块注释行
|
|
108
|
+
export const stripComments = (text) => text.split('\n').map((l) => (/^\s*(\*|\/\*)/.test(l) ? '' : l.replace(/(^|\s)\/\/.*$/, '$1'))).join('\n');
|
|
109
|
+
export const stripRegexLiterals = (text) => text.replace(/([=(:,[!&|?{};]\s*|return\s+)\/(?![*/])(?:\\.|\[(?:\\.|[^\]\\\n])*\]|[^/\\\n[])+\/[a-z]*/g, '$1/re/');
|
|
110
|
+
|
|
111
|
+
export function classify(file, profileRules = []) {
|
|
112
|
+
const p = file.path;
|
|
113
|
+
// 注释不算;JS / TS 里的正则字面量也不算(规则脚本自己写着匹配关键字的模式,不是业务代码)
|
|
114
|
+
let changed = file.changed_text ?? '';
|
|
115
|
+
if (/\.(java|kt|kts|scala|groovy|go|m?[jt]sx?|cjs|vue)$/i.test(p)) changed = stripComments(changed);
|
|
116
|
+
if (/\.(m?[jt]sx?|cjs|vue)$/i.test(p)) changed = stripRegexLiterals(changed);
|
|
117
|
+
const hit = (k) => KW[k].test(changed);
|
|
118
|
+
const lang = LANG.find(([re]) => re.test(p))?.[1] ?? (MAPPER.test(p) ? 'SQL / MyBatis' : null);
|
|
119
|
+
// 构建文件先于配置判断:pyproject.toml / Cargo.toml 也是 .toml
|
|
120
|
+
const kind = TEST.test(p) ? 'test' : MAPPER.test(p) || SQLFILE.test(p) ? 'sql' : BUILD.test(p) ? 'build' : CONFIG.test(p) ? 'config' : lang ? 'code' : 'other';
|
|
121
|
+
const s = new Set();
|
|
122
|
+
if (kind === 'test') { s.add(1); s.add(13); }
|
|
123
|
+
else if (kind === 'sql') { [1, 6, 7, 12].forEach((n) => s.add(n)); }
|
|
124
|
+
else if (kind === 'config') { [6, 10].forEach((n) => s.add(n)); }
|
|
125
|
+
else if (kind === 'build') { s.add(6); }
|
|
126
|
+
else { [1, 2, 5, 6, 7].forEach((n) => s.add(n)); }
|
|
127
|
+
if (kind === 'code' || kind === 'other') {
|
|
128
|
+
if (DATA_OBJ.test(p)) s.add(3);
|
|
129
|
+
if (hit('concurrency')) s.add(4);
|
|
130
|
+
if (hit('mq')) s.add(11);
|
|
131
|
+
if (hit('sql')) s.add(12);
|
|
132
|
+
if (hit('config')) s.add(10);
|
|
133
|
+
}
|
|
134
|
+
if (lang && kind !== 'config' && kind !== 'build') s.add(14);
|
|
135
|
+
const risk = kind === 'test' ? 'P2'
|
|
136
|
+
: kind === 'sql' || kind === 'config' || ENTRY.test(p) || (kind === 'code' && hit('p0')) ? 'P0'
|
|
137
|
+
: P1_PATH.test(p) || DATA_OBJ.test(p) || kind === 'build' ? 'P1' : 'P2';
|
|
138
|
+
const project_rules = profileRules.filter((r) => r?.path && r?.rule && matchGlob(p, r.path)).map((r) => ({ path: r.path, rule: r.rule }));
|
|
139
|
+
return { kind, lang, risk, sections: [...s].sort((a, b) => a - b), ...(project_rules.length ? { project_rules } : {}) };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------- diff
|
|
143
|
+
// git diff -U0 的输出 → [{ path, old_path?, status, binary, added, deleted, hunks: [[start, end]](新文件里新增 / 改动的行), changed_text }]
|
|
144
|
+
export function parseDiff(text) {
|
|
145
|
+
const files = [];
|
|
146
|
+
let f = null;
|
|
147
|
+
let newLine = 0;
|
|
148
|
+
const strip = (s) => s.replace(/^"|"$/g, '').replace(/^[ab]\//, '');
|
|
149
|
+
for (const line of text.split('\n')) {
|
|
150
|
+
if (line.startsWith('diff --git ')) {
|
|
151
|
+
const m = /^diff --git (?:"?a\/(.+?)"?) (?:"?b\/(.+?)"?)$/.exec(line);
|
|
152
|
+
f = { path: m?.[2] ?? '', old: m?.[1] ?? '', status: 'M', binary: false, added: 0, deleted: 0, hunks: [], changed: [], inHunk: false };
|
|
153
|
+
files.push(f);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (!f) continue;
|
|
157
|
+
// 进入 hunk 之后以 +/- 开头的都是内容(被删的 SQL 注释「-- x」在 diff 里就是「--- x」)
|
|
158
|
+
if (f.inHunk) {
|
|
159
|
+
if (line.startsWith('@@')) { /* 下面统一处理 */ }
|
|
160
|
+
else if (line.startsWith('+')) { f.added++; f.changed.push(line.slice(1)); continue; }
|
|
161
|
+
else if (line.startsWith('-')) { f.deleted++; f.changed.push(line.slice(1)); continue; }
|
|
162
|
+
else continue;
|
|
163
|
+
}
|
|
164
|
+
if (line.startsWith('new file mode')) f.status = 'A';
|
|
165
|
+
else if (line.startsWith('deleted file mode')) f.status = 'D';
|
|
166
|
+
else if (line.startsWith('rename from ')) { f.old = line.slice(12); f.status = 'R'; }
|
|
167
|
+
else if (line.startsWith('rename to ')) f.path = line.slice(10);
|
|
168
|
+
else if (line.startsWith('Binary files ')) f.binary = true;
|
|
169
|
+
else if (line.startsWith('--- ')) { if (line !== '--- /dev/null') f.old = strip(line.slice(4)); }
|
|
170
|
+
else if (line.startsWith('+++ ')) { if (line !== '+++ /dev/null') f.path = strip(line.slice(4)); }
|
|
171
|
+
else if (line.startsWith('@@')) {
|
|
172
|
+
const m = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line);
|
|
173
|
+
newLine = Number(m?.[1] ?? 0);
|
|
174
|
+
const count = m?.[2] === undefined ? 1 : Number(m[2]);
|
|
175
|
+
if (count > 0) f.hunks.push([newLine, newLine + count - 1]);
|
|
176
|
+
f.inHunk = true;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return files.map(({ old, changed, inHunk, ...x }) => ({ ...x, ...(x.status === 'R' && old !== x.path ? { old_path: old } : {}), changed_text: changed.join('\n') }));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 范围 → { ok, mode, range, ref('WORKTREE' 或读文件用的提交), files }
|
|
183
|
+
export function loadDiff(root, { diff = null, base = null } = {}) {
|
|
184
|
+
if (diff) {
|
|
185
|
+
if (!/\.{2,3}/.test(diff)) return { ok: false, error: '--diff 写成 <base>...<head>' };
|
|
186
|
+
const head = diff.split(/\.{2,3}/)[1] || 'HEAD';
|
|
187
|
+
const r = git(root, ['diff', '-U0', '-M', '--no-color', '--no-ext-diff', diff]);
|
|
188
|
+
if (r.status !== 0) return { ok: false, error: r.stderr.trim() || `git diff ${diff} 失败` };
|
|
189
|
+
return { ok: true, mode: 'diff', range: diff, ref: head, files: parseDiff(r.stdout) };
|
|
190
|
+
}
|
|
191
|
+
const b = base || readProfile(root).profile?.main_branch || 'main';
|
|
192
|
+
const mb = git(root, ['merge-base', b, 'HEAD']);
|
|
193
|
+
if (mb.status !== 0) return { ok: false, error: mb.stderr.trim() || `找不到 ${b} 与 HEAD 的合并基点` };
|
|
194
|
+
const from = mb.stdout.trim();
|
|
195
|
+
const r = git(root, ['diff', '-U0', '-M', '--no-color', '--no-ext-diff', from]);
|
|
196
|
+
if (r.status !== 0) return { ok: false, error: r.stderr.trim() || `git diff ${b} 失败` };
|
|
197
|
+
const files = parseDiff(r.stdout);
|
|
198
|
+
// 未跟踪的新文件:git diff 看不到,按整文件新增处理
|
|
199
|
+
for (const p of git(root, ['ls-files', '--others', '--exclude-standard', '-z']).stdout.split('\0').filter(Boolean)) {
|
|
200
|
+
const abs = path.join(root, p);
|
|
201
|
+
let buf;
|
|
202
|
+
try { buf = fs.readFileSync(abs); } catch { continue; }
|
|
203
|
+
const binary = buf.subarray(0, 8000).includes(0);
|
|
204
|
+
const text = binary ? '' : buf.toString('utf8');
|
|
205
|
+
const n = binary || !text ? 0 : text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
|
|
206
|
+
files.push({ path: toPosix(p), status: 'A', untracked: true, binary, added: n, deleted: 0, hunks: n ? [[1, n]] : [], changed_text: text });
|
|
207
|
+
}
|
|
208
|
+
return { ok: true, mode: 'base', range: `${b}...(含未提交与未跟踪)`, base: b, ref: 'WORKTREE', files };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 文件在 ref 上的内容(WORKTREE 读磁盘);读不到返回 null
|
|
212
|
+
export function readAt(root, ref, file) {
|
|
213
|
+
if (ref === 'WORKTREE') { try { return fs.readFileSync(path.join(root, file), 'utf8'); } catch { return null; } }
|
|
214
|
+
const r = git(root, ['show', `${ref}:${file}`]);
|
|
215
|
+
return r.status === 0 ? r.stdout : null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ---------------------------------------------------------------- 分批(同 diff-strategy.md)
|
|
219
|
+
const FULL_FILES = 30, FULL_LINES = 1500, BATCH_FILES = 15, BATCH_LINES = 800, TOO_MANY = 500;
|
|
220
|
+
export function plan(review) {
|
|
221
|
+
const lines = review.reduce((a, f) => a + f.added + f.deleted, 0);
|
|
222
|
+
if (review.length > TOO_MANY) return { size: '过大', batches: [], hint: `排除后还有 ${review.length} 个文件:停下,请用户按模块或提交区间缩小范围` };
|
|
223
|
+
const order = { P0: 0, P1: 1, P2: 2 };
|
|
224
|
+
const sorted = [...review].sort((a, b) => order[a.risk] - order[b.risk] || a.path.localeCompare(b.path));
|
|
225
|
+
if (review.length <= FULL_FILES && lines <= FULL_LINES) return { size: '全量精读', batches: [sorted.map((f) => f.path)] };
|
|
226
|
+
const batches = [];
|
|
227
|
+
let cur = [], n = 0;
|
|
228
|
+
for (const f of sorted) {
|
|
229
|
+
const l = f.added + f.deleted;
|
|
230
|
+
if (cur.length && (cur.length >= BATCH_FILES || n + l > BATCH_LINES || cur.at(-1).risk !== f.risk)) { batches.push(cur); cur = []; n = 0; }
|
|
231
|
+
cur.push(f); n += l;
|
|
232
|
+
}
|
|
233
|
+
if (cur.length) batches.push(cur);
|
|
234
|
+
return { size: '分批精读', batches: batches.map((b) => b.map((f) => f.path)), hint: '按批次顺序精读(P0 → P1 → P2),P0 不能跳过;没读完的批次写进报告「未覆盖与待确认」' };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function reviewConfig(root) {
|
|
238
|
+
const r = readProfile(root).profile?.review ?? {};
|
|
239
|
+
const list = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string' && x.trim()) : []);
|
|
240
|
+
return { include: list(r.include), exclude: list(r.exclude), rules: Array.isArray(r.rules) ? r.rules : [] };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function scope(root, opts = {}) {
|
|
244
|
+
const d = loadDiff(root, opts);
|
|
245
|
+
if (!d.ok) return d;
|
|
246
|
+
const cfg = opts.config ?? reviewConfig(root);
|
|
247
|
+
const exclude = [...cfg.exclude, ...(opts.exclude ?? [])];
|
|
248
|
+
const review = [], skipped = [];
|
|
249
|
+
for (const f of d.files) {
|
|
250
|
+
const reason = skipReason(f, { include: cfg.include, exclude });
|
|
251
|
+
const base = { path: f.path, ...(f.old_path ? { old_path: f.old_path } : {}), status: f.status, ...(f.untracked ? { untracked: true } : {}), added: f.added, deleted: f.deleted };
|
|
252
|
+
if (reason) { skipped.push({ ...base, reason, note: NOTE[reason] }); continue; }
|
|
253
|
+
review.push({ ...base, ...classify(f, cfg.rules), ...(f.added + f.deleted > BATCH_LINES ? { large: true } : {}) });
|
|
254
|
+
}
|
|
255
|
+
const p = plan(review);
|
|
256
|
+
const count = (xs, k) => xs.reduce((m, x) => ((m[x[k]] = (m[x[k]] ?? 0) + 1), m), {});
|
|
257
|
+
return {
|
|
258
|
+
ok: true, mode: d.mode, range: d.range, ref: d.ref,
|
|
259
|
+
summary: {
|
|
260
|
+
files: d.files.length, review: review.length, skipped: skipped.length,
|
|
261
|
+
lines: review.reduce((a, f) => a + f.added + f.deleted, 0), risk: count(review, 'risk'), skipped_by_reason: count(skipped, 'reason'), size: p.size,
|
|
262
|
+
},
|
|
263
|
+
overall_sections: review.length ? [9, 13] : [],
|
|
264
|
+
review, skipped, batches: p.batches,
|
|
265
|
+
...(p.hint ? { hint: p.hint } : {}),
|
|
266
|
+
next: '每个文件按 sections 过清单(rules <文件…> 打印对应小节原文和项目规则);整个改动再过一次 overall_sections(9 影响面、13 测试)。报告「未覆盖与待确认」按 reason 汇总 skipped,没读完的文件逐个列出',
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---------------------------------------------------------------- rules:打印清单小节
|
|
271
|
+
export function checklistSections(text) {
|
|
272
|
+
const out = {};
|
|
273
|
+
const parts = text.replace(/\r/g, '').split(/^(?=## )/m);
|
|
274
|
+
for (const part of parts) {
|
|
275
|
+
const m = /^## (\d+)[.、.]/.exec(part);
|
|
276
|
+
if (m) out[Number(m[1])] = part.trimEnd();
|
|
277
|
+
}
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// 语言专项(14)只取对应语言那一条
|
|
282
|
+
function languageBullet(section, lang) {
|
|
283
|
+
if (!section || !lang) return null;
|
|
284
|
+
const head = section.split('\n')[0];
|
|
285
|
+
const bullet = section.split('\n').find((l) => l.includes(`**${lang}**`));
|
|
286
|
+
return bullet ? `${head}\n${bullet}` : null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function readChecklist(root) {
|
|
290
|
+
const t = resolveTemplate(root, 'project-review', 'checklist.md');
|
|
291
|
+
if (!t.ok) return { ok: false, error: t.hint ?? t.message ?? '取不到审查清单' };
|
|
292
|
+
const abs = path.resolve(root, t.path);
|
|
293
|
+
for (const f of [abs, `${abs}.tpl`]) { try { return { ok: true, path: t.path, text: fs.readFileSync(f, 'utf8') }; } catch { /* 下一个 */ } }
|
|
294
|
+
return { ok: false, error: `读不到审查清单 ${t.path}` };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// 文件在本次范围里时沿用 scope 的分类(按改动内容判断的小节一致);不在范围里的按整个文件内容判断
|
|
298
|
+
export function rulesFor(root, files, { config = reviewConfig(root), checklist = readChecklist(root), inScope = [] } = {}) {
|
|
299
|
+
if (!checklist.ok) return checklist;
|
|
300
|
+
const secs = checklistSections(checklist.text);
|
|
301
|
+
const parsed = Object.keys(secs).length > 0;
|
|
302
|
+
const known = new Map(inScope.map((x) => [x.path, x]));
|
|
303
|
+
const per = files.map((p) => {
|
|
304
|
+
const key = toPosix(p).replace(/^\.\//, '');
|
|
305
|
+
if (known.has(key)) {
|
|
306
|
+
const { project_rules, ...x } = known.get(key);
|
|
307
|
+
const pr = config.rules.filter((r) => r?.path && r?.rule && matchGlob(key, r.path)).map((r) => ({ path: r.path, rule: r.rule }));
|
|
308
|
+
return { ...x, ...(pr.length ? { project_rules: pr } : {}) };
|
|
309
|
+
}
|
|
310
|
+
const f = { path: key, changed_text: (() => { try { return fs.readFileSync(path.join(root, key), 'utf8'); } catch { return ''; } })() };
|
|
311
|
+
return { path: f.path, ...classify(f, config.rules) };
|
|
312
|
+
});
|
|
313
|
+
const nums = [...new Set(per.flatMap((x) => x.sections))].sort((a, b) => a - b);
|
|
314
|
+
const langs = [...new Set(per.map((x) => x.lang).filter(Boolean))];
|
|
315
|
+
const L = [`# 审查要点:${per.map((x) => x.path).join('、')}`, '', `清单:${checklist.path}`, ''];
|
|
316
|
+
for (const x of per) L.push(`- ${x.path}:${x.kind},风险 ${x.risk},清单 ${x.sections.join(' / ')}`);
|
|
317
|
+
L.push('');
|
|
318
|
+
if (!parsed) L.push('(清单没有「## <编号>.」格式的小节,按整份清单检查)', '', checklist.text.trim());
|
|
319
|
+
for (const n of nums) {
|
|
320
|
+
if (!secs[n]) { L.push(`(清单里没有第 ${n} 节)`, ''); continue; }
|
|
321
|
+
if (n === 14) { for (const lang of langs) { const b = languageBullet(secs[14], lang); if (b) L.push(b, ''); } continue; }
|
|
322
|
+
L.push(secs[n], '');
|
|
323
|
+
}
|
|
324
|
+
const pr = per.flatMap((x) => (x.project_rules ?? []).map((r) => ({ ...r, file: x.path })));
|
|
325
|
+
if (pr.length) {
|
|
326
|
+
L.push('## 项目规则(workflow/profile.json 的 review.rules,和上面的清单一起检查)', '');
|
|
327
|
+
for (const r of pr) L.push(`- \`${r.file}\`(匹配 \`${r.path}\`):${r.rule}`);
|
|
328
|
+
}
|
|
329
|
+
return { ok: true, text: L.join('\n').trimEnd() + '\n' };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function main(argv) {
|
|
333
|
+
let root = process.cwd();
|
|
334
|
+
const o = { exclude: [] };
|
|
335
|
+
const rest = [];
|
|
336
|
+
for (let i = 0; i < argv.length; i++) {
|
|
337
|
+
if (argv[i] === '--root') root = path.resolve(argv[++i] ?? '.');
|
|
338
|
+
else if (argv[i] === '--diff') o.diff = argv[++i] ?? null;
|
|
339
|
+
else if (argv[i] === '--base') o.base = argv[++i] ?? null;
|
|
340
|
+
else if (argv[i] === '--exclude') o.exclude.push(...(argv[++i] ?? '').split(',').map((s) => s.trim()).filter(Boolean));
|
|
341
|
+
else rest.push(argv[i]);
|
|
342
|
+
}
|
|
343
|
+
if (rest[0] === 'rules') {
|
|
344
|
+
if (rest.length < 2) { console.error('用法: review-scope.mjs rules <文件> [<文件>…]'); return 1; }
|
|
345
|
+
const sc = scope(root, o);
|
|
346
|
+
const r = rulesFor(root, rest.slice(1), { inScope: sc.ok ? sc.review : [] });
|
|
347
|
+
if (!r.ok) { console.error(r.error); return 1; }
|
|
348
|
+
process.stdout.write(r.text);
|
|
349
|
+
return 0;
|
|
350
|
+
}
|
|
351
|
+
if (rest.length) { console.error('用法: review-scope.mjs [--diff <base>...<head> | --base <分支>] [--exclude a,b] | rules <文件…>'); return 1; }
|
|
352
|
+
const r = scope(root, o);
|
|
353
|
+
console.log(JSON.stringify(r, null, 2));
|
|
354
|
+
return r.ok ? 0 : 1;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export { main };
|
|
358
|
+
const invoked = process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
359
|
+
if (invoked) process.exit(main(process.argv.slice(2)));
|