mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-10-30 22:07:28 +00:00
## 是什么
agent 统筹开发流程的插件:登记项目 → 开工程线(每条线一个隔离工作目录 +
一个 sidecar 子进程 + 一对通道)→ 投递任务 → 同会话续投 → 评审/合入。
38 个工具:project_*/branch_*/workdir_*/job_*/review_*/test_*/
endpoint_*/agent_*/pi_*/conflict_check。
## sidecar 后端抽象(本轮核心)
传输层(local/ssh)与后端(pi/zcode/自定义)**正交**,四个自由组合:
driver.json 描述一个后端:script / cliPath / env / args / llmConfig
三级查找:插件数据目录 → **随包 rawfile** → 内置 pi 描述
占位符:{workdir} {dataDir} {pluginDir} {rawfile} {sessionId}
★ 内置 pi 回退**与默认后端解耦**(2026-10-17 修):原实现写
`if cli == defaultAgentCLI { return builtinPiDriver() }`,把"给谁兜底"
和"默认选谁"耦合到同一常量 —— 一旦有人改默认,pi 当场失去兜底,
显式 cli="pi" 开始报「驱动不存在」。现在判断字面 "pi"。
默认后端保持 **pi**(我们本地主用);交付方用 zcode 走两种局部开关:
· 单线:commondev_branch_open(cli="zcode")
· 按机器:commondev_endpoint_set(host=..., cli="zcode")
## zcode 接入(三条真机判据,全部要求真改工程文件)
· TestZcodeFullPipeline(29-34s):随包 driver + 真实 ZCode CLI
· TestZcodeOverSSH(39s):真 ssh + scp,远端 node 跑 zcode
· TestZcodeOverSSHWithPerHostEndpoints(68s):两个 host 别名配不同
cli_path,走端点表而不是全局 settings
★ 会话库必须隔离到工作区(sidecar.mjs 的 ensureWorkspaceSessionStore):
实测 ZCode 的会话库路径**硬编码 homedir()**,`ZCODE_DATA_BASE_DIR` 对它
完全无效(26 个会话全落全局库)。但 `<workdir>/.zcode/config.json` 的
`storage.sessionDbPath` **确实生效**。所以启动前写一份工作区配置,
一举解决:多线不共用历史 / 不污染全局 ~/.zcode / 删线不留孤儿会话。
只在不存在时写 —— 用户手工放的要尊重。
## 其他
· rawfile:项目根 rawfile/ 默认全收,解到 <插件目录>/rawfile/,绝对路径拒绝
· 凭证:命名引用 + ${ENV_VAR},独立 0600 文件,绝不进 config.db(0644)
· secretshell:给不受信 agent 用的安全 shell(ackey + safeshell)
· 冲突检查 / 评审判据 / 镜像 / 每条线独立 workdir(hash 命名,合并后删除)
判据 30+ 条,含 4 条「判据本身的判据」(防止判据假绿)。
768 lines
26 KiB
Go
768 lines
26 KiB
Go
package main
|
||
|
||
// 刀 4:**判据与复核**(`criteria` / `review`)—— 设计文档 §4.5 的落地。
|
||
//
|
||
// ## 核心主张:复核必须是**两步**(正向 + 变异)
|
||
//
|
||
// 用户原话:「我定义过单元测试后,对应 agent 完成修改后你直接让插件进行复核即可」
|
||
//
|
||
// 这里的隐含假设是「插件 GREEN = 改对了」。**该假设不成立** ——
|
||
// 插件只能跑主管**预先写下**的判据;判据没覆盖那条 bug,插件照样打勾。
|
||
//
|
||
// 本仓当日实证(我在 /tmp 复刻过一遍,结论一致):
|
||
//
|
||
// func Max(a,b int) int { if a>b { return a }; return b }
|
||
// 松判据只测 Max(5,3) == 5
|
||
// 变异 return b → return a(真 bug)⇒ **判据仍全绿**
|
||
// 补一条 Max(3,5) == 5 ⇒ 同一变异立刻报红
|
||
//
|
||
// ⇒ **没有反向阶段,就是在给"测试通过"这个信号不断充值信用,
|
||
// 直到它骗一次大的。**
|
||
//
|
||
// ## 两个阶段
|
||
//
|
||
// 正向 forward:跑判据,期望**通过**
|
||
// 反向 mutate :改坏被测代码,期望判据**报红**(不报红 ⇒ 判据太松)
|
||
//
|
||
// ## ★ 实现要点(每条都是踩过的坑)
|
||
//
|
||
// 1. **必须禁用测试缓存**(`-count=1`):变异后若命中缓存,测试根本没跑,
|
||
// "报红"就无从谈起 —— 而 Go 默认会缓存。实测复刻时就看到 `(cached)`。
|
||
// 2. **变异必须真的改到文件**:改坏→跑→**恢复**,恢复用 defer 保证
|
||
// 即使 panic 也不留脏文件(否则工作目录被污染,后续全乱)。
|
||
// 3. **恢复失败要响亮**:恢复不回去比变异本身危险得多(agent 会基于
|
||
// 坏代码继续改)。
|
||
// 4. **判据脚本的"通过"由退出码决定**,不是由输出文本里有没有 "PASS"。
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// ---------------- 台账结构 ----------------
|
||
|
||
// Criteria 是**主管定义的一条判据**。
|
||
type Criteria struct {
|
||
ID string `json:"id"`
|
||
ProjectID string `json:"project_id"`
|
||
BranchID string `json:"branch_id,omitempty"` // 留空 = 项目级(所有分支共用)
|
||
|
||
Name string `json:"name"`
|
||
// Kind 是判据类型:unit|interface|stress|build。
|
||
Kind string `json:"kind"`
|
||
|
||
// Script 是判据本体(shell 命令)。退出码 0 = 通过。
|
||
Script string `json:"script"`
|
||
// Cwd 是执行目录(相对工作目录)。留空 = 工作目录根。
|
||
Cwd string `json:"cwd,omitempty"`
|
||
// TimeoutSec 单次执行上限。默认 300。
|
||
TimeoutSec int `json:"timeout_sec,omitempty"`
|
||
|
||
// Expect 描述"通过"的含义(**给人看**,不参与判定)。
|
||
Expect string `json:"expect,omitempty"`
|
||
|
||
// Mutations 是**变异规则**(反向阶段用)。
|
||
//
|
||
// 空 = 只做正向。但设计 §4.5 明确:**没有反向就是在给信号充值信用** ——
|
||
// 所以复核结果里会把"无变异规则"标成一个**警告**,而不是静默略过。
|
||
Mutations []Mutation `json:"mutations,omitempty"`
|
||
|
||
// DefHash 是定义指纹:判据内容变了它就该变。
|
||
//
|
||
// 用途:① 幂等(重复 define 同一条不产生新记录)
|
||
// ② 审计("这次复核跑的是哪一版判据")
|
||
DefHash string `json:"def_hash"`
|
||
|
||
CreatedAt string `json:"created_at"`
|
||
UpdatedAt string `json:"updated_at,omitempty"`
|
||
}
|
||
|
||
// Mutation 是一条变异规则:把文件里的 old 换成 new。
|
||
type Mutation struct {
|
||
// ID 用于报告(如 "M1")。
|
||
ID string `json:"id"`
|
||
// File 是相对工作目录的路径。
|
||
File string `json:"file"`
|
||
// Old / New 是字面量替换(**不是正则** —— 正则容易改错地方)。
|
||
Old string `json:"old"`
|
||
New string `json:"new"`
|
||
// Note 说明这条变异想验证什么("判据是否覆盖了 b>a 的分支")。
|
||
Note string `json:"note,omitempty"`
|
||
}
|
||
|
||
// ReviewRun 是一次复核执行的结果。
|
||
type ReviewRun struct {
|
||
ID string `json:"id"`
|
||
BranchID string `json:"branch_id"`
|
||
Phase string `json:"phase"` // forward | mutate
|
||
Criteria string `json:"criteria_id"`
|
||
Result string `json:"result"` // pass | fail | error | skipped
|
||
Detail string `json:"detail,omitempty"`
|
||
Evidence string `json:"evidence,omitempty"` // 输出摘要(截断)
|
||
StartedAt string `json:"started_at"`
|
||
EndedAt string `json:"ended_at,omitempty"`
|
||
// HeadSHA 是复核时的提交 —— 判据通过的是**那个提交**,不是一个会移动的目标。
|
||
HeadSHA string `json:"head_sha,omitempty"`
|
||
}
|
||
|
||
// ---------------- 判据定义 ----------------
|
||
|
||
// criteriaDefHash 计算判据定义的指纹。
|
||
//
|
||
// 为何要它:① 幂等(同定义重复登记不产生新记录);
|
||
// ② 审计("这次跑的是哪一版判据")。变异规则也必须进指纹 ——
|
||
// 只改变异规则而判据本体不变时,复核的正确性已经变了。
|
||
func criteriaDefHash(c *Criteria) string {
|
||
h := sha256.New()
|
||
for _, part := range []string{c.Name, c.Kind, c.Script, c.Cwd, c.Expect} {
|
||
h.Write([]byte(part))
|
||
h.Write([]byte{0})
|
||
}
|
||
for _, m := range c.Mutations {
|
||
h.Write([]byte(m.ID + "\x01" + m.File + "\x01" + m.Old + "\x01" + m.New))
|
||
h.Write([]byte{0})
|
||
}
|
||
fmt.Fprintf(h, "%d", c.TimeoutSec)
|
||
return hex.EncodeToString(h.Sum(nil))[:16]
|
||
}
|
||
|
||
// ---------------- 执行 ----------------
|
||
|
||
// runShell 在工作目录里跑一段命令,返回 (退出码, 合并输出)。
|
||
//
|
||
// ★ 超时是必须的:判据脚本可能挂住(死循环、等输入)。内核工具调用
|
||
// 有 60s 硬限,但这里是**后台作业**(不受它限),所以自己定上限。
|
||
func runShell(dir, script string, timeoutSec int) (int, string) {
|
||
if timeoutSec <= 0 {
|
||
timeoutSec = 300
|
||
}
|
||
ctx, cancel := newTimeoutCtx(time.Duration(timeoutSec) * time.Second)
|
||
defer cancel()
|
||
|
||
cmd := exec.CommandContext(ctx, "/bin/sh", "-c", script)
|
||
cmd.Dir = dir
|
||
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
|
||
out, err := cmd.CombinedOutput()
|
||
code := 0
|
||
if err != nil {
|
||
if ee, ok := err.(*exec.ExitError); ok {
|
||
code = ee.ExitCode()
|
||
} else {
|
||
code = -1 // 起不来(命令不存在等)
|
||
}
|
||
}
|
||
return code, string(out)
|
||
}
|
||
|
||
// runCriteriaForward 执行一条判据的**正向**阶段。
|
||
//
|
||
// 返回 (是否通过, 证据摘要)。
|
||
func (p *Plugin) runCriteriaForward(c *Criteria, workdir string) (bool, string) {
|
||
dir := workdir
|
||
if c.Cwd != "" {
|
||
dir = filepath.Join(workdir, c.Cwd)
|
||
}
|
||
if _, err := os.Stat(dir); err != nil {
|
||
return false, fmt.Sprintf("执行目录不存在: %s", dir)
|
||
}
|
||
code, out := runShell(dir, c.Script, c.TimeoutSec)
|
||
if code == 0 {
|
||
return true, summarize(out, "通过(退出码 0)")
|
||
}
|
||
return false, summarize(out, fmt.Sprintf("未通过(退出码 %d)", code))
|
||
}
|
||
|
||
// runCriteriaMutate 执行**反向**阶段:逐条变异,期望判据报红。
|
||
//
|
||
// ★ 这是本刀的核心。设计 §4.5:变异**不报红 ⇒ 判据太松**。
|
||
//
|
||
// 返回 (全部变异都被咬住, 逐条说明, 是否有严重错误)。
|
||
func (p *Plugin) runCriteriaMutate(c *Criteria, workdir string) (bool, string, bool) {
|
||
if len(c.Mutations) == 0 {
|
||
return false, "未定义变异规则 —— **无法判断判据是否太松**。" +
|
||
"设计 §4.5:没有反向阶段,就是在给「测试通过」这个信号充值信用。" +
|
||
"请在 commondev_test_define 里补 mutations", false
|
||
}
|
||
|
||
allCaught := true
|
||
var lines []string
|
||
for _, m := range c.Mutations {
|
||
caught, note, hardErr := p.runOneMutation(c, m, workdir)
|
||
if hardErr {
|
||
// 硬错误(改不到文件、恢复失败)—— 必须响亮,不能算"咬住"
|
||
allCaught = false
|
||
lines = append(lines, fmt.Sprintf(" ✗ %s 执行错误: %s", m.ID, note))
|
||
continue
|
||
}
|
||
if caught {
|
||
lines = append(lines, fmt.Sprintf(" ✓ %s 被咬住(判据报红)%s", m.ID, suffixNote(m.Note)))
|
||
} else {
|
||
allCaught = false
|
||
lines = append(lines, fmt.Sprintf(" ✗ %s **未被咬住** —— 判据太松%s", m.ID, suffixNote(m.Note)))
|
||
}
|
||
}
|
||
return allCaught, strings.Join(lines, "\n"), false
|
||
}
|
||
|
||
// runOneMutation 改坏 → 跑判据 → 恢复。返回 (是否被咬住, 说明, 硬错误)。
|
||
func (p *Plugin) runOneMutation(c *Criteria, m Mutation, workdir string) (bool, string, bool) {
|
||
if m.File == "" || m.Old == "" {
|
||
return false, "变异规则缺 file 或 old", true
|
||
}
|
||
path := filepath.Join(workdir, m.File)
|
||
orig, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return false, fmt.Sprintf("读不到 %s: %v", m.File, err), true
|
||
}
|
||
text := string(orig)
|
||
|
||
// ★ Old 必须**恰好出现一次**:出现 0 次 = 变异无效(改不到);
|
||
// 出现多次 = 可能改错地方(第一个不一定是我们想改的那个)。
|
||
// 两者都必须报错 —— 否则"变异没生效"会被误判成"判据太松"(假警报),
|
||
// 或"改了别处"被误判成"咬住了"(假通过)。
|
||
n := strings.Count(text, m.Old)
|
||
if n == 0 {
|
||
return false, fmt.Sprintf("%s 里找不到要替换的片段(变异无效): %q", m.File, truncate(m.Old, 60)), true
|
||
}
|
||
if n > 1 {
|
||
return false, fmt.Sprintf("%s 里该片段出现 %d 次(可能改错地方,请给出更长的上下文): %q",
|
||
m.File, n, truncate(m.Old, 60)), true
|
||
}
|
||
|
||
mutated := strings.Replace(text, m.Old, m.New, 1)
|
||
if mutated == text {
|
||
return false, "替换后内容未变化(Old 与 New 相同?)", true
|
||
}
|
||
|
||
// ★ 写坏 → 跑 → **无论如何都恢复**(defer 保证 panic 也恢复)
|
||
restore := func() error { return os.WriteFile(path, orig, 0o644) }
|
||
if err := os.WriteFile(path, []byte(mutated), 0o644); err != nil {
|
||
return false, fmt.Sprintf("写变异失败: %v", err), true
|
||
}
|
||
defer func() { _ = restore() }()
|
||
|
||
passed, evidence := p.runCriteriaForward(c, workdir)
|
||
|
||
// 恢复并**校验**恢复成功
|
||
if err := restore(); err != nil {
|
||
return false, fmt.Sprintf("★ 恢复 %s 失败(工作目录已被污染): %v", m.File, err), true
|
||
}
|
||
back, err := os.ReadFile(path)
|
||
if err != nil || string(back) != text {
|
||
return false, fmt.Sprintf("★ 恢复 %s 后内容不一致(工作目录已被污染)", m.File), true
|
||
}
|
||
|
||
if !passed {
|
||
return true, summarize(evidence, "判据报红(期望行为)"), false
|
||
}
|
||
// 变异了但判据仍通过 ⇒ 这条变异没被咬住 ⇒ 判据在该维度太松
|
||
return false, summarize(evidence, "变异后判据**仍通过**"), false
|
||
}
|
||
|
||
func suffixNote(note string) string {
|
||
if note == "" {
|
||
return ""
|
||
}
|
||
return "(" + note + ")"
|
||
}
|
||
|
||
// summarize 把输出压成一条可读证据(**截断**:判据输出可能很长,
|
||
// 全塞进台账会让文件爆炸,而证据的价值在"能看出怎么失败的")。
|
||
func summarize(out, headline string) string {
|
||
out = strings.TrimSpace(out)
|
||
if out == "" {
|
||
return headline
|
||
}
|
||
return headline + "\n" + truncate(out, 1500)
|
||
}
|
||
|
||
// ---------------- 工具实现 ----------------
|
||
|
||
func (p *Plugin) handleTestDefine(args map[string]interface{}) (interface{}, error) {
|
||
proj := strings.TrimSpace(str(args, "project"))
|
||
if proj == "" {
|
||
return map[string]interface{}{"error": "project 必填"}, nil
|
||
}
|
||
name := strings.TrimSpace(str(args, "name"))
|
||
script := str(args, "script")
|
||
if name == "" || strings.TrimSpace(script) == "" {
|
||
return map[string]interface{}{"error": "name 与 script 必填"}, nil
|
||
}
|
||
kind := strings.TrimSpace(str(args, "kind"))
|
||
if kind == "" {
|
||
kind = "unit"
|
||
}
|
||
switch kind {
|
||
case "unit", "interface", "stress", "build":
|
||
default:
|
||
return map[string]interface{}{"error": "kind 只能是 unit|interface|stress|build"}, nil
|
||
}
|
||
|
||
c := &Criteria{
|
||
ID: fmt.Sprintf("cr-%s-%s", safeNamePattern.ReplaceAllString(proj, "_"), shortHash(proj+name+script)),
|
||
ProjectID: proj,
|
||
BranchID: strings.TrimSpace(str(args, "branch_id")),
|
||
Name: name,
|
||
Kind: kind,
|
||
Script: script,
|
||
Cwd: strings.TrimSpace(str(args, "cwd")),
|
||
Expect: strings.TrimSpace(str(args, "expect")),
|
||
TimeoutSec: int(numArg(args, "timeout_sec")),
|
||
CreatedAt: nowRFC3339(),
|
||
}
|
||
// 变异规则
|
||
if raw, ok := args["mutations"].([]interface{}); ok {
|
||
for i, item := range raw {
|
||
mm, ok := item.(map[string]interface{})
|
||
if !ok {
|
||
continue
|
||
}
|
||
m := Mutation{
|
||
ID: str(mm, "id"),
|
||
File: str(mm, "file"),
|
||
Old: str(mm, "old"),
|
||
New: str(mm, "new"),
|
||
Note: str(mm, "note"),
|
||
}
|
||
if m.ID == "" {
|
||
m.ID = fmt.Sprintf("M%d", i+1)
|
||
}
|
||
if m.File == "" || m.Old == "" || m.New == "" {
|
||
return map[string]interface{}{"error": fmt.Sprintf("变异 %s 缺 file/old/new", m.ID)}, nil
|
||
}
|
||
c.Mutations = append(c.Mutations, m)
|
||
}
|
||
}
|
||
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
if p.ledger.Criteria == nil {
|
||
p.ledger.Criteria = map[string]*Criteria{}
|
||
}
|
||
// 幂等:同 ID 且定义指纹相同 ⇒ 不动("可迭代幂等")
|
||
if prev, exists := p.ledger.Criteria[c.ID]; exists {
|
||
if prev.DefHash == criteriaDefHash(c) {
|
||
return map[string]interface{}{
|
||
"status": "unchanged", "criteria_id": c.ID,
|
||
"note": "同定义已存在,未改动(幂等)",
|
||
}, nil
|
||
}
|
||
c.CreatedAt = prev.CreatedAt
|
||
c.UpdatedAt = nowRFC3339()
|
||
}
|
||
c.DefHash = criteriaDefHash(c)
|
||
p.ledger.Criteria[c.ID] = c
|
||
if err := p.saveLocked(); err != nil {
|
||
return map[string]interface{}{"error": err.Error()}, nil
|
||
}
|
||
|
||
out := map[string]interface{}{
|
||
"status": "ok", "criteria_id": c.ID, "kind": c.Kind,
|
||
"def_hash": c.DefHash, "mutations": len(c.Mutations),
|
||
}
|
||
if len(c.Mutations) == 0 {
|
||
out["warning"] = "未定义变异规则:复核时**无法判断判据是否太松**(设计 §4.5)"
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (p *Plugin) handleTestList(args map[string]interface{}) (interface{}, error) {
|
||
proj := strings.TrimSpace(str(args, "project"))
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
out := []map[string]interface{}{}
|
||
for _, c := range p.ledger.Criteria {
|
||
if proj != "" && c.ProjectID != proj {
|
||
continue
|
||
}
|
||
out = append(out, map[string]interface{}{
|
||
"criteria_id": c.ID, "name": c.Name, "kind": c.Kind,
|
||
"project": c.ProjectID, "branch_id": c.BranchID,
|
||
"mutations": len(c.Mutations), "def_hash": c.DefHash,
|
||
})
|
||
}
|
||
sort.Slice(out, func(i, j int) bool {
|
||
return out[i]["name"].(string) < out[j]["name"].(string)
|
||
})
|
||
return map[string]interface{}{"criteria": out, "count": len(out)}, nil
|
||
}
|
||
|
||
// newTimeoutCtx 是 context.WithTimeout 的薄包装。
|
||
func newTimeoutCtx(d time.Duration) (context.Context, context.CancelFunc) {
|
||
return context.WithTimeout(context.Background(), d)
|
||
}
|
||
|
||
// ---------------- 复核执行(后台作业) ----------------
|
||
|
||
// reviewAll 对一个分支跑**全部**判据的复核,返回汇总。
|
||
//
|
||
// ★ 这是「主管按一下、插件跑完正反两阶段」的入口。
|
||
func (p *Plugin) reviewAll(branchID string) (map[string]interface{}, error) {
|
||
p.mu.Lock()
|
||
b, ok := p.ledger.Branches[branchID]
|
||
var workdir, proj string
|
||
if ok {
|
||
workdir, proj = b.Workdir, b.ProjectID
|
||
}
|
||
// 收集适用判据:分支级优先,项目级兜底
|
||
var list []*Criteria
|
||
for _, c := range p.ledger.Criteria {
|
||
if c.ProjectID != proj {
|
||
continue
|
||
}
|
||
if c.BranchID != "" && c.BranchID != branchID {
|
||
continue
|
||
}
|
||
list = append(list, c)
|
||
}
|
||
p.mu.Unlock()
|
||
|
||
if !ok {
|
||
return nil, fmt.Errorf("工程对话 %s 不存在", branchID)
|
||
}
|
||
if workdir == "" {
|
||
return nil, fmt.Errorf("该分支没有工作目录")
|
||
}
|
||
if len(list) == 0 {
|
||
return map[string]interface{}{
|
||
"error": fmt.Sprintf("项目 %s 没有定义任何判据 —— 先 commondev_test_define", proj),
|
||
}, nil
|
||
}
|
||
// ★ 固定顺序(map 遍历随机)—— 复核报告要可对照
|
||
sort.Slice(list, func(i, j int) bool {
|
||
if list[i].Kind != list[j].Kind {
|
||
return list[i].Kind < list[j].Kind
|
||
}
|
||
return list[i].Name < list[j].Name
|
||
})
|
||
|
||
head, _ := runGitOut(workdir, "rev-parse", "HEAD")
|
||
head = strings.TrimSpace(head)
|
||
|
||
forwardOK := true
|
||
mutateOK := true
|
||
anyWithoutMutations := false
|
||
var report []map[string]interface{}
|
||
|
||
for _, c := range list {
|
||
fwdPass, fwdEv := p.runCriteriaForward(c, workdir)
|
||
if !fwdPass {
|
||
forwardOK = false
|
||
}
|
||
mutCaught, mutEv, _ := p.runCriteriaMutate(c, workdir)
|
||
if len(c.Mutations) == 0 {
|
||
anyWithoutMutations = true
|
||
} else if !mutCaught {
|
||
mutateOK = false
|
||
}
|
||
|
||
item := map[string]interface{}{
|
||
"criteria_id": c.ID, "name": c.Name, "kind": c.Kind,
|
||
"forward_pass": fwdPass,
|
||
"forward_evidence": truncate(fwdEv, 800),
|
||
"mutations_total": len(c.Mutations),
|
||
"mutations_caught": mutCaught,
|
||
}
|
||
if len(c.Mutations) > 0 {
|
||
item["mutate_evidence"] = truncate(mutEv, 1200)
|
||
} else {
|
||
item["mutate_evidence"] = mutEv
|
||
}
|
||
report = append(report, item)
|
||
|
||
// 写复核历史(可审计:哪次复核、跑的是哪一版判据、在哪个提交上)
|
||
p.appendReviewRun(branchID, c.ID, "forward", fwdPass, fwdEv, head)
|
||
if len(c.Mutations) > 0 {
|
||
p.appendReviewRun(branchID, c.ID, "mutate", mutCaught, mutEv, head)
|
||
}
|
||
}
|
||
|
||
// ★ 结论三条并列,**不合并成一个绿色勾**:
|
||
// 正向绿 + 反向没跑 ≠ 复核通过(那是"信号充值")
|
||
verdict := "pass"
|
||
notes := []string{}
|
||
if !forwardOK {
|
||
verdict = "fail"
|
||
notes = append(notes, "有判据未通过(实现有 bug)")
|
||
}
|
||
if anyWithoutMutations {
|
||
notes = append(notes, "★ 有判据未定义变异规则 ⇒ 无法判断它是否太松(设计 §4.5)")
|
||
if verdict == "pass" {
|
||
verdict = "pass_with_gaps"
|
||
}
|
||
}
|
||
if !mutateOK {
|
||
notes = append(notes, "★ 有变异**未被咬住** ⇒ 对应判据太松,实现可能有问题而判据看不见")
|
||
if verdict == "pass" {
|
||
verdict = "pass_with_gaps"
|
||
}
|
||
}
|
||
|
||
out := map[string]interface{}{
|
||
"verdict": verdict, // pass | pass_with_gaps | fail
|
||
"forward_ok": forwardOK,
|
||
"mutate_ok": mutateOK,
|
||
"head_sha": shortSHA(head),
|
||
"criteria_run": len(report),
|
||
"report": report,
|
||
}
|
||
if len(notes) > 0 {
|
||
out["notes"] = notes
|
||
}
|
||
if verdict == "pass_with_gaps" {
|
||
out["warning"] = "正向全绿,但**反向阶段有缺口** —— " +
|
||
"这不是「通过」,是「判据没覆盖到」。插件 GREEN ≠ 改对了。"
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// appendReviewRun 记一条复核历史。
|
||
func (p *Plugin) appendReviewRun(branchID, criteriaID, phase string, pass bool, evidence, head string) {
|
||
res := "fail"
|
||
if pass {
|
||
res = "pass"
|
||
}
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
p.ledger.ReviewRuns = append(p.ledger.ReviewRuns, ReviewRun{
|
||
ID: fmt.Sprintf("rv-%d", time.Now().UnixNano()%1000000000),
|
||
BranchID: branchID, Criteria: criteriaID, Phase: phase,
|
||
Result: res, Evidence: truncate(evidence, 500),
|
||
StartedAt: nowRFC3339(), EndedAt: nowRFC3339(),
|
||
HeadSHA: head,
|
||
})
|
||
// 只保留最近 500 条(防台账无限膨胀;复核历史是审计用,不需要全量)
|
||
if n := len(p.ledger.ReviewRuns); n > 500 {
|
||
p.ledger.ReviewRuns = p.ledger.ReviewRuns[n-500:]
|
||
}
|
||
_ = p.saveLocked()
|
||
}
|
||
|
||
// handleReviewSubmit 是 commondev_review_submit 的实现。
|
||
//
|
||
// ★ 复核可能跑很久(判据里可能有编译、压测)⇒ 走**后台作业**,
|
||
// 与 branch_open 的 clone 同理(内核工具调用有 60s 硬限)。
|
||
func (p *Plugin) handleReviewSubmit(args map[string]interface{}) (interface{}, error) {
|
||
branchID := strings.TrimSpace(str(args, "branch_id"))
|
||
if branchID == "" {
|
||
return map[string]interface{}{"error": "branch_id 必填"}, nil
|
||
}
|
||
p.mu.Lock()
|
||
if _, ok := p.ledger.Branches[branchID]; !ok {
|
||
p.mu.Unlock()
|
||
return map[string]interface{}{"error": fmt.Sprintf("工程对话 %s 不存在", branchID)}, nil
|
||
}
|
||
jobID := fmt.Sprintf("review-%s-%d", branchID, time.Now().UnixNano()%100000)
|
||
p.ledger.Jobs[jobID] = &Job{
|
||
ID: jobID, BranchID: branchID, Kind: "review",
|
||
Status: "pending", StartedAt: nowRFC3339(),
|
||
}
|
||
p.saveLocked()
|
||
p.mu.Unlock()
|
||
|
||
go func() {
|
||
p.setJob(jobID, "running", "正在复核(正向 + 变异)", "")
|
||
res, err := p.reviewAll(branchID)
|
||
if err != nil {
|
||
p.setJob(jobID, "failed", "", err.Error())
|
||
return
|
||
}
|
||
if e, isErr := res["error"]; isErr {
|
||
p.setJob(jobID, "failed", "", fmt.Sprint(e))
|
||
return
|
||
}
|
||
verdict := fmt.Sprint(res["verdict"])
|
||
detail := fmt.Sprintf("复核结论: %s", verdict)
|
||
if notes, ok := res["notes"].([]string); ok && len(notes) > 0 {
|
||
detail += " | " + strings.Join(notes, ";")
|
||
}
|
||
p.setJob(jobID, "done", detail, "")
|
||
p.lastReview = res
|
||
}()
|
||
|
||
return map[string]interface{}{
|
||
"status": "ok", "job_id": jobID, "branch_id": branchID,
|
||
"next": "复核在后台进行(判据可能含编译/压测,超过 60s 工具上限)——" +
|
||
"用 commondev_job_status 查进度;用 commondev_review_result 取结论",
|
||
}, nil
|
||
}
|
||
|
||
// handleReviewResult 取最近一次复核结论。
|
||
func (p *Plugin) handleReviewResult(args map[string]interface{}) (interface{}, error) {
|
||
branchID := strings.TrimSpace(str(args, "branch_id"))
|
||
p.mu.Lock()
|
||
res := p.lastReview
|
||
// 也把历史摘要带上(审计)
|
||
var hist []map[string]interface{}
|
||
for _, r := range p.ledger.ReviewRuns {
|
||
if branchID != "" && r.BranchID != branchID {
|
||
continue
|
||
}
|
||
hist = append(hist, map[string]interface{}{
|
||
"id": r.ID, "phase": r.Phase, "criteria": r.Criteria,
|
||
"result": r.Result, "head_sha": shortSHA(r.HeadSHA), "at": r.StartedAt,
|
||
})
|
||
}
|
||
p.mu.Unlock()
|
||
|
||
if res == nil && len(hist) == 0 {
|
||
return map[string]interface{}{"error": "还没有复核记录(先 commondev_review_submit)"}, nil
|
||
}
|
||
// 只给最近 40 条(防刷屏)
|
||
if len(hist) > 40 {
|
||
hist = hist[len(hist)-40:]
|
||
}
|
||
out := map[string]interface{}{"history": hist, "history_count": len(hist)}
|
||
if res != nil {
|
||
out["last"] = res
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// ---------------- 单阶段复核工具(设计 §4.1) ----------------
|
||
//
|
||
// 设计 §4.1 把复核拆成两个工具:
|
||
//
|
||
// commondev_test_run 执行判据集合(正向)
|
||
// commondev_test_mutate 执行变异验证(反向)★ 必须有
|
||
//
|
||
// 而 commondev_review_submit 是**两者一起跑**(主管按一下就跑完)。
|
||
// 三种粒度都保留,因为排查时需要单跑某一阶段:
|
||
// "正向都过不了" 与 "正过但变异咬不住" 是**完全不同**的问题。
|
||
|
||
// criteriaFor 收集适用于某分支的判据(分支级优先,项目级兜底)。
|
||
func (p *Plugin) criteriaFor(branchID, proj string) []*Criteria {
|
||
p.mu.Lock()
|
||
var list []*Criteria
|
||
for _, c := range p.ledger.Criteria {
|
||
if c.ProjectID != proj {
|
||
continue
|
||
}
|
||
if c.BranchID != "" && c.BranchID != branchID {
|
||
continue
|
||
}
|
||
list = append(list, c)
|
||
}
|
||
p.mu.Unlock()
|
||
sort.Slice(list, func(i, j int) bool {
|
||
if list[i].Kind != list[j].Kind {
|
||
return list[i].Kind < list[j].Kind
|
||
}
|
||
return list[i].Name < list[j].Name
|
||
})
|
||
return list
|
||
}
|
||
|
||
// lookupBranchWorkdir 取分支与其项目(找不到时给明确错误)。
|
||
func (p *Plugin) lookupBranchWorkdir(branchID string) (*Branch, *Project, error) {
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
b, ok := p.ledger.Branches[branchID]
|
||
if !ok {
|
||
return nil, nil, fmt.Errorf("工程对话 %s 不存在", branchID)
|
||
}
|
||
proj := p.ledger.Projects[b.ProjectID]
|
||
if proj == nil {
|
||
return nil, nil, fmt.Errorf("分支 %s 的项目 %s 不存在", branchID, b.ProjectID)
|
||
}
|
||
if strings.TrimSpace(b.Workdir) == "" {
|
||
return nil, nil, fmt.Errorf("该分支没有工作目录(先 commondev_branch_open)")
|
||
}
|
||
return b, proj, nil
|
||
}
|
||
|
||
// handleTestRun 只跑**正向**阶段。
|
||
func (p *Plugin) handleTestRun(args map[string]interface{}) (interface{}, error) {
|
||
branchID := strings.TrimSpace(str(args, "branch_id"))
|
||
if branchID == "" {
|
||
return map[string]interface{}{"error": "branch_id 必填"}, nil
|
||
}
|
||
b, proj, err := p.lookupBranchWorkdir(branchID)
|
||
if err != nil {
|
||
return map[string]interface{}{"error": err.Error()}, nil
|
||
}
|
||
list := p.criteriaFor(branchID, proj.Name)
|
||
if len(list) == 0 {
|
||
return map[string]interface{}{"error": fmt.Sprintf("项目 %s 没有定义判据(先 commondev_test_define)", proj.Name)}, nil
|
||
}
|
||
allPass, report := true, []map[string]interface{}{}
|
||
for _, c := range list {
|
||
pass, ev := p.runCriteriaForward(c, b.Workdir)
|
||
if !pass {
|
||
allPass = false
|
||
}
|
||
report = append(report, map[string]interface{}{
|
||
"criteria_id": c.ID, "name": c.Name, "kind": c.Kind,
|
||
"pass": pass, "evidence": truncate(ev, 800),
|
||
})
|
||
}
|
||
out := map[string]interface{}{
|
||
"phase": "forward", "all_pass": allPass,
|
||
"criteria_run": len(report), "report": report,
|
||
}
|
||
if !allPass {
|
||
out["error"] = "有判据未通过(实现有 bug)"
|
||
}
|
||
// ★ 提醒:正向过了也不代表改对了
|
||
out["warning"] = "正向通过 ≠ 改对了 —— 还要跑 commondev_test_mutate" +
|
||
"(变异不报红 ⇒ 判据太松,见设计 §4.5)"
|
||
return out, nil
|
||
}
|
||
|
||
// handleTestMutate 只跑**反向**阶段。
|
||
func (p *Plugin) handleTestMutate(args map[string]interface{}) (interface{}, error) {
|
||
branchID := strings.TrimSpace(str(args, "branch_id"))
|
||
if branchID == "" {
|
||
return map[string]interface{}{"error": "branch_id 必填"}, nil
|
||
}
|
||
b, proj, err := p.lookupBranchWorkdir(branchID)
|
||
if err != nil {
|
||
return map[string]interface{}{"error": err.Error()}, nil
|
||
}
|
||
list := p.criteriaFor(branchID, proj.Name)
|
||
if len(list) == 0 {
|
||
return map[string]interface{}{"error": fmt.Sprintf("项目 %s 没有定义判据", proj.Name)}, nil
|
||
}
|
||
allCaught, withGaps, report := true, []string{}, []map[string]interface{}{}
|
||
for _, c := range list {
|
||
if len(c.Mutations) == 0 {
|
||
allCaught = false
|
||
withGaps = append(withGaps, c.Name)
|
||
report = append(report, map[string]interface{}{
|
||
"criteria_id": c.ID, "name": c.Name,
|
||
"mutations_total": 0, "all_caught": false,
|
||
"evidence": "★ 未定义变异规则 ⇒ 无法判断判据是否太松",
|
||
})
|
||
continue
|
||
}
|
||
caught, ev, _ := p.runCriteriaMutate(c, b.Workdir)
|
||
if !caught {
|
||
allCaught = false
|
||
}
|
||
report = append(report, map[string]interface{}{
|
||
"criteria_id": c.ID, "name": c.Name,
|
||
"mutations_total": len(c.Mutations), "all_caught": caught,
|
||
"evidence": truncate(ev, 1200),
|
||
})
|
||
}
|
||
out := map[string]interface{}{
|
||
"phase": "mutate", "all_caught": allCaught,
|
||
"criteria_run": len(report), "report": report,
|
||
}
|
||
if len(withGaps) > 0 {
|
||
out["criteria_without_mutations"] = withGaps
|
||
}
|
||
if !allCaught {
|
||
out["warning"] = "★ 有变异**未被咬住** ⇒ 判据太松,实现可能有问题而判据看不见"
|
||
}
|
||
return out, nil
|
||
}
|