Files
DeepSeek-Reasonix/cmd/e2ebench/diff.go
T
SivanCola 4f1cae1061 feat(runtime): replace execution modes with adaptive standard policy / 用自适应标准执行替代轻量·均衡·交付模式 (#8866)
* feat(runtime): replace execution modes with adaptive standard policy

Problem
Reasonix exposed Light/Balanced/Delivery (and legacy TokenMode) as a
user-selectable execution mode. That split the product into three
workflows, leaked mode labels into UI/ACP/CLI, and let low-risk work
pay Delivery ceremony or high-risk work skip closed-loop evidence.

Root cause
Planner route, verification, review, and completion were branched on
AgentPreset/TokenMode/deliveryProfile instead of task risk.

Fix
Derive one TaskPolicy per turn from intent, risk, structure, and user
constraints. Planning, verification, review, and evidence closure follow
that policy and may only ratchet upward. New UI/CLI/ACP surfaces no
longer offer an execution mode. Deprecated flags, slash commands, ACP
options, and Wails SetAgentPreset*/SetTokenMode* stay accepted for one
compatibility version as no-ops. New persisted compat fields pin
agentPreset=balanced and tokenMode=full. Isolated worktrees keep their
on-disk directory and no longer force Delivery.

Verification
- go test ./...
- cd desktop && go test .
- go test -race ./internal/control ./internal/acp ./internal/taskpolicy
- frontend tsc --noEmit plus Composer/isolated-worktree/send-failed tests

* fix(ci): restore desktop bindings and satisfy repolint

Problem
CI lint failed on comment, file-size, and complexity overruns. Desktop
jobs failed TypeScript because Wails still exports deprecated
SetAgentPreset*/SetTokenMode* and DeliveryWorktree aliases that
AppBindings no longer listed.

Root cause
The standard-execution change kept the one-version Go compatibility
methods bound, but the frontend drift check requires every generated
Wails method on AppBindings. The same change also grew several
already-baselined files past comment and size budgets.

Fix
Bind the deprecated Wails methods as no-op aliases in AppBindings and
the browser mock. Extract ExecutionPolicyBlock and boot test helpers,
split Derive helpers, and compress comments and tests so repolint stays
inside existing budgets.

Verification
- go run ./tools/repolint
- go test ./internal/taskpolicy ./internal/acp ./internal/boot ./internal/control ./internal/evidence ./internal/cli ./internal/capability ./internal/agent
- cd desktop && go test . -run 'TestLoadTabSessionProfile|TestSetTokenMode|TestSetAgentPreset|TestDeliveryWorktree|TestCreateDelivery|TestIsolated'
- desktop/frontend tsc --noEmit
- isolated-worktree and composer-profile frontend tests

* fix(ci): drop unused mode leftovers and preset UI assertion

Problem
After repolint went clean, golangci-lint failed on an ineffectual
ACP workMode assignment, an unused CLI footer helper, a desktop
token-mode snapshot helper, and a strings.Builder modernization
in boot tests. The desktop frontend job failed because the change
panel no longer renders a Balanced preset label.

Root cause
Execution-mode removal left unused compatibility helpers and an
assignment that was immediately overwritten with the pinned
compat value. The workspace-changes test still required the old
preset localization.

Fix
Pin ACP status WorkMode to balanced without reading the unused
runtime profile, delete the unused helpers, join the boot
transcript with strings.Builder, and assert only the localized
verdict on the change panel.

Verification
- go run ./tools/repolint
- golangci-lint run ./internal/acp ./internal/cli ./internal/boot
- go test ./internal/acp ./internal/cli ./internal/boot
- desktop go test -c
- pnpm exec tsx src/__tests__/workspace-changes-errors.test.tsx

* fix(runtime): close standard execution review gaps

Problem:
- Capability routing could run before TaskPolicy was frozen.
- Atomic edits could finish without a mutation, and later low-risk edits could hide earlier high-risk changes.
- Explicit full verification did not force full planning and review.
- Legacy execution modes remained discoverable in CLI, desktop styling, docs, telemetry, and benchmark axes.

Root cause:
- Policy metadata and evidence checks were evaluated at inconsistent points in the turn lifecycle.
- Compatibility controls were registered directly on public surfaces.
- Benchmark and documentation cleanup stopped short of the last profile-specific paths.

Fix:
- Freeze policy before routing, enforce atomic mutation readiness, and ratchet risk across the complete turn ledger.
- Promote explicit full verification to full planning and forced review.
- Hide one-version compatibility parsing behind non-discoverable adapters and remove dead UI, telemetry, documentation, and benchmark profile surfaces.
- Add regression coverage for routing, readiness, risk, compatibility help, and standard benchmark commands.

Verification:
- go test -p 4 -timeout=8m ./...
- go vet ./...
- go run ./tools/repolint
- ./scripts/cache-guard.sh
- go test -race on release-critical packages and repeated plugin refresh coverage
- desktop Go vet/tests, frontend pnpm test:all/build, and SDK Go vet/tests/race

* test(desktop): tolerate slow Windows autosave

Problem:
- The Windows desktop job could fail while the asynchronous turn snapshot was still completing.

Root cause:
- Autosave assertions used a two-second deadline, shorter than legitimate Windows antivirus and indexer delays observed in CI.

Fix:
- Use a shared ten-second autosave test deadline without changing production retry behavior.

Verification:
- Autosave persistence test passed 100 consecutive runs.
- Autosave persistence test passed 20 race-enabled runs.
- Full desktop Go test suite passed.

* fix(desktop): reconcile partial trash before restore

Problem: A committed topic archive can leave a sidecar at the live path when Windows temporarily blocks one step of the multi-artifact move. An immediate restore then reports the leftover sidecar as an unrelated conflict.

Root cause: RestoreSession preflighted destination artifacts without honoring the durable cleanup-pending marker used by committed archive recovery.

Fix: Reconcile pending desktop cleanup before restore conflict checks and retain the cleanup-in-progress response when reconciliation cannot finish. Add a deterministic partial-move regression test.

Verification: desktop go test ./...; desktop go vet ./...; targeted restore tests x100; targeted race tests x30; repolint; git diff --check.

* test(plugin): synchronize no-op refresh cycle

Problem:
The no-op tool-list refresh test could observe cycleDone after a fast refresh had already completed, causing the race job to report that the cycle never started.

Root cause:
The controlled transport did not block tools/list in this test, so the refresh goroutine could finish and clear the cycle handle before the test captured it.

Fix:
Block the controlled tools/list call, capture the active cycle, then release the refresh and assert its no-op behavior.

Verification:
go test -race ./internal/plugin -run '^TestToolListRefreshNoOpPreservesAdapterGeneration$' -count=1000
go test -race ./internal/plugin/...
go run ./tools/repolint
golangci-lint run --timeout=5m ./internal/plugin/...

* fix(runtime): preserve adaptive risk across absolute paths

Problem
Deep absolute mutation paths could lose sensitive owner directories during risk classification, allowing a stale low-risk policy to survive the first write. Verification allowlists also recognized only test commands, so vet, lint, and typecheck could bypass an explicit user restriction.

Root cause
Risk classification truncated absolute paths without workspace context, and parent, child, and background evidence did not share a workspace-aware classifier. The task-policy gate maintained a narrower verifier list than the evidence subsystem.

Fix
Normalize paths lexically against the owning workspace while preserving deep sensitive owners and conservative out-of-workspace behavior. Propagate host-only workspace context through subagents and background task/fleet evidence without serializing it. Reuse the canonical verification classifier in the pre-execution policy gate.

Verification
- go test ./internal/evidence ./internal/taskpolicy ./internal/agent ./internal/jobs ./internal/control
- go test -race ./internal/evidence ./internal/agent ./internal/jobs ./internal/control
- go vet ./internal/evidence ./internal/taskpolicy ./internal/agent ./internal/jobs ./internal/control
- go run ./tools/repolint
- git diff --check

* fix(ci): align latest base contracts

Problem:
The latest main-v2 reasoning display mode was not represented in the startup settings contract, and the new path helper tripped the modernize linter.

Root cause:
PR #8732 added the expanded reasoning option after this branch was created, while the path helper retained a manual membership loop.

Fix:
Require the expanded reasoning option and locale key in the startup contract, and use slices.Contains for exact risk-path segment checks.

Verification:
pnpm exec tsx src/__tests__/startup-settings-contract.test.ts
pnpm test:all
golangci-lint run --timeout=5m ./...
wails generate module
2026-08-15 00:19:50 +08:00

651 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"unicode/utf8"
"reasonix/internal/ablation"
"reasonix/internal/shellparse"
)
type diffOpts struct {
bin, model, repo, base, testCmd string
ablate ablation.Set
maxSteps, timeoutSec, attempts int
}
type testRef struct{ name, pkg string }
// pinResult records whether one generated test fails when the PR's source is
// reverted (so it pins the change) and, if so, whether it failed by assertion
// (strong: it checks the new behavior) or only by compile error (weak: it just
// references a symbol the PR added).
type pinResult struct {
testRef
pins bool
byAssertion bool
}
// runDiff asks the agent to write tests covering what the PR changed, grades
// them against the repo's own tests, and — because the agent is stochastic —
// retries up to o.attempts times until a run passes, keeping the best result.
func runDiff(o diffOpts) string {
srcFiles := changedGoFiles(o.repo, o.base, false)
if len(srcFiles) == 0 {
return "## 🤖 Reasonix e2e — diff test-gen\n\nNo Go source changes in this PR (excluding `_test.go`); nothing to generate tests for.\n"
}
pkgs := packagesOf(srcFiles)
prompt := buildDiffPrompt(srcFiles, pkgs, truncate(gitOut(o.repo, "diff", o.base+"...HEAD", "--")))
attempts := max(o.attempts, 1)
var best diffReport
made := 0
for i := 1; i <= attempts; i++ {
if i > 1 {
resetTree(o.repo)
}
r := runOnce(o, srcFiles, pkgs, prompt)
made = i
if i == 1 || better(r, best) {
best = r
}
if best.passed {
break // stop at the first passing run; attempts is a retry budget
}
}
best.attempt, best.attempts = made, attempts
return renderDiff(best)
}
// runOnce does one agent run + grade: generate tests, check they pass on HEAD,
// differential-check each against the reverted source, measure changed-line
// coverage, and confirm the agent didn't break the build anywhere.
func runOnce(o diffOpts, srcFiles, pkgs []string, prompt string) diffReport {
metricsPath := filepath.Join(o.repo, ".e2e-diff-metrics.json")
_ = os.Remove(metricsPath)
defer os.Remove(metricsPath)
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(o.timeoutSec)*time.Second)
defer cancel()
args := []string{"run", "--metrics", metricsPath, "--max-steps", fmt.Sprint(o.maxSteps)}
if o.model != "" {
args = append(args, "--model", o.model)
}
if !o.ablate.Empty() {
args = append(args, "--ablate", o.ablate.String())
}
args = append(args, prompt)
cmd := exec.CommandContext(ctx, o.bin, args...)
cmd.Dir = o.repo
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
cmd.WaitDelay = 10 * time.Second // bound the wait for a wedged child after ctx timeout
runErr := cmd.Run()
// The agent's new files are untracked, so `git diff HEAD` would miss them;
// intent-to-add surfaces them as additions without committing.
_ = exec.Command("git", "-C", o.repo, "add", "-AN").Run()
m, _ := readMetrics(metricsPath)
testDiff := gitOut(o.repo, "diff", "HEAD", "--", "*_test.go")
refs := parseNewTests(testDiff)
sourceTouched := len(changedGoFilesWorktree(o.repo, false))
testsPass, testOut := runTests(o.repo, o.testCmd, pkgs)
var pins []pinResult
var mut mutationResult
covered, coverTotal := 0, 0
if len(refs) > 0 && testsPass {
covered, coverTotal = changedLineCoverage(o.repo, o.base, pkgs, srcFiles)
pins = differentialPerTest(o.repo, o.base, srcFiles, refs)
mut = runMutation(o.repo, o.base, srcFiles, refs)
}
buildOK, buildOut := goBuildAll(o.repo)
passed := len(refs) > 0 && testsPass && buildOK && countPins(pins) > 0
return diffReport{
srcFiles: srcFiles, pkgs: pkgs, addedTestLines: countAdded(testDiff),
newTests: refs, sourceTouched: sourceTouched, testsPass: testsPass,
pins: pins, mut: mut, covered: covered, coverTotal: coverTotal,
buildOK: buildOK, buildOut: buildOut, failing: failingTestNames(testOut),
passed: passed, m: m, runErr: runErr, testOut: testOut, testDiff: testDiff,
}
}
// better reports whether candidate a is a stronger result than b: a pass beats a
// fail, then more assertion-pins, then more pins, then higher changed-line
// coverage.
func better(a, b diffReport) bool {
if a.passed != b.passed {
return a.passed
}
if x, y := countAssertionPins(a.pins), countAssertionPins(b.pins); x != y {
return x > y
}
if x, y := countPins(a.pins), countPins(b.pins); x != y {
return x > y
}
if a.mut.caught != b.mut.caught {
return a.mut.caught > b.mut.caught
}
return ratio(a.covered, a.coverTotal) > ratio(b.covered, b.coverTotal)
}
func ratio(n, d int) float64 {
if d == 0 {
return 0
}
return float64(n) / float64(d)
}
// resetTree restores the PR-head tree between attempts, dropping the previous
// attempt's generated tests but keeping the provider config the workflow wrote.
func resetTree(repo string) {
_ = exec.Command("git", "-C", repo, "checkout", "--", ".").Run()
_ = exec.Command("git", "-C", repo, "clean", "-fd", "-e", "reasonix.toml").Run()
}
func goBuildAll(repo string) (bool, string) {
cmd := exec.Command("go", "build", "./...")
cmd.Dir = repo
cmd.WaitDelay = 2 * time.Minute // bound the wait if `go build` hangs
out, err := cmd.CombinedOutput()
return err == nil, string(out)
}
func buildDiffPrompt(srcFiles, pkgs []string, diffText string) string {
var b strings.Builder
b.WriteString("You are in a Go repository. This pull request changed these source files:\n")
for _, f := range srcFiles {
fmt.Fprintf(&b, " - %s\n", f)
}
b.WriteString("\nUnified diff of the change:\n```diff\n")
b.WriteString(diffText)
b.WriteString("\n```\n\n")
b.WriteString("Write focused Go unit tests that exercise the NEW or CHANGED behavior in those files. ")
b.WriteString("Add them to the appropriate *_test.go files in the same packages (")
b.WriteString(strings.Join(pkgs, ", "))
b.WriteString("). Do NOT modify the non-test source files — only add or extend test files. ")
b.WriteString("Prefer small, focused edits and run `gofmt`/`go vet` on the test files as you go to avoid syntax errors. ")
b.WriteString("Then run the package tests and iterate until they pass. When finished, list the test functions you added.")
return b.String()
}
type diffReport struct {
srcFiles, pkgs []string
addedTestLines int
newTests []testRef
sourceTouched int
testsPass bool
pins []pinResult
mut mutationResult
covered, coverTotal int
buildOK bool
buildOut string
failing []string
passed bool
attempt, attempts int
m runMetrics
runErr error
testOut string
testDiff string
}
func renderDiff(r diffReport) string {
var b strings.Builder
result := "❌ fail"
if r.passed {
result = "✅ pass"
}
fmt.Fprint(&b, "## 🤖 Reasonix e2e — diff test-gen\n\n")
fmt.Fprintf(&b, "**Result:** %s · **%d** changed source file(s) across **%d** package(s)\n\n", result, len(r.srcFiles), len(r.pkgs))
pinned, byAssert := countPins(r.pins), countAssertionPins(r.pins)
fmt.Fprintf(&b, "| Metric | Value |\n|---|---|\n")
fmt.Fprintf(&b, "| New test functions added | %d |\n", len(r.newTests))
fmt.Fprintf(&b, "| Test lines added | +%d |\n", r.addedTestLines)
fmt.Fprintf(&b, "| `go test` on affected pkgs | %s |\n", passFail(r.testsPass))
fmt.Fprintf(&b, "| Differential (fail on pre-PR code) | %s |\n", differentialCell(r))
if pinned > 0 {
fmt.Fprintf(&b, "| ↳ pin by assertion / by compile only | %d / %d |\n", byAssert, pinned-byAssert)
}
fmt.Fprintf(&b, "| Changed-line coverage | %s |\n", coverageCell(r))
fmt.Fprintf(&b, "| Mutation (changed funcs caught) | %s |\n", mutationCell(r))
fmt.Fprintf(&b, "| `go build ./...` (regression) | %s |\n", passFail(r.buildOK))
fmt.Fprintf(&b, "| Non-test source touched by agent | %d file(s) |\n", r.sourceTouched)
fmt.Fprintf(&b, "| Cache hit | %s |\n", pct(r.m.CacheHitTokens, r.m.CacheHitTokens+r.m.CacheMissTokens))
fmt.Fprintf(&b, "| Tokens (prompt / completion) | %s / %s |\n", comma(r.m.PromptTokens), comma(r.m.CompletionTokens))
fmt.Fprintf(&b, "| Model calls | %d |\n", r.m.Steps)
fmt.Fprintf(&b, "| Cost | %s%.4f |\n", currencySym(r.m.Currency), r.m.Cost)
if r.m.CapabilityRoutes > 0 || r.m.CapabilitySkillInvocations > 0 || r.m.CapabilityMCPCall > 0 || r.m.ReadinessChecks > 0 {
fmt.Fprintf(&b, "| Capability routes (semantic) | %d (%d) |\n", r.m.CapabilityRoutes, r.m.CapabilitySemanticRoutes)
fmt.Fprintf(&b, "| Routed candidates (require / prefer / suggest / declined) | %d (%d / %d / %d / %d) |\n", r.m.CapabilityRoutedCandidates, r.m.CapabilityRoutedRequire, r.m.CapabilityRoutedPrefer, r.m.CapabilityRoutedSuggest, r.m.CapabilityDeclines)
fmt.Fprintf(&b, "| Skill invocations / MCP proxy calls | %d / %d |\n", r.m.CapabilitySkillInvocations, r.m.CapabilityMCPCall)
fmt.Fprintf(&b, "| Review blocks / readiness recoveries | %d / %d |\n", r.m.CapabilityReviewBlocks, r.m.ReadinessRecoveries)
if r.m.CapabilityRouterCost > 0 || r.m.CapabilityRouterLatencyMs > 0 {
fmt.Fprintf(&b, "| Capability-router cost / latency | %s%.4f / %dms |\n", currencySym(r.m.Currency), r.m.CapabilityRouterCost, r.m.CapabilityRouterLatencyMs)
}
}
if len(r.failing) > 0 {
fmt.Fprintf(&b, "| Failing tests | `%s` |\n", strings.Join(r.failing, "`, `"))
}
if r.attempts > 1 {
status := "none passed"
if r.passed {
status = "passed"
}
fmt.Fprintf(&b, "| Attempts | %d of up to %d (%s) |\n", r.attempt, r.attempts, status)
}
fmt.Fprintf(&b, "\n**Packages:** %s\n", strings.Join(r.pkgs, ", "))
if r.attempts <= 1 {
fmt.Fprintf(&b, "\n<sub>Single stochastic run — a green result is one sample, not a guarantee. Comment `/e2e diff x3` to retry up to 3×.</sub>\n")
}
if !r.buildOK && strings.TrimSpace(r.buildOut) != "" {
fmt.Fprintf(&b, "\n<details><summary>go build ./... output (tail)</summary>\n\n```\n%s\n```\n</details>\n", tail(r.buildOut, 40))
}
if r.sourceTouched > 0 {
fmt.Fprintf(&b, "\n⚠️ The agent modified %d non-test source file(s); a green run may not reflect the PR's code. Review the diff.\n", r.sourceTouched)
}
if len(r.pins) > 0 {
fmt.Fprintf(&b, "\n<details><summary>Per-test differential</summary>\n\n| Test | Package | Pins the change? |\n|---|---|---|\n")
for _, p := range r.pins {
fmt.Fprintf(&b, "| `%s` | %s | %s |\n", p.name, p.pkg, pinCell(p))
}
fmt.Fprintf(&b, "\n</details>\n")
}
if strings.TrimSpace(r.testDiff) != "" {
fmt.Fprintf(&b, "\n<details><summary>Generated tests (review the assertions)</summary>\n\n```diff\n%s\n```\n</details>\n", truncateFor(r.testDiff, 20000))
}
if !r.testsPass && strings.TrimSpace(r.testOut) != "" {
fmt.Fprintf(&b, "\n<details><summary>go test output (tail)</summary>\n\n```\n%s\n```\n</details>\n", tail(r.testOut, 60))
}
if r.runErr != nil {
fmt.Fprintf(&b, "\n<sub>agent run note: %v</sub>\n", r.runErr)
}
fmt.Fprintf(&b, "\n<sub>Pass = the agent added ≥1 test, the affected packages are green, AND ≥1 new test fails when the PR's source is reverted. \"By assertion\" pins are strong (they check changed behavior); \"by compile only\" pins just need a PR-added symbol — and since Go compiles per package, one compile-coupled test marks every test in its package that way. Mutation is the behavioral signal for additive PRs: each changed function's return is replaced with zero values and the new tests are re-run; \"caught\" means a test asserts that output, \"survived\" means it doesn't. Read the generated tests above to judge the rest.</sub>\n")
return b.String()
}
func differentialCell(r diffReport) string {
if !(len(r.newTests) > 0 && r.testsPass) {
return "n/a (tests not green)"
}
return fmt.Sprintf("%d/%d new tests", countPins(r.pins), len(r.pins))
}
func coverageCell(r diffReport) string {
if r.coverTotal == 0 {
return "n/a"
}
return fmt.Sprintf("%s (%d/%d changed lines)", pct(r.covered, r.coverTotal), r.covered, r.coverTotal)
}
func mutationCell(r diffReport) string {
if r.mut.total == 0 {
return "n/a"
}
cell := fmt.Sprintf("%d/%d (%s)", r.mut.caught, r.mut.total, pct(r.mut.caught, r.mut.total))
if len(r.mut.survivors) > 0 {
cell += fmt.Sprintf(" · survived: `%s`", strings.Join(r.mut.survivors, "`, `"))
}
return cell
}
func pinCell(p pinResult) string {
switch {
case p.pins && p.byAssertion:
return "✅ by assertion"
case p.pins:
return "⚠️ by compile only"
default:
return "❌ no (passes on old code)"
}
}
// differentialPerTest reverts the PR's changed source to base (deleting files
// new in the PR), runs each generated test on its own against the old code, and
// restores the source. A test that fails on the old code pins the change.
func differentialPerTest(repo, base string, srcFiles []string, refs []testRef) []pinResult {
for _, f := range srcFiles {
if err := exec.Command("git", "-C", repo, "checkout", base, "--", f).Run(); err != nil {
_ = os.Remove(filepath.Join(repo, filepath.FromSlash(f)))
}
}
// Restore source even on panic; a tree left on `base` would mask the PR for later steps.
restored := false
defer func() {
if restored {
return
}
for _, f := range srcFiles {
_ = exec.Command("git", "-C", repo, "checkout", "HEAD", "--", f).Run()
}
}()
out := make([]pinResult, 0, len(refs))
for _, r := range refs {
cmd := exec.Command("go", "test", "-run", "^"+r.name+"$", r.pkg)
cmd.Dir = repo
cmd.WaitDelay = 2 * time.Minute // bound the wait for a hung test
raw, err := cmd.CombinedOutput()
out = append(out, pinResult{
testRef: r,
pins: err != nil,
byAssertion: strings.Contains(string(raw), "--- FAIL: "+r.name),
})
}
for _, f := range srcFiles {
_ = exec.Command("git", "-C", repo, "checkout", "HEAD", "--", f).Run()
}
restored = true
return out
}
// changedLineCoverage runs the affected packages with a coverage profile and
// reports how many of the PR's changed source statement-lines the (new+existing)
// tests actually execute. covered/total are over changed lines that fall inside
// a coverage block; lines that aren't statements are ignored.
func changedLineCoverage(repo, base string, pkgs, srcFiles []string) (covered, total int) {
profile := filepath.Join(repo, ".e2e-cover.out")
defer os.Remove(profile)
args := append([]string{"test", "-covermode=set", "-coverprofile=" + profile, "-coverpkg=" + strings.Join(pkgs, ",")}, pkgs...)
cmd := exec.Command("go", args...)
cmd.Dir = repo
_ = cmd.Run() // a non-zero exit still writes the profile for the tests that ran
blocks := parseCoverProfile(repo, profile)
for file, lines := range changedLineSet(repo, base, srcFiles) {
fileBlocks := blocks[file]
for ln := range lines {
for _, blk := range fileBlocks {
if ln >= blk.start && ln <= blk.end {
total++
if blk.count > 0 {
covered++
}
break
}
}
}
}
return covered, total
}
type coverBlock struct {
start, end, count int
}
// parseCoverProfile reads a Go coverage profile, keyed by repo-relative file path
// (the profile uses module-qualified paths; we match by repo-relative suffix).
func parseCoverProfile(repo, path string) map[string][]coverBlock {
data, err := os.ReadFile(path)
if err != nil {
return nil
}
out := map[string][]coverBlock{}
for ln := range strings.SplitSeq(string(data), "\n") {
if ln == "" || strings.HasPrefix(ln, "mode:") {
continue
}
colon := strings.LastIndexByte(ln, ':')
if colon < 0 {
continue
}
modPath, rest := ln[:colon], ln[colon+1:]
var sl, sc, el, ec, nstmt, count int
if _, err := fmt.Sscanf(rest, "%d.%d,%d.%d %d %d", &sl, &sc, &el, &ec, &nstmt, &count); err != nil {
continue
}
rel := repoRelFromModulePath(modPath)
out[rel] = append(out[rel], coverBlock{start: sl, end: el, count: count})
}
return out
}
// repoRelFromModulePath turns "reasonix/internal/agent/foo.go" into
// "internal/agent/foo.go" by dropping the first path element (the module root).
func repoRelFromModulePath(p string) string {
// Strip the full module prefix; a generic first-segment cut mis-strips a multi-segment module path.
prefix := "reasonix/"
if strings.HasPrefix(p, prefix) {
return p[len(prefix):]
}
if _, after, ok := strings.Cut(p, "/"); ok {
return after
}
return p
}
// changedLineSet returns, per repo-relative source file, the set of new line
// numbers the PR added or changed (from a zero-context diff).
func changedLineSet(repo, base string, srcFiles []string) map[string]map[int]bool {
args := append([]string{"diff", "--unified=0", base + "...HEAD", "--"}, srcFiles...)
diff := gitOut(repo, args...)
out := map[string]map[int]bool{}
file := ""
newLine := 0
for ln := range strings.SplitSeq(diff, "\n") {
// '-' (deletion) lines are intentionally unhandled: they don't advance the
// new-side line counter, so they fall through with no case.
switch {
case strings.HasPrefix(ln, "+++ b/"):
file = strings.TrimPrefix(ln, "+++ b/")
out[file] = map[int]bool{}
case strings.HasPrefix(ln, "@@"):
// @@ -a,b +c,d @@ — start collecting at new-side line c.
// Digit-only cut: malformed headers (e.g. `@@ +abc @@`) fail closed.
if _, after, ok := strings.Cut(ln, "+"); ok {
num := after
end := len(num)
for i := range len(num) {
if num[i] < '0' || num[i] > '9' {
end = i
break
}
}
_, _ = fmt.Sscanf(num[:end], "%d", &newLine)
}
case strings.HasPrefix(ln, "+") && !strings.HasPrefix(ln, "+++"):
if file != "" {
out[file][newLine] = true
}
newLine++
}
}
return out
}
func countPins(ps []pinResult) int {
n := 0
for _, p := range ps {
if p.pins {
n++
}
}
return n
}
func countAssertionPins(ps []pinResult) int {
n := 0
for _, p := range ps {
if p.pins && p.byAssertion {
n++
}
}
return n
}
// parseNewTests reads the working-tree *_test.go diff and returns the Test/Fuzz/
// Benchmark functions the agent added, each tagged with its package directory.
func parseNewTests(diff string) []testRef {
var refs []testRef
pkg := ""
for ln := range strings.SplitSeq(diff, "\n") {
if after, ok := strings.CutPrefix(ln, "+++ b/"); ok {
pkg = "./" + filepath.ToSlash(filepath.Dir(after))
continue
}
if !strings.HasPrefix(ln, "+") || strings.HasPrefix(ln, "+++") {
continue
}
body := strings.TrimSpace(ln[1:])
if !strings.HasPrefix(body, "func ") {
continue
}
sig := strings.TrimPrefix(body, "func ")
// Method form `(r T) Name(...)` starts with '('; parse the receiver out before the name.
var name string
if sig[0] == '(' {
_, after, ok := strings.Cut(sig, ")")
if !ok {
continue
}
rest := strings.TrimSpace(after)
methodParen := strings.IndexByte(rest, '(')
if methodParen <= 0 {
continue
}
name = rest[:methodParen]
} else {
funcParen := strings.IndexByte(sig, '(')
if funcParen <= 0 {
continue
}
name = sig[:funcParen]
}
if strings.HasPrefix(name, "Test") || strings.HasPrefix(name, "Fuzz") || strings.HasPrefix(name, "Benchmark") {
refs = append(refs, testRef{name: name, pkg: pkg})
}
}
return refs
}
func countAdded(diff string) int {
n := 0
for ln := range strings.SplitSeq(diff, "\n") {
if strings.HasPrefix(ln, "+") && !strings.HasPrefix(ln, "+++") {
n++
}
}
return n
}
// failingTestNames pulls the names out of `--- FAIL: TestX (…)` lines.
func failingTestNames(out string) []string {
var names []string
seen := map[string]bool{}
for ln := range strings.SplitSeq(out, "\n") {
ln = strings.TrimSpace(ln)
if !strings.HasPrefix(ln, "--- FAIL:") {
continue
}
rest := strings.Fields(strings.TrimSpace(strings.TrimPrefix(ln, "--- FAIL:")))
if len(rest) > 0 && !seen[rest[0]] {
seen[rest[0]] = true
names = append(names, rest[0])
}
}
return names
}
func runTests(repo, testCmd string, pkgs []string) (bool, string) {
test, err := shellparse.ParseStaticCommand(testCmd, shellparse.StaticCommandPolicy{AllowEnvAssignments: true, AllowStderrToStdout: true})
if err != nil {
return false, "invalid test command: " + err.Error()
}
fields := test.Argv
if len(fields) == 0 {
fields = []string{"go", "test"}
}
args := append(fields[1:], pkgs...)
cmd := exec.Command(fields[0], args...)
cmd.Dir = repo
if len(test.Env) > 0 {
cmd.Env = append(os.Environ(), test.Env...)
}
cmd.WaitDelay = 5 * time.Minute // bound the wait if `go test` hangs
out, err := cmd.CombinedOutput()
return err == nil, string(out)
}
// changedGoFiles lists .go files changed by base...HEAD, excluding *_test.go
// when includeTests is false (we want the source under test).
func changedGoFiles(repo, base string, includeTests bool) []string {
return filterGo(gitOut(repo, "diff", "--name-only", base+"...HEAD", "--", "*.go"), includeTests)
}
func changedGoFilesWorktree(repo string, includeTests bool) []string {
return filterGo(gitOut(repo, "diff", "--name-only", "HEAD", "--", "*.go"), includeTests)
}
func filterGo(out string, includeTests bool) []string {
var keep []string
for f := range strings.FieldsSeq(strings.ReplaceAll(out, "\n", " ")) {
if strings.HasSuffix(f, "_test.go") && !includeTests {
continue
}
keep = append(keep, f)
}
sort.Strings(keep)
return keep
}
func packagesOf(files []string) []string {
seen := map[string]bool{}
var pkgs []string
for _, f := range files {
dir := "./" + filepath.ToSlash(filepath.Dir(f))
if !seen[dir] {
seen[dir] = true
pkgs = append(pkgs, dir)
}
}
sort.Strings(pkgs)
return pkgs
}
func gitOut(repo string, args ...string) string {
cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
out, _ := cmd.Output()
return string(out)
}
func truncate(s string) string { return truncateFor(s, 12000) }
func truncateFor(s string, max int) string {
if max <= 0 || len(s) <= max {
return s
}
// Back the cut up to a rune boundary so we don't split a multi-byte UTF-8 rune.
cut := max
for cut > 0 && !utf8.RuneStart(s[cut]) {
cut--
}
return s[:cut] + "\n…(truncated)…"
}
func tail(s string, n int) string {
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
if len(lines) > n {
lines = lines[len(lines)-n:]
}
return strings.Join(lines, "\n")
}
func passFail(ok bool) string {
if ok {
return "pass"
}
return "fail"
}