| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266 |
- // 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(按名字子串匹配)")
- 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("")
- // 检查 ①:case 列表不能为空。
- // 防"case 列表被清空 / build tag 没生效 / DefaultCases 被改坏" —— 那种情况下
- // `Summarize` 会返回 `0 == 0 = true`,跑了个寂寞但退出 0。
- if len(all) == 0 {
- fmt.Fprintf(os.Stderr, "FATAL: DefaultCases 长度为 0,没有任何 case 可跑\n")
- 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("分母:DefaultCases=%d | 本次应跑=%d | 实跑=%d\n", len(all), matched, len(results))
- if *only != "" {
- fmt.Printf("⚠ 定向跑:仅 1 条,**不构成验收** —— 验收必须全量跑(不带 -case)\n")
- }
- fmt.Println()
- if ok := stage2.Summarize(os.Stdout, results); !ok {
- return 1
- }
- return 0
- }
|