返回 DeepSeek-Reasonix
main.go
根目录 / cmd / e2ebench / main.go
1 // e2ebench runs the committed e2e task suite against a real provider and emits a
2 // markdown + JSON report (accuracy, cache-hit rate, token use, cost) for a PR.
3 package main
4
5 import (
6 "context"
7 "encoding/json"
8 "flag"
9 "fmt"
10 "io"
11 "os"
12 "os/exec"
13 "path/filepath"
14 "sort"
15 "strings"
16 "time"
17
18 "github.com/BurntSushi/toml"
19
20 "reasonix/internal/ablation"
21 fileencoding "reasonix/internal/fileutil/encoding"
22 )
23
24 type task struct {
25 ID string
26 Prompt string `toml:"prompt"`
27 MaxSteps int `toml:"max_steps"`
28 TimeoutSec int `toml:"timeout_sec"`
29 dir string
30 }
31
32 type runMetrics struct {
33 PromptTokens int `json:"prompt_tokens"`
34 CompletionTokens int `json:"completion_tokens"`
35 CacheHitTokens int `json:"cache_hit_tokens"`
36 CacheMissTokens int `json:"cache_miss_tokens"`
37 // PrefixChangeReasonCounts mirrors internal/cli.RunMetrics's field of the
38 // same name: per-run tallies of why the cache prefix changed (e.g.
39 // "compact_auto", "snip", "tools"), omitempty for older metrics files.
40 PrefixChangeReasonCounts map[string]int `json:"prefix_change_reason_counts,omitempty"`
41 Steps int `json:"steps"`
42 Cost float64 `json:"cost"`
43 Currency string `json:"currency"`
44 Compactions int `json:"compactions"`
45
46 // Optional Delivery capability counters (omitempty for baseline/old metrics).
47 ReadinessChecks int `json:"readiness_checks,omitempty"`
48 ReadinessRecoveries int `json:"readiness_recoveries,omitempty"`
49 CapabilityRoutes int `json:"capability_routes,omitempty"`
50 CapabilityRoutedCandidates int `json:"capability_routed_candidates,omitempty"`
51 CapabilityRoutedRequire int `json:"capability_routed_require,omitempty"`
52 CapabilityRoutedPrefer int `json:"capability_routed_prefer,omitempty"`
53 CapabilityRoutedSuggest int `json:"capability_routed_suggest,omitempty"`
54 CapabilityDeclines int `json:"capability_declines,omitempty"`
55 CapabilitySemanticRoutes int `json:"capability_semantic_routes,omitempty"`
56 CapabilitySkillInvocations int `json:"capability_skill_invocations,omitempty"`
57 CapabilityMCPCall int `json:"capability_mcp_call,omitempty"`
58 CapabilityReviewBlocks int `json:"capability_review_blocks,omitempty"`
59 CapabilityRouterCost float64 `json:"capability_router_cost,omitempty"`
60 CapabilityRouterLatencyMs int64 `json:"capability_router_latency_ms,omitempty"`
61
62 Complete bool `json:"complete"`
63 Outcome string `json:"outcome,omitempty"`
64 ToolCalls int `json:"tool_calls,omitempty"`
65 ToolFailures int `json:"tool_failures,omitempty"`
66 SubagentToolCalls int `json:"subagent_tool_calls,omitempty"`
67 Retries int `json:"retries,omitempty"`
68 ToolCallsByName map[string]int `json:"tool_calls_by_name,omitempty"`
69 ToolFailuresByName map[string]int `json:"tool_failures_by_name,omitempty"`
70 }
71
72 type result struct {
73 task
74 runMetrics
75 Profile string `json:"profile"`
76 // Arm is the ablation arm the harness requested, not the arm the child
77 // reported, so a run that died before writing metrics is still attributable.
78 Arm string `json:"arm"`
79 Passed bool
80 Skipped bool
81 Note string
82 // WallMs is the harness's own clock, not the agent's self-report, so the
83 // number stays comparable when the same suite runs against another harness.
84 WallMs int64 `json:"wall_ms"`
85 // Unaccounted marks a run whose metrics file never landed — a killed agent
86 // writes nothing. Its real cost is unknown, so it is kept out of the cost
87 // and token aggregates instead of being averaged in as zero, which would
88 // quietly understate every published per-task figure.
89 Unaccounted bool `json:"unaccounted"`
90 // Partial marks accounting recovered from an in-flight snapshot after the
91 // agent was killed. The numbers are real but stop at the last snapshot, so
92 // they are counted as lower bounds rather than dropped.
93 Partial bool `json:"partial"`
94 }
95
96 // class is the published failure taxonomy: solved, the guard that stopped the
97 // run, or wrong_patch when the agent finished cleanly and the grader still
98 // failed. outcome carries the agent's own classification when it wrote metrics.
99 func (r result) class() string {
100 switch {
101 case r.Skipped:
102 return "skipped"
103 case r.Passed:
104 return "solved"
105 case r.Outcome != "" && r.Outcome != "success":
106 return r.Outcome
107 case r.Outcome == "":
108 return "no_metrics"
109 default:
110 return "wrong_patch"
111 }
112 }
113
114 const defaultSuiteTokenBudget = 800_000
115
116 func main() {
117 flag.Usage = func() {
118 fmt.Fprintf(flag.CommandLine.Output(), "e2ebench — Reasonix end-to-end benchmark.\n\n")
119 fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", flag.CommandLine.Name())
120 flag.PrintDefaults()
121 fmt.Fprintf(flag.CommandLine.Output(), "\nExamples:\n")
122 fmt.Fprintf(flag.CommandLine.Output(), " # Run the committed suite:\n")
123 fmt.Fprintf(flag.CommandLine.Output(), " %[1]s\n\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
124 fmt.Fprintf(flag.CommandLine.Output(), " # Grade a PR's diff with a retry budget:\n")
125 fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -mode diff -base origin/main-v2 -repo . -attempts 3 -timeout 1800\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
126 fmt.Fprintf(flag.CommandLine.Output(), "\n # Run the same suite with the delivery contract:\n")
127 fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -profile delivery\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
128 }
129
130 mode := flag.String("mode", "suite", "suite | diff | swebench")
131 subset := flag.String("subset", "benchmarks/swebench/subset.json", "swebench mode: instance subset file")
132 namespace := flag.String("namespace", "swebench", "swebench mode: registry namespace holding the evaluation images")
133 runID := flag.String("run-id", "reasonix", "swebench mode: run id passed to the official harness")
134 harnessPy := flag.String("harness-python", "python3", "swebench mode: interpreter with the swebench package installed")
135 dataset := flag.String("dataset", "princeton-nlp/SWE-bench_Verified", "swebench mode: dataset name")
136 permission := flag.String("permission", "auto", "swebench mode: agent permission posture (auto | yolo)")
137 network := flag.String("network", "", "swebench mode: docker network for agent containers; must have no off-box route")
138 proxyURL := flag.String("proxy", "", "swebench mode: the only egress the agent gets, expected to allowlist just the model API")
139 workers := flag.Int("workers", 4, "swebench mode: parallel grader workers")
140 keepImages := flag.Bool("keep-images", false, "swebench mode: keep instance images instead of removing them after each run")
141 suite := flag.String("suite", "benchmarks/e2e", "suite root (contains tasks/<id>/)")
142 bin := flag.String("bin", "reasonix", "path to the reasonix binary")
143 model := flag.String("model", "", "provider/model name (default: config default)")
144 profileFlag := flag.String("profile", benchmarkProfileBaseline, "prompt profile: baseline | delivery")
145 ablateFlag := flag.String("ablate", "", "ablation arm: subsystems to switch off (evidence, planner, subagent, retrieval, compaction; none|all)")
146 outMD := flag.String("out", "", "write the markdown report here (default: stdout)")
147 outJSON := flag.String("json", "", "write the JSON report here (optional)")
148 budget := flag.Int("budget", defaultSuiteTokenBudget, "abort once total tokens cross this (0 = no cap)")
149 // diff-mode flags
150 repo := flag.String("repo", ".", "repo root (diff mode)")
151 base := flag.String("base", "", "base ref to diff the PR head against (diff mode)")
152 testCmd := flag.String("test-cmd", "go test", "grader command run on the affected packages (diff mode)")
153 maxSteps := flag.Int("max-steps", 80, "agent tool-call cap for the diff task")
154 timeoutSec := flag.Int("timeout", 1200, "agent timeout in seconds (diff mode)")
155 attempts := flag.Int("attempts", 1, "diff mode: retry up to N times until a run passes (stochastic agent)")
156 flag.Parse()
157 profile, err := normalizeBenchmarkProfile(*profileFlag)
158 if err != nil {
159 fmt.Fprintln(os.Stderr, err)
160 os.Exit(2)
161 }
162 arm, err := ablation.Parse(*ablateFlag)
163 if err != nil {
164 fmt.Fprintln(os.Stderr, err)
165 os.Exit(2)
166 }
167
168 if *mode == "swebench" {
169 if _, err := permissionFlag(*permission); err != nil {
170 fmt.Fprintln(os.Stderr, err)
171 os.Exit(2)
172 }
173 cwd, _ := os.Getwd()
174 report := runSwebench(swebenchOpts{
175 bin: *bin, subset: *subset, namespace: *namespace, model: *model,
176 profile: profile, permission: *permission, arm: arm, runID: *runID, workDir: cwd,
177 harness: *harnessPy, dataset: *dataset, maxSteps: *maxSteps,
178 timeoutSec: *timeoutSec, workers: *workers, keepImages: *keepImages,
179 network: *network, proxyURL: *proxyURL,
180 })
181 emit(report, *outMD, "")
182 if *outJSON != "" {
183 fmt.Fprintln(os.Stderr, "note: -json is not written in swebench mode; the harness report is authoritative")
184 }
185 return
186 }
187
188 if *mode == "diff" {
189 report := runDiff(diffOpts{
190 bin: *bin, model: *model, repo: *repo, base: *base,
191 testCmd: *testCmd, profile: profile, ablate: arm, maxSteps: *maxSteps, timeoutSec: *timeoutSec, attempts: *attempts,
192 })
193 emit(report, *outMD, "")
194 return
195 }
196
197 tasks, err := loadTasks(*suite)
198 if err != nil {
199 fmt.Fprintln(os.Stderr, "load suite:", err)
200 os.Exit(1)
201 }
202 if len(tasks) == 0 {
203 dir := filepath.Join(*suite, "tasks")
204 if _, statErr := os.Stat(dir); statErr != nil {
205 fmt.Fprintf(os.Stderr, "no tasks found under %s: %v\n", dir, statErr)
206 } else {
207 fmt.Fprintf(os.Stderr, "no tasks found under %s (the directory exists but contains no task.toml files)\n", dir)
208 }
209 os.Exit(1)
210 }
211
212 var results []result
213 total := 0
214 for _, t := range tasks {
215 if *budget > 0 && total >= *budget {
216 results = append(results, result{task: t, Profile: profile, Skipped: true, Note: "skipped: token budget reached"})
217 continue
218 }
219 r := runTask(*bin, *model, profile, arm, t)
220 total += r.PromptTokens + r.CompletionTokens
221 results = append(results, r)
222 }
223
224 report := render(results)
225 if *outMD != "" {
226 if err := os.WriteFile(*outMD, []byte(report), 0o644); err != nil {
227 fmt.Fprintln(os.Stderr, "write report:", err)
228 os.Exit(1)
229 }
230 } else {
231 fmt.Print(report)
232 }
233 if *outJSON != "" {
234 b, err := json.MarshalIndent(results, "", " ")
235 if err != nil {
236 fmt.Fprintln(os.Stderr, "marshal json:", err)
237 os.Exit(1)
238 }
239 if err := os.WriteFile(*outJSON, b, 0o644); err != nil {
240 fmt.Fprintln(os.Stderr, "write json:", err)
241 os.Exit(1)
242 }
243 }
244 }
245
246 func emit(report, outMD, _ string) {
247 if outMD != "" {
248 if err := os.WriteFile(outMD, []byte(report), 0o644); err != nil {
249 fmt.Fprintln(os.Stderr, "write report:", err)
250 os.Exit(1)
251 }
252 return
253 }
254 fmt.Print(report)
255 }
256
257 func loadTasks(suite string) ([]task, error) {
258 tasksDir := filepath.Join(suite, "tasks")
259 entries, err := os.ReadDir(tasksDir)
260 if err != nil {
261 return nil, err
262 }
263 var tasks []task
264 for _, e := range entries {
265 if !e.IsDir() {
266 continue
267 }
268 dir := filepath.Join(tasksDir, e.Name())
269 var t task
270 data, err := fileencoding.ReadFileUTF8(filepath.Join(dir, "task.toml"))
271 if err != nil {
272 return nil, fmt.Errorf("%s: %w", e.Name(), err)
273 }
274 if _, err := toml.Decode(string(data), &t); err != nil {
275 return nil, fmt.Errorf("%s: %w", e.Name(), err)
276 }
277 t.ID = e.Name()
278 t.dir = dir
279 if t.TimeoutSec == 0 {
280 t.TimeoutSec = 240
281 }
282 tasks = append(tasks, t)
283 }
284 sort.Slice(tasks, func(i, j int) bool { return tasks[i].ID < tasks[j].ID })
285 return tasks, nil
286 }
287
288 // runTask copies the task's seed workdir into a temp dir, runs the agent there,
289 // then drops in verify.sh and runs it as the grader. The grader is added only
290 // after the run so the agent can't read the answer key.
291 func runTask(bin, model, profile string, arm ablation.Set, t task) result {
292 r := result{task: t, Profile: profile}
293 r.Arm = arm.Arm()
294
295 work, err := os.MkdirTemp("", "e2ebench-"+t.ID+"-")
296 if err != nil {
297 r.Note = "mktemp: " + err.Error()
298 return r
299 }
300 defer os.RemoveAll(work)
301
302 if seed := filepath.Join(t.dir, "workdir"); dirExists(seed) {
303 if err := copyDir(seed, work); err != nil {
304 r.Note = "copy seed: " + err.Error()
305 return r
306 }
307 }
308
309 ctx, cancel := context.WithTimeout(context.Background(), time.Duration(t.TimeoutSec)*time.Second)
310 defer cancel()
311
312 metricsPath := filepath.Join(work, ".run-metrics.json")
313 args := buildRunTaskArgs(metricsPath, model, profile, arm, t.MaxSteps, t.Prompt)
314
315 cmd := exec.CommandContext(ctx, bin, args...)
316 cmd.Dir = work
317 cmd.Stdout = os.Stderr // stream the run to the job log, keep stdout clean for the report
318 cmd.Stderr = os.Stderr
319 cmd.WaitDelay = 10 * time.Second // bound the wait for a stuck child after ctx timeout
320 startedAt := time.Now()
321 runErr := cmd.Run()
322 r.WallMs = time.Since(startedAt).Milliseconds()
323
324 if m, err := readMetrics(metricsPath); err == nil {
325 r.runMetrics = m
326 }
327 // A killed child never writes metrics, so the deadline is the only place
328 // this failure mode is still observable.
329 if ctx.Err() == context.DeadlineExceeded {
330 r.Outcome = "timeout"
331 }
332 if runErr != nil {
333 r.Note = "run: " + runErr.Error()
334 // still grade — a non-zero exit may just be a max-steps notice
335 }
336
337 r.Passed = grade(work, t.dir)
338 return r
339 }
340
341 func buildRunTaskArgs(metricsPath, model, profile string, arm ablation.Set, maxSteps int, prompt string) []string {
342 // Benchmarks are unattended and their fixtures require ordinary workspace
343 // writes. Auto still honors explicit ask/deny rules and the sandbox boundary.
344 args := []string{"run", "--auto", "--metrics", metricsPath}
345 if model != "" {
346 args = append(args, "--model", model)
347 }
348 if maxSteps > 0 {
349 args = append(args, "--max-steps", fmt.Sprint(maxSteps))
350 }
351 args = appendBenchmarkProfileArgs(args, profile)
352 // The control arm must produce a byte-identical command line to the one the
353 // suite ran before ablation existed, so its numbers stay comparable.
354 if !arm.Empty() {
355 args = append(args, "--ablate", arm.String())
356 }
357 return append(args, prompt)
358 }
359
360 func grade(work, taskDir string) bool {
361 verify := filepath.Join(taskDir, "verify.sh")
362 if !fileExists(verify) {
363 return false
364 }
365 dst := filepath.Join(work, "verify.sh")
366 if err := copyFile(verify, dst); err != nil {
367 return false
368 }
369 cmd := exec.Command("bash", "verify.sh")
370 cmd.Dir = work
371 cmd.Stdout = os.Stderr
372 cmd.Stderr = os.Stderr
373 return cmd.Run() == nil
374 }
375
376 func render(results []result) string {
377 profile := benchmarkProfileBaseline
378 arm := "full"
379 if len(results) > 0 {
380 if results[0].Profile != "" {
381 profile = results[0].Profile
382 }
383 if results[0].Arm != "" {
384 arm = results[0].Arm
385 }
386 }
387 return fmt.Sprintf("## 🤖 Reasonix e2e benchmark (%s · arm `%s`)\n\n", profile, arm) + renderBody(results)
388 }
389
390 // renderBody is the report without a heading, so a caller that supplies its own
391 // (SWE-bench mode) does not stack two titles.
392 func renderBody(results []result) string {
393 var b strings.Builder
394 passed, ran := 0, 0
395 accounted, accountedSolved, unaccounted, unaccountedSolved, partial := 0, 0, 0, 0, 0
396 var pTok, cTok, hit, miss, compacts, tools, toolFails int
397 var cost float64
398 var walls []int64
399 currency := ""
400 classes := map[string]int{}
401 prefixChangeReasons := map[string]int{}
402 for _, r := range results {
403 if r.Skipped {
404 continue
405 }
406 ran++
407 if r.Passed {
408 passed++
409 }
410 classes[r.class()]++
411 walls = append(walls, r.WallMs)
412 if r.Unaccounted {
413 unaccounted++
414 if r.Passed {
415 unaccountedSolved++
416 }
417 continue
418 }
419 accounted++
420 if r.Passed {
421 accountedSolved++
422 }
423 if r.Partial {
424 partial++
425 }
426 pTok += r.PromptTokens
427 cTok += r.CompletionTokens
428 hit += r.CacheHitTokens
429 miss += r.CacheMissTokens
430 compacts += r.Compactions
431 tools += r.ToolCalls
432 toolFails += r.ToolFailures
433 cost += r.Cost
434 if r.Currency != "" {
435 currency = r.Currency
436 }
437 for reason, n := range r.PrefixChangeReasonCounts {
438 prefixChangeReasons[reason] += n
439 }
440 }
441
442 // Cost and tokens are divided by the solved instances we actually have
443 // accounting for. Dividing by every solve would treat a lost metrics file as
444 // a free solve and understate the published figure.
445 fmt.Fprintf(&b, "**Solved:** %d/%d (%s) · **Cost per solved:** %s · **Tokens per solved:** %s · **Median wall time:** %s\n\n",
446 passed, ran, pct(passed, ran),
447 costPerSolved(cost, accountedSolved, currency), tokensPerSolved(pTok+cTok, accountedSolved), dur(median(walls)))
448 fmt.Fprintf(&b, "**Cache hit:** %s · **Tokens:** %s (prompt %s / completion %s) · **Tool calls:** %s (%s failed) · **Compactions:** %d · **Cost:** %s%.4f\n\n",
449 pct(hit, hit+miss), comma(pTok+cTok), comma(pTok), comma(cTok),
450 comma(tools), comma(toolFails), compacts, currencySym(currency), cost)
451 if unaccounted > 0 {
452 fmt.Fprintf(&b, "> **Accounting incomplete for %d of %d instances** (%d of them solved): the agent was killed before it wrote any metrics, so their cost and tokens are unknown. Totals above cover the %d accounted instances only, and per-solved figures divide by the %d accounted solves — the true totals are higher.\n\n",
453 unaccounted, ran, unaccountedSolved, accounted, accountedSolved)
454 }
455 if partial > 0 {
456 fmt.Fprintf(&b, "> **%d of %d instances contributed partial accounting**: the agent was killed mid-run and its numbers were recovered from the last in-flight snapshot. What is counted is real but stops at that snapshot, so every total above is a lower bound.\n\n",
457 partial, ran)
458 }
459
460 fmt.Fprintf(&b, "| Task | Result | Class | Steps | Tools | Time | Prompt | Completion | Cache hit | Cost |\n")
461 fmt.Fprintf(&b, "|------|--------|-------|------:|------:|-----:|-------:|-----------:|----------:|-----:|\n")
462 for _, r := range results {
463 switch {
464 case r.Skipped:
465 fmt.Fprintf(&b, "| `%s` | ⏭️ skipped | — | — | — | — | — | — | — | — |\n", r.ID)
466 default:
467 res := "❌ fail"
468 if r.Passed {
469 res = "✅ pass"
470 }
471 fmt.Fprintf(&b, "| `%s` | %s | %s | %d | %d | %s | %s | %s | %s | %s%.4f |\n",
472 r.ID, res, r.class(), r.Steps, r.ToolCalls, dur(r.WallMs),
473 comma(r.PromptTokens), comma(r.CompletionTokens),
474 pct(r.CacheHitTokens, r.CacheHitTokens+r.CacheMissTokens),
475 currencySym(r.Currency), r.Cost)
476 }
477 }
478 fmt.Fprintf(&b, "\n<sub>Real provider run. Cache-hit %% is cached prompt tokens / total prompt tokens. Wall time is measured by the harness and includes process startup.</sub>\n")
479
480 if breakdown := failureBreakdown(classes); breakdown != "" {
481 fmt.Fprintf(&b, "\n**Failures by class:** %s\n", breakdown)
482 }
483 if breakdown := reasonBreakdown(prefixChangeReasons); breakdown != "" {
484 fmt.Fprintf(&b, "\n**Cache resets by cause:** %s\n", breakdown)
485 }
486
487 notes := false
488 for _, r := range results {
489 if r.Note != "" {
490 if !notes {
491 fmt.Fprintf(&b, "\n<details><summary>Notes</summary>\n\n")
492 notes = true
493 }
494 fmt.Fprintf(&b, "- `%s`: %s\n", r.ID, r.Note)
495 }
496 }
497 if notes {
498 fmt.Fprintf(&b, "\n</details>\n")
499 }
500 return b.String()
501 }
502
503 func pct(n, d int) string {
504 if d == 0 {
505 return "n/a"
506 }
507 return fmt.Sprintf("%.0f%%", 100*float64(n)/float64(d))
508 }
509
510 func costPerSolved(cost float64, solved int, currency string) string {
511 if solved == 0 {
512 return "n/a"
513 }
514 return fmt.Sprintf("%s%.4f", currencySym(currency), cost/float64(solved))
515 }
516
517 func tokensPerSolved(tokens, solved int) string {
518 if solved == 0 {
519 return "n/a"
520 }
521 return comma(tokens / solved)
522 }
523
524 func median(ms []int64) int64 {
525 if len(ms) == 0 {
526 return 0
527 }
528 sorted := append([]int64(nil), ms...)
529 sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
530 return sorted[len(sorted)/2]
531 }
532
533 func dur(ms int64) string {
534 if ms <= 0 {
535 return "—"
536 }
537 d := time.Duration(ms) * time.Millisecond
538 if d < time.Minute {
539 return fmt.Sprintf("%.1fs", d.Seconds())
540 }
541 return fmt.Sprintf("%dm%02ds", int(d.Minutes()), int(d.Seconds())%60)
542 }
543
544 func failureBreakdown(classes map[string]int) string {
545 names := make([]string, 0, len(classes))
546 for name := range classes {
547 if name != "solved" {
548 names = append(names, name)
549 }
550 }
551 if len(names) == 0 {
552 return ""
553 }
554 sort.Strings(names)
555 parts := make([]string, 0, len(names))
556 for _, name := range names {
557 parts = append(parts, fmt.Sprintf("%s ×%d", name, classes[name]))
558 }
559 return strings.Join(parts, " · ")
560 }
561
562 // reasonBreakdown renders cache-prefix-change reason counts (compact_auto,
563 // snip, prune, tools, ...) the same way failureBreakdown renders failure
564 // classes, so a hit-rate regression in a PR shows which operation caused it.
565 func reasonBreakdown(reasons map[string]int) string {
566 names := make([]string, 0, len(reasons))
567 for name := range reasons {
568 names = append(names, name)
569 }
570 if len(names) == 0 {
571 return ""
572 }
573 sort.Strings(names)
574 parts := make([]string, 0, len(names))
575 for _, name := range names {
576 parts = append(parts, fmt.Sprintf("%s ×%d", name, reasons[name]))
577 }
578 return strings.Join(parts, " · ")
579 }
580
581 func comma(n int) string {
582 s := fmt.Sprint(n)
583 if len(s) <= 3 {
584 return s
585 }
586 var out []byte
587 for i, c := range []byte(s) {
588 if i > 0 && (len(s)-i)%3 == 0 {
589 out = append(out, ',')
590 }
591 out = append(out, c)
592 }
593 return string(out)
594 }
595
596 func currencySym(c string) string {
597 if c == "" {
598 return ""
599 }
600 return c + " "
601 }
602
603 func readMetrics(path string) (runMetrics, error) {
604 var m runMetrics
605 b, err := fileencoding.ReadFileUTF8(path)
606 if err != nil {
607 return m, err
608 }
609 return m, json.Unmarshal(b, &m)
610 }
611
612 func dirExists(p string) bool {
613 fi, err := os.Stat(p)
614 return err == nil && fi.IsDir()
615 }
616
617 func fileExists(p string) bool {
618 fi, err := os.Stat(p)
619 return err == nil && !fi.IsDir()
620 }
621
622 func copyDir(src, dst string) error {
623 return filepath.Walk(src, func(p string, info os.FileInfo, err error) error {
624 if err != nil {
625 return err
626 }
627 // Skip symlinks so a seed link can't leak a file from outside the seed tree.
628 if info.Mode()&os.ModeSymlink != 0 {
629 return nil
630 }
631 rel, _ := filepath.Rel(src, p)
632 target := filepath.Join(dst, rel)
633 if info.IsDir() {
634 return os.MkdirAll(target, 0o755)
635 }
636 return copyFile(p, target)
637 })
638 }
639
640 func copyFile(src, dst string) error {
641 if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
642 return err
643 }
644 in, err := os.Open(src)
645 if err != nil {
646 return err
647 }
648 defer in.Close()
649 info, err := in.Stat()
650 if err != nil {
651 return err
652 }
653 out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode().Perm())
654 if err != nil {
655 return err
656 }
657 defer out.Close()
658 if _, err := io.Copy(out, in); err != nil {
659 return err
660 }
661 // Mirror the source mode so a seed's read-only / exec bit survives the copy.
662 return os.Chmod(dst, info.Mode().Perm())
663 }
664
664 lines GO