mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-13 04:31:04 -10:00
* 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
651 lines
22 KiB
Go
651 lines
22 KiB
Go
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"
|
||
}
|