// Command dualrun 是 AC-15 双跑 harness 的 CLI 入口。 // // 用法: // // dualrun stage1 --case-set real100 # 真实基线 100 条 // dualrun stage1 --case-set synthetic # 合成边界 6 类 × 5 // dualrun stage1 --all --out report.json --md report.md // dualrun stage1 --dry-run # 只列 case 不执行 // dualrun stage1 --all --idempotency # 额外做幂等验证 // // 环境变量(口令只经环境变量,不落盘): // // AC15_DB_HOST / AC15_DB_PORT / AC15_DB_NAME / AC15_DB_USER / AC15_DB_PASS // AC15_LEGACY_REPO legacy 应用根目录(默认 E:/study/php/ysw-finance/yuansiwei-finance) // AC15_PHP_BIN php 可执行(默认 php) // // 退出码:0 = 跑完(无论结论如何);2 = 参数错误;3 = 环境层面失败(守卫/断言/连接)。 package main import ( "context" "flag" "fmt" "os" "strings" "yuansiwei/unified-backend/internal/academic/dualrun" "yuansiwei/unified-backend/internal/academic/dualrun/stage2" ) func main() { if len(os.Args) < 2 { usage() os.Exit(2) } switch os.Args[1] { case "stage1": os.Exit(runStage1(os.Args[2:])) case "stage2": os.Exit(runStage2(os.Args[2:])) case "sentinel": os.Exit(runSentinel(os.Args[2:])) case "-h", "--help", "help": usage() os.Exit(0) default: fmt.Fprintf(os.Stderr, "未知子命令:%s\n\n", os.Args[1]) usage() os.Exit(2) } } func usage() { fmt.Fprint(os.Stderr, `dualrun —— AC-15 双跑 harness 子命令: stage1 跑课酬链比对(课酬,纯函数) stage2 跑写 SP 双跑(消费:sp_user_expense_school_expenditure ↔ FundService.Expenditure) sentinel 跑存量哨兵:全库扫「全局列 <=> Σ校区列」(不挂在 stage2 的 case 里) stage1 参数: --case-set string real100 | synthetic | all(默认 all) --all 等价于 --case-set all --dry-run 只列 case 不执行 --out string JSON 报告路径(默认 dualrun-stage1.json) --md string Markdown 报告路径(默认 dualrun-stage1.md) --idempotency 额外做幂等验证(同 case 连跑两次) --real-count int 真实基线条数(默认 100) --per-cat int 合成每类条数(默认 5) `) } func runStage1(args []string) int { fs := flag.NewFlagSet("stage1", flag.ContinueOnError) fs.SetOutput(os.Stderr) caseSet := fs.String("case-set", "all", "real100 | synthetic | all") all := fs.Bool("all", false, "等价于 --case-set all") dryRun := fs.Bool("dry-run", false, "只列 case 不执行") out := fs.String("out", "dualrun-stage1.json", "JSON 报告路径") md := fs.String("md", "dualrun-stage1.md", "Markdown 报告路径") idem := fs.Bool("idempotency", false, "幂等验证") realCount := fs.Int("real-count", 100, "真实基线条数") perCat := fs.Int("per-cat", 5, "合成每类条数") if err := fs.Parse(args); err != nil { return 2 } if *all { *caseSet = "all" } cfg := dualrun.LoadConfigFromEnv() // 守卫违反会 panic(验收要求),这里转成清晰的退出码与提示 exit := 0 func() { defer func() { if r := recover(); r != nil { fmt.Fprintf(os.Stderr, "FATAL: %v\n", r) exit = 3 } }() rep, err := dualrun.RunStage1(context.Background(), cfg, dualrun.Options{ CaseSet: *caseSet, DryRun: *dryRun, OutJSON: *out, OutMD: *md, Idempotency: *idem, RealCount: *realCount, PerCat: *perCat, }) if err != nil { fmt.Fprintf(os.Stderr, "FATAL: %v\n", err) exit = 3 return } fmt.Printf("cases=%d dryRun=%v\n", len(rep.Cases)+len(rep.DryRunCases), rep.DryRun) for _, a := range dualrun.AllAttributions { if n := rep.Summary[a]; n > 0 { fmt.Printf(" %-24s %d\n", a, n) } } if rep.DryRun { fmt.Printf("dry-run 清单已写入 %s / %s\n", *out, *md) } else { fmt.Printf("报告已写入 %s / %s\n", *out, *md) } }() return exit } // caseNameMatches 判断 `-case` 是否命中该 case 名。 // // 语义:**按名字首段精确匹配**,不是子串。 // // "-case C1-5" → 命中 "C1-5 清零前置校验·…",**不命中** "C1-5b …"("C1-5b 清零" 不以 "C1-5 " 开头) // "-case C1-5xx" → 一条都不命中 → FATAL // // 为什么不做纯子串:子串会让 "C1-5" 把 "C1-5b" 一起带进来,"case 总数"就错了, // 正好让 team-lead 定的"hash + 总数 + 通过数三者对齐"失效。 // 为什么不做纯全等:那要用户连中文后缀一起打,实际不可用。 func caseNameMatches(name, only string) bool { if name == only { return true } return strings.HasPrefix(name, only+" ") } // runSentinel 跑存量哨兵(全库扫描,与具体 case 无关)。 func runSentinel(args []string) int { fs := flag.NewFlagSet("sentinel", flag.ContinueOnError) fs.SetOutput(os.Stderr) dbName := fs.String("db", "finance_bk", "扫描的库(finance_bk | yuansiwei_2026)") if err := fs.Parse(args); err != nil { return 2 } repoRoot, err := os.Getwd() if err != nil { fmt.Fprintf(os.Stderr, "FATAL: 取工作目录失败:%v\n", err) return 3 } cfg, err := stage2.LoadConfig(repoRoot) if err != nil { fmt.Fprintf(os.Stderr, "FATAL: %v\n", err) return 3 } if err := cfg.Guard(); err != nil { fmt.Fprintf(os.Stderr, "FATAL: %v\n", err) return 3 } ok, err := stage2.RunStockSentinels(context.Background(), cfg, *dbName, os.Stdout) if err != nil { fmt.Fprintf(os.Stderr, "FATAL: 哨兵执行失败:%v\n", err) return 3 } if !ok { return 1 } return 0 } // runStage2 跑写 SP 双跑(第一支:消费)。 func runStage2(args []string) int { fs := flag.NewFlagSet("stage2", flag.ContinueOnError) fs.SetOutput(os.Stderr) only := fs.String("case", "", "只跑指定 case(按名字首段精确匹配,不是子串)") probe := fs.Bool("probe", false, "跑**探针**(非同构 case,不进 DefaultCases、不构成验收)") if err := fs.Parse(args); err != nil { return 2 } repoRoot, err := os.Getwd() if err != nil { fmt.Fprintf(os.Stderr, "FATAL: 取工作目录失败:%v\n", err) return 3 } cfg, err := stage2.LoadConfig(repoRoot) if err != nil { fmt.Fprintf(os.Stderr, "FATAL: %v\n", err) return 3 } if err := cfg.Guard(); err != nil { fmt.Fprintf(os.Stderr, "FATAL: %v\n", err) return 3 } fmt.Printf("%s\n\n", cfg.Masked()) ctx := context.Background() all := stage2.DefaultCases("") source := "DefaultCases" if *probe { // 探针:非同构 case(第二侧喂不到同构输入)⇒ **不进分母、不算验收**。 // 见 stage2.ProbeCases 的注释。 all = stage2.ProbeCases() source = "ProbeCases" } // 检查 ①:case 列表不能为空。 // 防"case 列表被清空 / build tag 没生效 / DefaultCases 被改坏" —— 那种情况下 // `Summarize` 会返回 `0 == 0 = true`,跑了个寂寞但退出 0。 if len(all) == 0 { fmt.Fprintf(os.Stderr, "FATAL: %s 长度为 0,没有任何 case 可跑\n", source) return 3 } // 检查 ②:`-case` **精确匹配**(不是子串)。 // 子串匹配会让 `-case C1-5` 把 `C1-5b` 一起带进来,"case 总数"就错了。 matched := 0 var results []*stage2.CaseResult for _, c := range all { if *only != "" && !caseNameMatches(c.Name, *only) { continue } matched++ r, err := stage2.RunOne(ctx, cfg, c) if err != nil { fmt.Fprintf(os.Stderr, "FATAL: case %q 执行失败:%v\n", c.Name, err) return 3 } stage2.PrintResult(os.Stdout, r) results = append(results, r) } // 检查 ③:`-case` 必须**恰好**匹配 1 条 —— 匹配不到或匹配到多条都报错退非 0。 // 否则 0 个 case 会以退出码 0 "通过"(`Summarize` 的 `0 == 0` 是 true)。 if *only != "" && matched == 0 { fmt.Fprintf(os.Stderr, "FATAL: -case %q 没有匹配到任何 case(按名字首段精确匹配,不是子串)\n", *only) return 2 } if *only != "" && matched > 1 { fmt.Fprintf(os.Stderr, "FATAL: -case %q 匹配到 %d 条,定向跑必须恰好 1 条\n", *only, matched) return 2 } if *only != "" { fmt.Printf("定向 case:%s\n", results[0].Case.Name) } // 检查 ④:实跑数必须等于"本次应跑数"(**分母收缩**护栏)。 // 防将来有人把 `RunOne` 的 `return 3` 改成 `continue` —— 那时分母会悄悄从 25 变 22, // 而结论仍是"全 PASS"。分母必须是"应该跑的全部",不是"真正跑完的"。 if len(results) != matched { fmt.Fprintf(os.Stderr, "FATAL: 分母收缩 —— 应跑 %d 条,实跑 %d 条\n", matched, len(results)) return 3 } fmt.Printf("分母:%s=%d | 本次应跑=%d | 实跑=%d\n", source, len(all), matched, len(results)) if *only != "" { fmt.Printf("⚠ 定向跑:仅 1 条,**不构成验收** —— 验收必须全量跑(不带 -case)\n") } if *probe { fmt.Printf("⚠ 探针模式:%s 里的 case **非同构**(第二侧喂不到同构输入),只登记事实、不算验收\n", source) } fmt.Println() if ok := stage2.Summarize(os.Stdout, results); !ok { return 1 } return 0 }