sillyspec 3.22.8 → 3.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/package.json +1 -1
- package/src/hooks/worktree-guard.js +86 -31
- package/src/index.js +141 -5
- package/src/local-detect.js +110 -0
- package/src/machine-interface.js +471 -0
- package/src/progress.js +110 -5
- package/src/run.js +227 -46
- package/src/stage-contract.js +107 -1
- package/src/stages/brainstorm-auto.js +1 -1
- package/src/stages/brainstorm.js +1 -1
- package/src/stages/quick.js +25 -14
- package/src/stages/scan.js +7 -16
- package/src/stages/verify.js +3 -1
- package/src/sync.js +73 -5
- package/src/task-review.js +104 -1
- package/src/verify-postcheck.js +433 -0
- package/src/worktree-apply.js +19 -4
- package/src/worktree.js +12 -0
package/src/stages/quick.js
CHANGED
|
@@ -8,26 +8,32 @@ export const definition = {
|
|
|
8
8
|
name: '理解任务',
|
|
9
9
|
prompt: `解析任务参数,加载项目上下文。
|
|
10
10
|
|
|
11
|
+
### 📌 本 quick 会话 sessionId: \`<quick-session-id>\`
|
|
12
|
+
- CLI 是短进程,run 与 done 是独立进程,sessionId 靠 \`--change\` 跨进程传递
|
|
13
|
+
- **完成每个 step 用**:\`sillyspec run quick --done --change <quick-session-id> --output "..."\`
|
|
14
|
+
- 多会话并发时**必须带 \`--change <quick-session-id>\`**,否则可能命中他者会话的状态
|
|
15
|
+
- 不带 \`--change\` 时 fallback 读 \`current-quick-run-id\`(单会话兼容;多会话不可靠)
|
|
16
|
+
|
|
11
17
|
### 操作
|
|
12
18
|
1. 检查关联变更(\`<linked-changes>\`,逗号分隔的变更名列表;显示「(无)」= 不关联变更),确定记录方式
|
|
13
19
|
2. 理解任务:模糊则问一个问题确认
|
|
14
|
-
3. 加载项目信息:\`cat
|
|
15
|
-
4. 加载上下文:\`cat
|
|
16
|
-
5. 加载本地配置:\`cat
|
|
17
|
-
6. 若有关联变更,加载每个变更的设计文档:\`cat
|
|
18
|
-
7. 如有需要,查询知识库:\`cat
|
|
20
|
+
3. 加载项目信息:\`cat {SPEC_ROOT}/projects/*.yaml 2>/dev/null\`(了解项目结构和技术栈)
|
|
21
|
+
4. 加载上下文:\`cat {SPEC_ROOT}/docs/<project>/scan/CONVENTIONS.md 2>/dev/null\`
|
|
22
|
+
5. 加载本地配置:\`cat {SPEC_ROOT}/local.yaml 2>/dev/null\`(构建命令、测试命令、环境变量等)
|
|
23
|
+
6. 若有关联变更,加载每个变更的设计文档:\`cat {SPEC_ROOT}/changes/<c>/design.md 2>/dev/null\`(理解设计意图)
|
|
24
|
+
7. 如有需要,查询知识库:\`cat {SPEC_ROOT}/knowledge/INDEX.md 2>/dev/null\`
|
|
19
25
|
|
|
20
26
|
### 模块文档加载
|
|
21
|
-
8. 读取
|
|
27
|
+
8. 读取 \`{SPEC_ROOT}/docs/<project>/modules/_module-map.yaml\`(不存在则跳过以下步骤)
|
|
22
28
|
9. 根据任务描述初步判断可能涉及的模块
|
|
23
|
-
10. 读取匹配到的
|
|
29
|
+
10. 读取匹配到的 \`{SPEC_ROOT}/docs/<project>/modules/<module>.md\`
|
|
24
30
|
|
|
25
31
|
### 创建任务记录(⛔ 此步骤不能跳过,没有 quicklog 记录 = 未完成)
|
|
26
32
|
理解完任务后,**必须**立即创建记录文件,再输出任何其他内容:
|
|
27
33
|
|
|
28
34
|
**A. 始终创建 QUICKLOG(无论是否关联变更)**
|
|
29
35
|
1. 使用预注入的 git 用户名:\`<git-user>\`
|
|
30
|
-
2. 创建/追加
|
|
36
|
+
2. 创建/追加 \`{SPEC_ROOT}/quicklog/QUICKLOG-\`<git-user>\`.md\`,写入:
|
|
31
37
|
\`\`\`
|
|
32
38
|
## ql-<YYYYMMDD>-<NNN>-<XXXX> | <now-datetime> | <一句话任务描述>
|
|
33
39
|
状态:进行中
|
|
@@ -42,13 +48,13 @@ export const definition = {
|
|
|
42
48
|
|
|
43
49
|
**B. 若关联变更(\`<linked-changes>\` 不为「(无)」)**
|
|
44
50
|
3. 解析 \`<linked-changes>\`(逗号分隔的变更名列表,可能有多个 = 一次 quick 同时归属多个变更)
|
|
45
|
-
4. 对**每个**变更 \`<c>\`:在
|
|
51
|
+
4. 对**每个**变更 \`<c>\`:在 \`{SPEC_ROOT}/changes/<c>/tasks.md\` 追加一条未勾选 task:
|
|
46
52
|
\`- [ ] <ql-ID> <一句话任务描述>\`(复用步骤 A 生成的同一个 ql-ID)
|
|
47
|
-
- 若 \`changes/<c>/\` 目录或 tasks.md 不存在,先 \`mkdir -p
|
|
53
|
+
- 若 \`changes/<c>/\` 目录或 tasks.md 不存在,先 \`mkdir -p {SPEC_ROOT}/changes/<c>\` 再创建 tasks.md
|
|
48
54
|
- 同一个 ql-ID 出现在 QUICKLOG 和所有关联变更的 tasks.md 中,便于交叉引用
|
|
49
55
|
- \`<linked-changes>\` 为「(无)」时跳过本步,仅保留 QUICKLOG
|
|
50
56
|
|
|
51
|
-
这样 Gate 检测到
|
|
57
|
+
这样 Gate 检测到 \`{SPEC_ROOT}/\` 下有变更,就不会拦截后续的代码修改。
|
|
52
58
|
|
|
53
59
|
### 输出
|
|
54
60
|
quicklog 已创建(必须放在输出的第一行确认)+ 任务理解 + 上下文摘要
|
|
@@ -81,6 +87,11 @@ quicklog 已创建(必须放在输出的第一行确认)+ 任务理解 + 上
|
|
|
81
87
|
name: '暂存和更新记录',
|
|
82
88
|
prompt: `Git 暂存并更新任务记录。
|
|
83
89
|
|
|
90
|
+
### 📌 收尾确认 — sessionId: \`<quick-session-id>\`
|
|
91
|
+
- 本步骤是最后一步,完成后 quick 会话即结束
|
|
92
|
+
- **完成本 step 用**:\`sillyspec run quick --done --change <quick-session-id> --output "暂存和记录确认"\`
|
|
93
|
+
- 多会话并发**必须带 \`--change <quick-session-id>\`**,不带会 fallback 读 \`current-quick-run-id\` 可能命中他者会话
|
|
94
|
+
|
|
84
95
|
### 操作
|
|
85
96
|
1. 查看 \`git status --porcelain\`,确认只包含本次 quick 相关文件
|
|
86
97
|
2. 使用 \`git add -- <file...>\` 暂存本次 quick 实际修改的文件(不要 commit,由用户通过统一提交工具处理)
|
|
@@ -90,14 +101,14 @@ quicklog 已创建(必须放在输出的第一行确认)+ 任务理解 + 上
|
|
|
90
101
|
- QUICKLOG:找到对应 ql-ID 的条目,将「状态:进行中」改为「状态:已完成」,补充实际改动文件和结果摘要(**始终做**,无论是否关联变更)
|
|
91
102
|
- 关联变更:对 Step 1 在每个 \`changes/<c>/tasks.md\` 写入的 ql-ID task,将其 checkbox 由 \`- [ ]\` 勾选为 \`- [x]\`;若 Step 1 未写入(用户事后才决定关联),则按 Step 1 的格式补写并直接勾选
|
|
92
103
|
4. QUICKLOG 轮转:超过 500 行则重命名为 \`QUICKLOG-<USER>-YYYY-MM-DD.md\`(日期取最后一条记录的日期)。新文件从空开始,ql-ID 需扫描同目录所有 QUICKLOG 文件中当天最大序号 +1
|
|
93
|
-
5. 如果发现项目特有的坑,追加到
|
|
104
|
+
5. 如果发现项目特有的坑,追加到 \`{SPEC_ROOT}/knowledge/uncategorized.md\`
|
|
94
105
|
6. 任务比预期复杂 → 建议用完整流程
|
|
95
106
|
|
|
96
107
|
### 模块文档同步
|
|
97
|
-
7. 读取
|
|
108
|
+
7. 读取 \`{SPEC_ROOT}/docs/<project>/modules/_module-map.yaml\`(不存在则跳过以下步骤)
|
|
98
109
|
8. 对比本次修改的文件(\`git diff --name-only HEAD\`)与模块映射
|
|
99
110
|
9. 如果命中模块 → 直接同步模块文档:
|
|
100
|
-
- 读取对应的
|
|
111
|
+
- 读取对应的 \`{SPEC_ROOT}/docs/<project>/modules/<module>.md\`(如不存在则新建)
|
|
101
112
|
- 根据本次改动内容更新模块文档(正文描述当前状态,底部变更索引追加本次 ql-ID)
|
|
102
113
|
- 变更索引格式:\`- ql-YYYYMMDD-NNN-XXXX | <一句话描述>\`
|
|
103
114
|
- 写入模块文档
|
package/src/stages/scan.js
CHANGED
|
@@ -182,19 +182,15 @@ export const definition = {
|
|
|
182
182
|
|
|
183
183
|
### 操作
|
|
184
184
|
1. 检查 {SPEC_ROOT}/local.yaml 是否已存在,已存在则跳过(提示"local.yaml 已存在,跳过生成")
|
|
185
|
-
2.
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
3. 确保目录存在:mkdir -p {SPEC_ROOT}
|
|
191
|
-
4. 原子写入(先写 tmp 文件再 rename)
|
|
192
|
-
|
|
193
|
-
### 文件格式
|
|
185
|
+
2. 调用 \`sillyspec local detect\` 命令生成 local.yaml(该命令封装了纯 fs 项目类型嗅探:package.json→nodejs / pom.xml→maven / build.gradle→gradle / Makefile→make / 否则 generic,对应默认 commands)。**不要自行嗅探 package.json/pom.xml/build.gradle 等文件**——探测逻辑唯一归属 local-detect.js,本步骤只负责调用命令并报告结果。
|
|
186
|
+
3. 该命令内部已处理:确保目录存在(mkdir -p)、已存在则跳过、原子写入(先写 tmp 再 rename)。
|
|
187
|
+
4. 报告生成的 project.type 与 local.yaml 路径。
|
|
188
|
+
|
|
189
|
+
### 输出格式参考(由 \`sillyspec local detect\` 自动产出)
|
|
194
190
|
\`\`\`yaml
|
|
195
191
|
# SillySpec 本地配置(自动生成,可手动修改)
|
|
196
192
|
project:
|
|
197
|
-
type: nodejs # nodejs/maven/gradle/generic
|
|
193
|
+
type: nodejs # nodejs/maven/gradle/make/generic
|
|
198
194
|
|
|
199
195
|
commands:
|
|
200
196
|
build: "npm run build"
|
|
@@ -203,15 +199,10 @@ commands:
|
|
|
203
199
|
|
|
204
200
|
# 测试策略:full=全量测试, module=只测变更模块, skip=跳过测试
|
|
205
201
|
test_strategy: module
|
|
206
|
-
|
|
207
|
-
# 模块测试路径映射(可选)
|
|
208
|
-
# module_paths:
|
|
209
|
-
# user-service: "user/"
|
|
210
|
-
# order-service: "order/"
|
|
211
202
|
\`\`\`
|
|
212
203
|
|
|
213
204
|
### 输出
|
|
214
|
-
local.yaml
|
|
205
|
+
local.yaml 生成结果(已存在/已生成 + project.type)`,
|
|
215
206
|
outputHint: 'local.yaml 生成状态',
|
|
216
207
|
optional: false
|
|
217
208
|
},
|
package/src/stages/verify.js
CHANGED
|
@@ -223,6 +223,7 @@ grep -rl "<关键词>" <源码目录>/ --include="*.java" --include="*.js" --inc
|
|
|
223
223
|
### 注意
|
|
224
224
|
- 不要全量编译/测试整个项目,只测变更涉及的模块
|
|
225
225
|
- 如果变更模块不确定,优先使用 local.yaml 中的命令
|
|
226
|
+
- **CLI 对账机制**:verify 阶段最终 --done 时,CLI 会亲自执行 local.yaml 的 commands.test 并与你的报告对账;实测失败会直接阻断 verify 完成,谎报测试结果没有意义
|
|
226
227
|
|
|
227
228
|
### 输出
|
|
228
229
|
测试结果 + 技术债务标记`,
|
|
@@ -314,7 +315,8 @@ verify-result.md 路径 + 验证报告摘要 + 下一步命令
|
|
|
314
315
|
### 注意
|
|
315
316
|
- PASS → 运行 \`sillyspec run archive\` 归档
|
|
316
317
|
- FAIL → 修复后运行 \`sillyspec run verify\` 重新验证
|
|
317
|
-
- verify-result.md
|
|
318
|
+
- verify-result.md 是变更包的正式验收记录,归档后保留
|
|
319
|
+
- **CLI 对账机制**:本步骤 --done 时 CLI 会亲自执行 local.yaml 的 commands.test;结论写 PASS 但实测失败 → verify 完成被阻断`,
|
|
318
320
|
outputHint: '验证报告',
|
|
319
321
|
optional: false
|
|
320
322
|
}
|
package/src/sync.js
CHANGED
|
@@ -75,7 +75,9 @@ function parseSimpleYaml(content) {
|
|
|
75
75
|
for (const line of content.split('\n')) {
|
|
76
76
|
const trimmed = line.trim();
|
|
77
77
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
78
|
-
|
|
78
|
+
// 用原始 line(非 trimmed)判断缩进:trimmed 已去前导空格,startsWith(' ') 恒 false
|
|
79
|
+
// 会导致缩进子段(如 platform 下 url/token)被误判为 root 行、section 恒为空 {}
|
|
80
|
+
if (!line.startsWith(' ')) {
|
|
79
81
|
const m = trimmed.match(/^(\S+)\s*:\s*(.*)$/);
|
|
80
82
|
if (m) {
|
|
81
83
|
const key = m[1];
|
|
@@ -413,14 +415,80 @@ export async function checkApproval(changeName, cwd) {
|
|
|
413
415
|
return new SyncManager(cwd).checkApproval(changeName);
|
|
414
416
|
}
|
|
415
417
|
|
|
418
|
+
// TBD-hub-api: approve/reject 端点路径与请求体以 SillyHub 仓库实际 API 为准;
|
|
419
|
+
// 对齐时只改本函数(_submitApproval),无需动 approve/reject 入口。
|
|
420
|
+
/**
|
|
421
|
+
* 向平台提交审批决定(approve/reject 共用)。
|
|
422
|
+
*
|
|
423
|
+
* 显式用户/daemon 动作:网络/平台失败必须可见(decisions.md D-006@v1)——
|
|
424
|
+
* 与 best-effort 的 sync 不同,此处失败打 error 并置 process.exitCode = 1。
|
|
425
|
+
*
|
|
426
|
+
* @param {string} cwd - 工作目录
|
|
427
|
+
* @param {string} changeName - 变更名称
|
|
428
|
+
* @param {'approved'|'rejected'} decision - 审批决定
|
|
429
|
+
* @param {string|null} reason - rejected 时的原因(approved 时忽略)
|
|
430
|
+
* @returns {Promise<boolean>} 是否成功
|
|
431
|
+
*/
|
|
432
|
+
async function _submitApproval(cwd, changeName, decision, reason = null) {
|
|
433
|
+
const sm = new SyncManager(cwd);
|
|
434
|
+
const platform = sm._getPlatform();
|
|
435
|
+
if (!platform) {
|
|
436
|
+
console.error('❌ 未连接平台,请先 sillyspec platform connect');
|
|
437
|
+
process.exitCode = 1;
|
|
438
|
+
return false;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (!changeName) {
|
|
442
|
+
console.error('❌ 审批需要指定变更名称 (changeName)');
|
|
443
|
+
process.exitCode = 1;
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// TBD-hub-api: 端点路径/body 以 SillyHub 实际 API 为准,对齐时只改此处
|
|
448
|
+
const approvalUrl = `${platform.url}/api/changes/${encodeURIComponent(changeName)}/approval`;
|
|
449
|
+
const body = decision === 'rejected'
|
|
450
|
+
? { decision: 'rejected', reason }
|
|
451
|
+
: { decision: 'approved' };
|
|
452
|
+
|
|
453
|
+
const result = await fetchJson(approvalUrl, {
|
|
454
|
+
method: 'POST',
|
|
455
|
+
headers: {
|
|
456
|
+
'Content-Type': 'application/json',
|
|
457
|
+
Authorization: `Bearer ${platform.token}`,
|
|
458
|
+
},
|
|
459
|
+
body: JSON.stringify(body),
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// fetchJson 在非 2xx / 网络错 / 超时 / 非 JSON 时返回 null
|
|
463
|
+
if (result === null) {
|
|
464
|
+
console.error(`❌ 审批请求失败 (${changeName} → ${decision}),详情见上方 [sync] 警告`);
|
|
465
|
+
process.exitCode = 1;
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// HTTP 成功后更新本地 approvals 表(HTTP 已成功是主要目标,落库失败只 warn 不阻断)
|
|
470
|
+
try {
|
|
471
|
+
const { ProgressManager } = await import('./progress.js');
|
|
472
|
+
const pm = new ProgressManager({ specDir: safePlatformSpecDir(cwd) });
|
|
473
|
+
await pm._updateApprovalStatus(cwd, changeName, decision, reason);
|
|
474
|
+
} catch (err) {
|
|
475
|
+
console.warn(`[sync] 更新本地审批状态失败 (${changeName}): ${err.message}`);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (decision === 'approved') {
|
|
479
|
+
console.log(`✅ 已批准变更 ${changeName}`);
|
|
480
|
+
} else {
|
|
481
|
+
console.log(`✅ 已拒绝变更 ${changeName}${reason ? `(原因: ${reason})` : ''}`);
|
|
482
|
+
}
|
|
483
|
+
return true;
|
|
484
|
+
}
|
|
485
|
+
|
|
416
486
|
export async function approve(changeName, cwd) {
|
|
417
|
-
|
|
418
|
-
console.warn(`[sync] approve 尚未实现 (${changeName})`);
|
|
487
|
+
return _submitApproval(cwd, changeName, 'approved');
|
|
419
488
|
}
|
|
420
489
|
|
|
421
490
|
export async function reject(changeName, reason, cwd) {
|
|
422
|
-
|
|
423
|
-
console.warn(`[sync] reject 尚未实现 (${changeName})`);
|
|
491
|
+
return _submitApproval(cwd, changeName, 'rejected', reason);
|
|
424
492
|
}
|
|
425
493
|
|
|
426
494
|
export async function status(cwd) {
|
package/src/task-review.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync } from 'fs'
|
|
12
12
|
import { join, resolve } from 'path'
|
|
13
|
+
import { execFileSync } from 'child_process'
|
|
13
14
|
|
|
14
15
|
// ── review.json schema version ──
|
|
15
16
|
export const REVIEW_SCHEMA_VERSION = 1
|
|
@@ -147,7 +148,7 @@ function isTaskLowRisk(changeDir, taskId) {
|
|
|
147
148
|
}
|
|
148
149
|
|
|
149
150
|
export function validateTaskReviews(opts) {
|
|
150
|
-
const { planContent, runtimeRoot, executeRunId, allowCannotVerify = true, changeDir = null } = opts
|
|
151
|
+
const { planContent, runtimeRoot, executeRunId, allowCannotVerify = true, changeDir = null, gitDir = null } = opts
|
|
151
152
|
|
|
152
153
|
const taskIds = parseTaskIdsFromPlan(planContent)
|
|
153
154
|
|
|
@@ -201,6 +202,24 @@ export function validateTaskReviews(opts) {
|
|
|
201
202
|
continue
|
|
202
203
|
}
|
|
203
204
|
|
|
205
|
+
// ── git 真实性交叉校验:base/head 必须是真实 commit,diff 不能为空 ──
|
|
206
|
+
if (gitDir) {
|
|
207
|
+
const evidence = verifyReviewGitEvidence(review, gitDir)
|
|
208
|
+
for (const w of evidence.warnings) warnings.push(`${taskId}: ${w}`)
|
|
209
|
+
if (!evidence.ok) {
|
|
210
|
+
for (const err of evidence.errors) errors.push(`${taskId}: ${err}`)
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
if (evidence.emptyDiff) {
|
|
214
|
+
if (isTaskLowRisk(changeDir, taskId)) {
|
|
215
|
+
warnings.push(`${taskId}: base..head 无代码变更(task 声明 low_risk: true,不阻断)`)
|
|
216
|
+
} else {
|
|
217
|
+
errors.push(`${taskId}: base..head(${review.base.slice(0, 8)}..${review.head.slice(0, 8)})无任何代码变更 — 评审了一个零改动的任务,review 疑似伪造`)
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
204
223
|
// 检查 cannot_verify
|
|
205
224
|
if (review.specVerdict === 'cannot_verify' || review.qualityVerdict === 'cannot_verify') {
|
|
206
225
|
if (!allowCannotVerify) {
|
|
@@ -252,6 +271,90 @@ export function validateTaskReviews(opts) {
|
|
|
252
271
|
}
|
|
253
272
|
}
|
|
254
273
|
|
|
274
|
+
// ── Git 真实性交叉校验 ──
|
|
275
|
+
|
|
276
|
+
function runGit(gitDir, args) {
|
|
277
|
+
return execFileSync('git', args, {
|
|
278
|
+
cwd: gitDir,
|
|
279
|
+
encoding: 'utf8',
|
|
280
|
+
timeout: 15000,
|
|
281
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
282
|
+
}).trim()
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* 校验 review.json 的 base/head 是否指向真实 git 提交,且 diff 非空。
|
|
287
|
+
*
|
|
288
|
+
* 历史漏洞:base/head 只做非空字符串检查,agent 可以填任意假 hash 批量伪造
|
|
289
|
+
* review.json 通过 Task Review Gate。此函数用 git 做客观交叉校验:
|
|
290
|
+
* 1. base/head 必须是仓库中可解析的真实 commit
|
|
291
|
+
* 2. base..head 的 diff 不能为空(评审一个零改动的"任务"无意义)
|
|
292
|
+
* 3. review.changedFiles(如提供)必须与实际 diff 文件有交集
|
|
293
|
+
*
|
|
294
|
+
* git 环境不可用(非 git 仓库 / git 缺失)时返回 unavailable,由调用方降级为
|
|
295
|
+
* warning——不因环境问题误杀,但记录在输出中。
|
|
296
|
+
*
|
|
297
|
+
* @param {object} review - 已通过 schema 校验的 review 对象
|
|
298
|
+
* @param {string} gitDir - 执行 git 命令的目录(worktree 优先,回退主仓库)
|
|
299
|
+
* @returns {{ ok: boolean, emptyDiff: boolean, errors: string[], warnings: string[], unavailable: boolean }}
|
|
300
|
+
*/
|
|
301
|
+
export function verifyReviewGitEvidence(review, gitDir) {
|
|
302
|
+
const errors = []
|
|
303
|
+
const warnings = []
|
|
304
|
+
|
|
305
|
+
// git 环境探测:失败即 unavailable,交由调用方降级
|
|
306
|
+
try {
|
|
307
|
+
runGit(gitDir, ['rev-parse', '--git-dir'])
|
|
308
|
+
} catch (e) {
|
|
309
|
+
return {
|
|
310
|
+
ok: true,
|
|
311
|
+
emptyDiff: false,
|
|
312
|
+
errors: [],
|
|
313
|
+
warnings: [`git 环境不可用(${gitDir}),跳过 review 真实性交叉校验: ${e.message?.split('\n')[0] || e.message}`],
|
|
314
|
+
unavailable: true,
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
for (const field of ['base', 'head']) {
|
|
319
|
+
const hash = review[field]
|
|
320
|
+
try {
|
|
321
|
+
runGit(gitDir, ['rev-parse', '--verify', '--quiet', `${hash}^{commit}`])
|
|
322
|
+
} catch {
|
|
323
|
+
errors.push(`${field} "${hash}" 不是仓库中的真实 commit — review.json 疑似伪造`)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (errors.length > 0) {
|
|
327
|
+
return { ok: false, emptyDiff: false, errors, warnings, unavailable: false }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
let diffFiles = []
|
|
331
|
+
try {
|
|
332
|
+
const out = runGit(gitDir, ['diff', '--name-only', `${review.base}..${review.head}`])
|
|
333
|
+
diffFiles = out ? out.split('\n').filter(Boolean) : []
|
|
334
|
+
} catch (e) {
|
|
335
|
+
warnings.push(`git diff ${review.base}..${review.head} 执行失败,跳过 diff 校验: ${e.message?.split('\n')[0] || e.message}`)
|
|
336
|
+
return { ok: true, emptyDiff: false, errors, warnings, unavailable: false }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const emptyDiff = diffFiles.length === 0
|
|
340
|
+
|
|
341
|
+
// changedFiles 交叉比对:完全不相交 = review 描述的改动与实际 diff 无关
|
|
342
|
+
if (!emptyDiff && Array.isArray(review.changedFiles) && review.changedFiles.length > 0) {
|
|
343
|
+
const normalize = (p) => String(p).replace(/^\.\//, '')
|
|
344
|
+
const diffSet = new Set(diffFiles.map(normalize))
|
|
345
|
+
const hasOverlap = review.changedFiles.some(f => {
|
|
346
|
+
const nf = normalize(f)
|
|
347
|
+
// 兼容 review 写相对/部分路径:前后缀匹配即可
|
|
348
|
+
return diffSet.has(nf) || diffFiles.some(d => d.endsWith(nf) || nf.endsWith(d))
|
|
349
|
+
})
|
|
350
|
+
if (!hasOverlap) {
|
|
351
|
+
errors.push(`changedFiles 与实际 git diff(${review.base.slice(0, 8)}..${review.head.slice(0, 8)},${diffFiles.length} 个文件)完全不相交 — review 内容与代码变更无关`)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return { ok: errors.length === 0, emptyDiff, errors, warnings, unavailable: false }
|
|
356
|
+
}
|
|
357
|
+
|
|
255
358
|
/**
|
|
256
359
|
* 将 cannot_verify 的 requiredEvidence 写入 change 目录
|
|
257
360
|
* 供 verify 阶段消费
|