diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8064425..2950adc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,3 +29,24 @@ jobs: - uses: actions/checkout@v4 - name: Build Docker Image run: docker build -t pr-af:test . + + go: + runs-on: ubuntu-latest + defaults: + run: + working-directory: go + steps: + - uses: actions/checkout@v4 + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: "1.21" + cache-dependency-path: go/go.sum + - name: Build + run: go build ./... + - name: Vet + run: go vet ./... + - name: Test + run: go test ./... + - name: Gofmt + run: test -z "$(gofmt -l .)" diff --git a/README.md b/README.md index fcc1011..9662b2b 100644 --- a/README.md +++ b/README.md @@ -317,3 +317,15 @@ When the writer and the reviewer are the same intelligence, the pull request gat

[Read the post →](https://www.agentfield.ai/blog/ai-native-code-review?utm_source=github-readme&utm_campaign=pr-af-readme&utm_id=pr-af-readme-blog-ai-native-code-review) + +--- + +## Go implementation (opt-in) + +This repo also ships a Go port of the node under [`go/`](go/README.md). The +**Python implementation is the default** — everything above is unchanged and +still runs as `pr-af` (`:8004`). The Go port registers **separately** as +`pr-af-go` (`:8007`), so both can run against one control plane simultaneously. +Opt in by targeting the `-go` reasoner path, e.g. +`POST /api/v1/execute/async/pr-af-go.review`. Build, run, and Docker/compose +docs live in [`go/README.md`](go/README.md). diff --git a/docker-compose.go.yml b/docker-compose.go.yml new file mode 100644 index 0000000..b282d61 --- /dev/null +++ b/docker-compose.go.yml @@ -0,0 +1,76 @@ +# PR-AF Go node — opt-in ADD-ON to the Python stack. +# +# The Python docker-compose.yml is the DEFAULT stack (the AgentField control +# plane `agentfield` + the Python `pr-af` node on :8004) and is left 100% +# untouched. This file adds ONLY the Go node, registered under a DISTINCT +# identity so both nodes can run against one control plane simultaneously: +# +# pr-af-go -> node id "pr-af-go", :8007 +# +# Run story (two commands, Python stack first): +# +# docker compose up -d # Python stack + control plane +# docker compose -f docker-compose.go.yml up -d # adds the Go node +# +# This is a SEPARATE compose project (name: pr-af-go) that joins the Python +# stack's network as an EXTERNAL reference, so AGENTFIELD_SERVER= +# http://agentfield:8080 resolves and the Go node shares the Python stack's +# workspaces volume over that network. The control plane (service `agentfield`) +# lives in the Python project, so there is NO `depends_on` here — bring the +# Python stack up first. +# +# COMPOSE_PROJECT_NAME caveat: the external network/volume names below +# (pr-af_default, pr-af_pr-af-workspaces) are the Python project's +# default-project-name resources. The Python docker-compose.yml has NO explicit +# `name:`, so its project name defaults to the compose directory's basename — +# `pr-af` when the repo is checked out as a directory named `pr-af`. If you set +# COMPOSE_PROJECT_NAME for the Python stack (or the checkout directory is named +# something else), override the external `name:` fields below to match +# `_default` and `_pr-af-workspaces`. +name: pr-af-go + +services: + pr-af-go: + build: + context: . + dockerfile: go/Dockerfile + environment: + - AGENTFIELD_SERVER=http://agentfield:8080 # CP service name in pr-af's compose is "agentfield" + - AGENTFIELD_API_KEY=${AGENTFIELD_API_KEY:-} + - NODE_ID=pr-af-go + - PORT=8007 + - AGENT_CALLBACK_URL=http://pr-af-go:8007 + - PR_AF_PROVIDER=${PR_AF_PROVIDER:-opencode} + - PR_AF_MODEL=${PR_AF_MODEL:-openrouter/moonshotai/kimi-k2.5} + - OPENROUTER_API_KEY=${OPENROUTER_API_KEY} + - GH_TOKEN=${GH_TOKEN:-} + - XDG_DATA_HOME=/home/praf/.local/share + - PR_AF_WORKDIR=/workspaces + ports: + - "8007:8007" + volumes: + - workspaces:/workspaces + - opencode-data:/home/praf/.local/share + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8007/health"] + interval: 60s + timeout: 30s + retries: 5 + start_period: 30s + restart: unless-stopped + +# Join the Python stack's default network so `agentfield` (the control plane) +# resolves by service name. external => Compose does NOT create it; the Python +# stack must be up first (see COMPOSE_PROJECT_NAME caveat in the header). +networks: + default: + external: true + name: pr-af_default + +# Share the Python stack's workspaces volume (_ = +# pr-af_pr-af-workspaces); keep opencode data node-local. +volumes: + workspaces: + external: true + name: pr-af_pr-af-workspaces + opencode-data: {} diff --git a/go/.gitignore b/go/.gitignore new file mode 100644 index 0000000..cb351cd --- /dev/null +++ b/go/.gitignore @@ -0,0 +1,11 @@ +# Local Go workspace used during SDK-parity development to resolve the +# agentfield Go SDK to a local worktree. Must NOT be committed — CI/Docker rely +# on the real versioned require in go.mod (no replace directive) instead. +go.work +go.work.sum + +# Compiled binaries. +bin/ + +# stray single-package build artifact (go build ./test/mockcli) +/mockcli diff --git a/go/Dockerfile b/go/Dockerfile new file mode 100644 index 0000000..5b95646 --- /dev/null +++ b/go/Dockerfile @@ -0,0 +1,79 @@ +# PR-AF Go node — multi-stage build. +# +# Build from the PR-AF repo ROOT so the go/ module is in the build context and +# the paths below (go/go.mod, go/) resolve. The docker-compose.go.yml add-on +# builds it exactly this way (build.context: ., dockerfile: go/Dockerfile): +# +# docker build -f go/Dockerfile -t pr-af-go:latest . +# +# Unlike the SWE-AF Go port, the AgentField Go SDK is a REAL versioned require +# resolved from proxy.golang.org — so there is NO sparse SDK clone stage, no +# GOWORK=off, and no `replace` dance. `go mod download` pulls the SDK and every +# other dependency straight from the module proxy, cache-keyed on go.mod/go.sum. + +# --------------------------------------------------------------------------- +# Stage 1 — builder: fetch modules from the proxy, build the static binary. +# golang 1.23 satisfies go.mod's `go 1.21` directive. +# --------------------------------------------------------------------------- +FROM golang:1.23-bookworm AS builder + +WORKDIR /src + +# Prime the module cache from go.mod/go.sum first so dependency downloads cache +# independently of source edits (this layer re-runs only when go.mod/go.sum change). +COPY go/go.mod go/go.sum ./ +RUN go mod download + +# Copy the rest of the module and build a static binary. +COPY go/ ./ +ENV CGO_ENABLED=0 GOOS=linux +RUN go build -trimpath -ldflags="-s -w" -o /out/pr-af ./cmd/pr-af + +# --------------------------------------------------------------------------- +# Stage 2 — runtime: slim Debian mirroring the Python image (opencode CLI + a +# non-root praf user), but shipping the single static Go binary instead of a +# Python runtime. The entrypoint is byte-identical to the Python one — it +# generates opencode.json from PR_AF_MODEL at container start. +# --------------------------------------------------------------------------- +FROM debian:bookworm-slim AS runtime + +ARG OPENCODE_VERSION=1.17.15 + +ENV AGENTFIELD_SERVER=http://agentfield:8080 \ + PR_AF_PROVIDER=opencode \ + PR_AF_MODEL=openrouter/moonshotai/kimi-k2.5 \ + PORT=8007 \ + NODE_ID=pr-af-go \ + HOME=/home/praf \ + PATH=/home/praf/.opencode/bin:${PATH} \ + XDG_DATA_HOME=/home/praf/.local/share \ + PR_AF_WORKDIR=/workspaces + +# System deps: ca-certificates (HTTPS to the LLM provider + GitHub), curl +# (healthcheck + opencode installer), git (repo_path reviews clone/fetch/checkout). +# Create the non-root praf user (uid/gid 10001) and install the opencode CLI as +# that user so it lands under /home/praf/.opencode (on PATH above). +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + git && \ + groupadd --gid 10001 praf && \ + useradd --uid 10001 --gid praf --create-home --home-dir /home/praf --shell /bin/sh praf && \ + su -s /bin/sh praf -c "curl -fsSL https://opencode.ai/install | bash -s -- --version ${OPENCODE_VERSION} --no-modify-path" && \ + mkdir -p /workspaces /home/praf/.local/share && \ + chown -R praf:praf /workspaces /home/praf && \ + rm -rf /var/lib/apt/lists/* + +COPY --from=builder /out/pr-af /usr/local/bin/pr-af +COPY go/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +USER praf + +EXPOSE 8007 + +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD curl -f http://localhost:8007/health || exit 1 + +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] +CMD ["/usr/local/bin/pr-af"] diff --git a/go/Makefile b/go/Makefile new file mode 100644 index 0000000..5cd6648 --- /dev/null +++ b/go/Makefile @@ -0,0 +1,61 @@ +# PR-AF Go port — build/test targets. Run from the go/ directory. +# Mirrors the acceptance gate used by every port task and CI (design §F, §G): +# go build ./... && go vet ./... && go test ./... +# +# The AgentField Go SDK is a REAL versioned require resolved from +# proxy.golang.org (no replace directive), so no GOWORK/replace dance is needed +# for CI/Docker. A gitignored go.work is the local dev path; set GOWORK=off to +# ignore it explicitly. + +.PHONY: build vet test check fmt lint run docker-build docker-up docker-down + +# Compile every package in the module. +build: + go build ./... + +# Static analysis (go vet) across the module. +vet: + go vet ./... + +# Unit tests across the module. +test: + go test ./... + +# The full local gate CI runs: build + vet + tests. +check: build vet test + +# Format every Go source file in place. +fmt: + gofmt -w . + +# Optional lint pass; no-op with a hint when golangci-lint is not installed. +lint: + @command -v golangci-lint >/dev/null 2>&1 && golangci-lint run || \ + echo "golangci-lint not installed; skipping (install: https://golangci-lint.run)" + +# Run the PR-AF node (pr-af-go, default port 8007). +run: + go run ./cmd/pr-af + +# --- Docker (multi-stage image + Go-node compose add-on) ------------------ +# The Dockerfile build context is this go/ directory (design §E.1): the SDK is +# a real require pulled by `go mod download`, so there is no SDK clone stage and +# no build-arg. The compose add-on (docker-compose.go.yml) lives at the repo +# root and joins the Python pr-af stack's external network + workspaces volume. +# Override the image tag with: make docker-build IMAGE=myrepo/pr-af-go:dev +IMAGE ?= pr-af-go:latest + +# Build the multi-stage Go image. The Dockerfile expects the REPO ROOT as the +# build context (it COPYs go/go.mod, go/ ...), so the context is the parent dir. +docker-build: + docker build -f Dockerfile -t $(IMAGE) .. + +# Bring up the Go node (pr-af-go:8007) as an ADD-ON to the Python stack. Start +# the Python stack first (`docker compose up` — it owns the control plane + +# shared network); this add-on joins that network as an external reference. +docker-up: + docker compose -f ../docker-compose.go.yml up --build + +# Tear the Go-node add-on down. +docker-down: + docker compose -f ../docker-compose.go.yml down diff --git a/go/README.md b/go/README.md new file mode 100644 index 0000000..dffc293 --- /dev/null +++ b/go/README.md @@ -0,0 +1,147 @@ +# PR-AF — Go node + +A Go port of the PR-AF agentic code-review node. It registers the same reasoner +surface under the same names as the Python node and exposes a byte-compatible +HTTP API, so the control-plane DAG UI renders identically. The Python package +under `src/pr_af/` is untouched; this port lives entirely under `go/`. + +One binary: + +| Binary | Node ID | Default port | Role | +|----------|------------|--------------|----------------------------------------| +| `pr-af` | `pr-af-go` | `8007` | Full review pipeline (intake → review) | + +Module path: `github.com/Agent-Field/pr-af/go`. + +## Opt-in alongside Python + +The Python node is the **default**: `pr-af` on `:8004`, unchanged. This Go port +registers **separately** under a distinct identity — `pr-af-go` on `:8007` — so +both nodes can run against **one** control plane at the same time. Nothing is +replaced; callers **opt in** by targeting the `-go` reasoner path, e.g. + +```bash +curl -X POST http://localhost:8080/api/v1/execute/async/pr-af-go.review \ + -H 'Content-Type: application/json' \ + -d '{"input":{"pr_url":"https://github.com/owner/repo/pull/123"}}' +``` + +`NODE_ID` / `PORT` still override the defaults if you want a different id/port. + +## Depending on the AgentField Go SDK + +Unlike the SWE-AF Go port, this module depends on the AgentField Go SDK +(`github.com/Agent-Field/agentfield/sdk/go`) via a **real, committed `require`** +resolved from `proxy.golang.org` — there is **no `replace` directive** and no +sibling checkout to lay out. `go build ./...` works out of the box against the +pinned SDK pseudo-version in `go.mod`. + +- **CI / Docker.** `go mod download` pulls the SDK (and every other dependency) + straight from the module proxy. No `GOWORK=off`, no sparse clone. +- **Dev — optional Go workspace.** A gitignored `go.work` (spanning this module + and a local `agentfield/sdk/go` checkout) is the way to develop against + unreleased SDK changes; with it present, `go build ./...` picks up local SDK + edits live. It is never committed. + +Bumping the SDK is a deliberate, reviewable change: bump the `require` +pseudo-version in `go.mod`, and move the Docker builder image tag / CI +`setup-go` version together if the SDK's own `go` directive ever advances past +`go 1.21`. + +## Build & run locally + +From `go/`: + +```bash +make build # go build ./... +make vet # go vet ./... +make test # go test ./... +make check # vet + test +make fmt # gofmt -w . +make run # run the node (pr-af-go, :8007) +``` + +`make run` needs a control plane reachable at `AGENTFIELD_SERVER` (default +`http://localhost:8080`). The node reads all configuration from the environment +at startup. + +## Docker + +The image is a multi-stage build and is **simpler than SWE-AF's** — because the +SDK is a real proxy-resolved `require`, the builder just runs `go mod download` +(no SDK clone stage). The runtime stage is a slim Debian mirroring the Python +image: the pinned `opencode` CLI (`1.17.15`), a non-root `praf` user, and the +**same** `docker-entrypoint.sh` (a byte copy of the Python one) that generates +`opencode.json` from `PR_AF_MODEL` at container start. + +The build context is the **repo root** so the `go/` module is in context: + +```bash +# from the repo root +docker build -f go/Dockerfile -t pr-af-go:latest . +``` + +### Compose: opt-in add-on to the Python stack + +`docker-compose.go.yml` (at the repo root) is an **add-on**, not a standalone +stack. It defines only the Go node and joins the Python stack's compose network +as an external reference, sharing the control plane (`agentfield`) and the +`workspaces` volume the Python stack brings up. The Python `docker-compose.yml` +is left untouched. Start the Python stack first, then layer the Go node: + +```bash +docker compose up -d # Python stack (control plane + pr-af :8004) +docker compose -f docker-compose.go.yml up -d # adds pr-af-go :8007 +``` + +Adds: + +| Service | Port | Node id | Notes | +|------------|--------|------------|--------------------------| +| `pr-af-go` | `8007` | `pr-af-go` | full review pipeline | + +The control plane (`:8080`) and the `workspaces` volume come from the Python +stack — the Go add-on joins them via the external `pr-af_default` network and +`pr-af_pr-af-workspaces` volume. This assumes the Python stack was brought up +with the default project name `pr-af` (the Python compose has no explicit +`name:`, so its project name is the checkout directory's basename); see the +compose file header for the `COMPOSE_PROJECT_NAME` override. Health: +`curl -f http://localhost:8007/health`. + +## Environment variables + +The node is configured entirely through the environment. + +| Variable | Purpose | +|-----------------------------|----------------------------------------------------------------| +| `OPENROUTER_API_KEY` | LLM provider key (OpenRouter) — required | +| `GH_TOKEN` | GitHub token (`repo` scope) for reading PRs and posting reviews | +| `AGENTFIELD_SERVER` | Control-plane URL (default `http://localhost:8080`) | +| `AGENTFIELD_API_KEY` | Control-plane API key (if the CP has auth enabled) | +| `NODE_ID` | Node ID (default `pr-af-go`) | +| `PORT` | Listen port (default `8007`) | +| `PR_AF_PROVIDER` | Harness provider (default `opencode`) | +| `PR_AF_MODEL` | Harness model (default `openrouter/moonshotai/kimi-k2.5`) | +| `PR_AF_MAX_COST_USD` | Per-run cost ceiling in USD (default `2.0`) | +| `PR_AF_MAX_DURATION_SECONDS`| Per-run wall-clock ceiling in seconds (default `300`) | +| `HAX_API_KEY` | Optional — enables the HITL review-approval gate when set | + +Note: the code default model is `minimax/minimax-m2.5`, while the Docker image / +compose / manifest set `PR_AF_MODEL=openrouter/moonshotai/kimi-k2.5`. The env +var always wins; both defaults are intentional (they mirror the Python node). + +## Deployment: `af install --path go` + +Because the SDK is a committed real `require` (no out-of-tree `replace` for the +installer to reject), the Go node **can** be installed via the AgentField +package installer, pointing it at the `go/` subdirectory: + +```bash +af install https://github.com/Agent-Field/pr-af --path go +``` + +This reads `go/agentfield-package.yaml` (node id `pr-af-go`, default port +`8007`) and builds `./cmd/pr-af`. The `--path` subdirectory selector requires +**agentfield ≥ v0.1.108** (the installer's `--path` support, merged in +agentfield#750); on an older control plane, prefer the Docker image / compose / +binary path above. diff --git a/go/agentfield-package.yaml b/go/agentfield-package.yaml new file mode 100644 index 0000000..be24fab --- /dev/null +++ b/go/agentfield-package.yaml @@ -0,0 +1,46 @@ +config_version: v1 +name: pr-af-go # MUST differ from root "pr-af" (installer registry is keyed by name) +version: 0.1.0 +description: AI-Native Pull Request Review Agent (Go port) +author: Agent-Field +language: go # explicit (also auto-detected from go/go.mod) + +entrypoint: + build: ./cmd/pr-af + start: bin/pr-af + healthcheck: /health + +agent_node: + node_id: pr-af-go + default_port: 8007 + +user_environment: + required: + - name: OPENROUTER_API_KEY + description: LLM provider key (OpenRouter) + type: secret + scope: global + - name: GH_TOKEN + description: GitHub token (repo scope) + type: secret + scope: global + optional: + - name: AGENTFIELD_SERVER + description: Control-plane URL + default: http://localhost:8080 + - name: AGENTFIELD_API_KEY + description: Control-plane API key + type: secret + scope: global + - name: PR_AF_PROVIDER + description: harness provider + default: opencode + - name: PR_AF_MODEL + description: harness model + default: openrouter/moonshotai/kimi-k2.5 + - name: PR_AF_MAX_COST_USD + description: per-run cost ceiling + default: "2.0" + - name: PR_AF_MAX_DURATION_SECONDS + description: per-run wall-clock ceiling + default: "300" diff --git a/go/cmd/pr-af/main.go b/go/cmd/pr-af/main.go new file mode 100644 index 0000000..343864c --- /dev/null +++ b/go/cmd/pr-af/main.go @@ -0,0 +1,48 @@ +// Command pr-af is the Go PR-AF review node (the port of src/pr_af/app.py's +// main()). It builds the agent from the environment, registers the 17-reasoner +// surface (design §B.1), and serves the SDK handler plus the custom +// /webhook/github route until SIGINT/SIGTERM. +// +// Defaults: NODE_ID "pr-af-go", PORT 8007 — a distinct identity from the Python +// pr-af node (:8004) so the Go port runs as an opt-in sibling alongside Python +// against one control plane. NODE_ID / PORT env vars override. +// +// Boot env (T4.3 e2e / production): +// +// AGENTFIELD_SERVER control-plane base URL (default http://localhost:8080) +// AGENTFIELD_API_KEY control-plane bearer token +// AGENT_CALLBACK_URL base URL the CP uses to reach this node (else localhost) +// NODE_ID node id (default pr-af-go) +// PORT listen port (default 8007) +// PR_AF_PROVIDER harness provider (default opencode) +// PR_AF_MODEL harness model (env wins over the code default) +// OPENROUTER_API_KEY LLM key — required for the .ai() gates; AIConfig is only +// attached when set (SDK rejects an empty key) +// GH_TOKEN GitHub token for FetchPR/clone/PostReview +// GITHUB_WEBHOOK_SECRET HMAC secret for /webhook/github (skip verify if unset) +// PR_AF_BOT_MENTION webhook trigger mention (default @pr-af) +package main + +import ( + "context" + "log" + + "github.com/Agent-Field/pr-af/go/internal/node" +) + +func main() { + n, err := node.BuildAgent( + "pr-af-go", + "8007", + "AI-Native Pull Request Review Agent", + ) + if err != nil { + log.Fatalf("pr-af: build agent: %v", err) + } + + n.RegisterAll() + + if err := n.Serve(context.Background()); err != nil { + log.Fatalf("pr-af: serve: %v", err) + } +} diff --git a/go/doc.go b/go/doc.go new file mode 100644 index 0000000..b688d96 --- /dev/null +++ b/go/doc.go @@ -0,0 +1,21 @@ +// Package praf is the module root marker for the PR-AF Go port. +// +// The port turns a pull request (or raw diff / local repo) into a structured, +// multi-dimensional code review — the same reasoner names, control-plane +// surface, and HTTP API shapes as the Python pr_af package. It registers as an +// OPT-IN sibling node ("pr-af-go", default port 8007) so it can run against one +// control plane alongside the untouched Python "pr-af" node. +// +// Layout (design §A): +// +// cmd/pr-af node entry point (node id "pr-af-go") +// internal/afx small AgentField SDK ergonomics (typed input binding) +// internal/fatal non-retryable harness-error classification +// internal/harnessx the single generic Run[T] harness choke-point + schema cache +// internal/... schemas, config, prompts, reasoners, orchestrator, etc. +// +// It depends on the AgentField Go SDK +// (github.com/Agent-Field/agentfield/sdk/go), consumed via a REAL versioned +// require resolved from proxy.golang.org — there is NO replace directive. A +// gitignored go.work workspace is the local dev path (design §A, §E.1). +package praf diff --git a/go/docker-entrypoint.sh b/go/docker-entrypoint.sh new file mode 100755 index 0000000..3e0f9c1 --- /dev/null +++ b/go/docker-entrypoint.sh @@ -0,0 +1,26 @@ +#!/bin/sh +# Generate the opencode config at container start so PR_AF_MODEL is honored. +# +# Previously opencode.json was baked into the image with a hardcoded model and +# a single-model provider whitelist. That meant PR_AF_MODEL was ignored by the +# opencode harness: even though the model is passed via `-m`, opencode fell back +# to (and restricted itself to) the baked model. Generating the config here from +# PR_AF_MODEL fixes that — the env var wins when set, and we fall back to the +# benchmarked default when it isn't. +set -e + +# Default matches the image ENV / benchmarked model. +MODEL="${PR_AF_MODEL:-openrouter/moonshotai/kimi-k2.5}" + +# opencode keys models under a provider by the slug *without* the provider +# prefix, e.g. "openrouter/z-ai/glm-5.2" -> provider "openrouter", key "z-ai/glm-5.2". +MODEL_KEY="${MODEL#openrouter/}" + +CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/opencode" +mkdir -p "$CONFIG_DIR" + +cat > "$CONFIG_DIR/opencode.json" < nil, +// present -> non-nil pointing at the given value (design §2.1). +func TestBind_PointerTriState(t *testing.T) { + absent, err := Bind[coderResultLike](map[string]any{}) + if err != nil { + t.Fatalf("Bind(absent) error: %v", err) + } + if absent.TestsPassed != nil { + t.Errorf("TestsPassed = %v, want nil when absent", *absent.TestsPassed) + } + + present, err := Bind[coderResultLike](map[string]any{"tests_passed": false}) + if err != nil { + t.Fatalf("Bind(present) error: %v", err) + } + if present.TestsPassed == nil { + t.Fatalf("TestsPassed = nil, want non-nil pointer to false") + } + if *present.TestsPassed != false { + t.Errorf("*TestsPassed = %v, want false", *present.TestsPassed) + } +} + +// TestBind_Nested covers a nested struct field binding, including the nested +// type's own default-seeding. +func TestBind_Nested(t *testing.T) { + got, err := Bind[nested](map[string]any{ + "name": "issue-1", + "inner": map[string]any{"summary": "nested summary"}, + }) + if err != nil { + t.Fatalf("Bind error: %v", err) + } + if got.Name != "issue-1" { + t.Errorf("Name = %q, want %q", got.Name, "issue-1") + } + if !got.Inner.Complete { + t.Errorf("Inner.Complete = false, want true (nested default seeding)") + } + if got.Inner.Summary != "nested summary" { + t.Errorf("Inner.Summary = %q, want %q", got.Inner.Summary, "nested summary") + } +} + +// TestBind_IntoMap covers binding into map[string]any (identity round-trip of +// the request body — used where a handler wants the raw shape back). +func TestBind_IntoMap(t *testing.T) { + in := map[string]any{"a": "x", "b": float64(2)} + got, err := Bind[map[string]any](in) + if err != nil { + t.Fatalf("Bind error: %v", err) + } + if got["a"] != "x" { + t.Errorf("got[a] = %v, want x", got["a"]) + } + if got["b"] != float64(2) { + t.Errorf("got[b] = %v (%T), want float64(2)", got["b"], got["b"]) + } +} + +// TestBind_TypeMismatch covers the error path: a value whose JSON type cannot +// unmarshal into the target field type returns an error, not a silent zero. +func TestBind_TypeMismatch(t *testing.T) { + type numeric struct { + Count int `json:"count"` + } + _, err := Bind[numeric](map[string]any{"count": "not-a-number"}) + if err == nil { + t.Fatalf("Bind = nil error, want unmarshal error for string into int") + } +} diff --git a/go/internal/blastradius/blastradius.go b/go/internal/blastradius/blastradius.go new file mode 100644 index 0000000..1aa627a --- /dev/null +++ b/go/internal/blastradius/blastradius.go @@ -0,0 +1,174 @@ +// Package blastradius ports pr_af/blast_radius.py: a file-level dependency +// graph built from Python import statements, used to find files that are +// affected by a change but not part of the changeset. +// +// Parity note (design §A, task T2.3): the Python engine is DELIBERATELY +// language-limited to Python imports and this port reproduces that limitation +// verbatim — it does NOT understand Go, JS, or any other language's imports. +// Relative imports (`from . import x`, `from .foo import y`) capture a +// leading-dot module name that never matches a module-map key, so they never +// produce an edge — exactly as in Python. +package blastradius + +import ( + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// skipDirs mirrors blast_radius.py's os.walk skip tuple. The Python code tests +// `skip in rel_root` — a SUBSTRING match on the directory's relative path — so +// a directory whose relative path merely CONTAINS one of these tokens is +// skipped (e.g. ".github" contains ".git"; "srcvenv" contains "venv"). This +// loose behavior is reproduced exactly. +var skipDirs = []string{".git", "node_modules", "vendor", "__pycache__", ".venv", "venv"} + +// importRe ports _extract_python_imports's regex. `(?m)` == re.MULTILINE so `^` +// matches at each line start. `[\w.]+` captures the module path; in Go `\w` is +// ASCII-only, which is sufficient for Python module names (always ASCII). +var importRe = regexp.MustCompile(`(?m)^\s*(?:from|import)\s+([\w.]+)`) + +// ComputeBlastRadius ports compute_blast_radius: files affected by changes but +// not themselves in the changeset. If file A imports file B and B changed (and +// A did not), A is in the blast radius. Returns a sorted, non-nil slice. +func ComputeBlastRadius(changedFiles []string, repoPath string) []string { + if repoPath == "" { + return []string{} + } + if info, err := os.Stat(repoPath); err != nil || !info.IsDir() { + return []string{} + } + + depGraph := BuildImportGraph(repoPath) + + changedSet := make(map[string]struct{}, len(changedFiles)) + for _, f := range changedFiles { + changedSet[f] = struct{}{} + } + + affected := map[string]struct{}{} + for _, changed := range changedFiles { + for dependent, imports := range depGraph { + if _, isChanged := changedSet[dependent]; isChanged { + continue + } + for _, imp := range imports { + if imp == changed { + affected[dependent] = struct{}{} + break + } + } + } + } + + out := make([]string, 0, len(affected)) + for k := range affected { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// BuildImportGraph ports build_import_graph: {file_path: [files_it_imports]}. +// Only Python (.py) files are considered, and only imports that resolve to a +// file within the repo produce an edge. Files with no resolving import do not +// appear as keys (matching Python's defaultdict -> dict(graph)). +func BuildImportGraph(repoPath string) map[string][]string { + graph := map[string][]string{} + + var pyFiles []string + // os.walk in Python descends the tree top-down and, for each directory, + // skips (does not collect) files when the directory's relative path + // contains a skip token. Because a skip token in an ancestor's rel path is + // still a substring of every descendant's rel path, every file under a + // skipped directory is also skipped — so pruning the subtree here is + // equivalent to Python's per-directory `continue`, and cheaper. + _ = filepath.WalkDir(repoPath, func(path string, d fs.DirEntry, err error) error { + if err != nil { + // os.walk's default onerror swallows errors and keeps going. + if d != nil && d.IsDir() { + return filepath.SkipDir + } + return nil + } + if d.IsDir() { + if path == repoPath { + return nil + } + relRoot, rerr := filepath.Rel(repoPath, path) + if rerr == nil && containsSkipToken(relRoot) { + return filepath.SkipDir + } + return nil + } + if strings.HasSuffix(d.Name(), ".py") { + pyFiles = append(pyFiles, path) + } + return nil + }) + + moduleToFile := buildPythonModuleMap(pyFiles, repoPath) + + for _, pyFile := range pyFiles { + relPath := relPathOf(repoPath, pyFile) + data, err := os.ReadFile(pyFile) + if err != nil { + continue + } + for _, mod := range extractPythonImports(string(data)) { + if target, ok := moduleToFile[mod]; ok { + graph[relPath] = append(graph[relPath], target) + } + } + } + + return graph +} + +// buildPythonModuleMap ports _build_python_module_map: module name -> relative +// file path. On a module-name collision the last file (in py_files order) wins, +// matching Python's plain-dict overwrite. +func buildPythonModuleMap(pyFiles []string, repoPath string) map[string]string { + mapping := make(map[string]string, len(pyFiles)) + for _, pyFile := range pyFiles { + rel := relPathOf(repoPath, pyFile) + module := strings.ReplaceAll(rel, string(os.PathSeparator), ".") + module = strings.TrimSuffix(module, ".py") + module = strings.TrimSuffix(module, ".__init__") + mapping[module] = rel + } + return mapping +} + +// extractPythonImports ports _extract_python_imports. +func extractPythonImports(content string) []string { + matches := importRe.FindAllStringSubmatch(content, -1) + modules := make([]string, 0, len(matches)) + for _, m := range matches { + modules = append(modules, m[1]) + } + return modules +} + +// relPathOf mirrors os.path.relpath(file, repoPath) with the OS separator. +func relPathOf(repoPath, file string) string { + rel, err := filepath.Rel(repoPath, file) + if err != nil { + return file + } + return rel +} + +// containsSkipToken reports whether any skip token is a substring of relRoot, +// reproducing Python's `any(skip in rel_root for skip in ...)`. +func containsSkipToken(relRoot string) bool { + for _, skip := range skipDirs { + if strings.Contains(relRoot, skip) { + return true + } + } + return false +} diff --git a/go/internal/blastradius/blastradius_test.go b/go/internal/blastradius/blastradius_test.go new file mode 100644 index 0000000..c7cfce2 --- /dev/null +++ b/go/internal/blastradius/blastradius_test.go @@ -0,0 +1,205 @@ +package blastradius + +import ( + "os" + "path/filepath" + "reflect" + "sort" + "testing" +) + +// writeFile writes content to repo/rel, creating parent dirs. +func writeFile(t *testing.T, repo, rel, content string) { + t.Helper() + abs := filepath.Join(repo, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", abs, err) + } + if err := os.WriteFile(abs, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", abs, err) + } +} + +// buildFixture constructs the reference repo used across tests. Its expected +// import graph and blast radii were captured from the Python implementation. +func buildFixture(t *testing.T) string { + t.Helper() + repo := t.TempDir() + writeFile(t, repo, "utils.py", "def util_func():\n return 1\n") + writeFile(t, repo, "app.py", "from utils import util_func\nimport helpers\n\ndef main():\n return util_func()\n") + writeFile(t, repo, "helpers.py", "def helper():\n return 2\n") + writeFile(t, repo, "relative.py", "from . import utils\nfrom .pkg import mod\nimport utils.deep.thing\n") + writeFile(t, repo, "pkg/__init__.py", "X = 1\n") + writeFile(t, repo, "uses_pkg.py", "from pkg import mod\nimport pkg.mod\n") + writeFile(t, repo, "pkg/mod.py", "def mod_fn():\n return 3\n") + // A file inside venv/ imports utils but MUST be skipped. + writeFile(t, repo, "venv/lib/skipme.py", "import utils\n") + return repo +} + +func TestBuildImportGraph(t *testing.T) { + repo := buildFixture(t) + got := BuildImportGraph(repo) + + // Golden captured from Python build_import_graph. + want := map[string][]string{ + "app.py": {"utils.py", "helpers.py"}, + "uses_pkg.py": {"pkg/__init__.py", "pkg/mod.py"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("import graph mismatch:\n got=%v\nwant=%v", got, want) + } +} + +func TestRelativeImportsProduceNoEdges(t *testing.T) { + repo := buildFixture(t) + got := BuildImportGraph(repo) + // relative.py uses `from . import utils`, `from .pkg import mod`, and + // `import utils.deep.thing` — all deliberately unresolvable, so it must not + // be a key in the graph at all. + if edges, present := got["relative.py"]; present { + t.Fatalf("relative.py must have no edges, got %v", edges) + } +} + +func TestVendorAndVenvDirsSkipped(t *testing.T) { + repo := buildFixture(t) + got := BuildImportGraph(repo) + // venv/lib/skipme.py imports utils but the venv/ subtree is skipped, so it + // must never appear as a dependent. + if _, present := got["venv/lib/skipme.py"]; present { + t.Fatal("venv/lib/skipme.py should be skipped") + } + // And it must not surface in the blast radius of a changed utils.py. + for _, f := range ComputeBlastRadius([]string{"utils.py"}, repo) { + if f == "venv/lib/skipme.py" { + t.Fatal("skipped file leaked into blast radius") + } + } +} + +func TestFromImportResolvesModuleNotName(t *testing.T) { + repo := buildFixture(t) + got := BuildImportGraph(repo) + // `from pkg import mod` resolves the package module `pkg` + // (pkg/__init__.py), NOT pkg/mod.py; `import pkg.mod` resolves pkg/mod.py. + edges := got["uses_pkg.py"] + want := []string{"pkg/__init__.py", "pkg/mod.py"} + if !reflect.DeepEqual(edges, want) { + t.Fatalf("uses_pkg.py edges = %v, want %v", edges, want) + } +} + +func TestComputeBlastRadius(t *testing.T) { + repo := buildFixture(t) + cases := []struct { + name string + changed []string + want []string + }{ + {"utils changed -> app affected", []string{"utils.py"}, []string{"app.py"}}, + {"pkg/mod changed -> uses_pkg affected", []string{"pkg/mod.py"}, []string{"uses_pkg.py"}}, + {"helpers changed -> app affected", []string{"helpers.py"}, []string{"app.py"}}, + {"pkg/__init__ changed -> uses_pkg affected", []string{"pkg/__init__.py"}, []string{"uses_pkg.py"}}, + {"no importers -> empty", []string{"relative.py"}, []string{}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := ComputeBlastRadius(tc.changed, repo) + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("ComputeBlastRadius(%v) = %v, want %v", tc.changed, got, tc.want) + } + }) + } +} + +func TestComputeBlastRadiusExcludesChangedFiles(t *testing.T) { + repo := buildFixture(t) + // If both utils.py AND app.py changed, app.py (a dependent) must be + // excluded because it is itself in the changeset. + got := ComputeBlastRadius([]string{"utils.py", "app.py"}, repo) + if len(got) != 0 { + t.Fatalf("expected empty (dependent is itself changed), got %v", got) + } +} + +func TestComputeBlastRadiusInvalidRepo(t *testing.T) { + if got := ComputeBlastRadius([]string{"x.py"}, ""); !reflect.DeepEqual(got, []string{}) { + t.Fatalf("empty repo path -> %v, want []", got) + } + if got := ComputeBlastRadius([]string{"x.py"}, filepath.Join(t.TempDir(), "does-not-exist")); !reflect.DeepEqual(got, []string{}) { + t.Fatalf("missing repo -> %v, want []", got) + } + // A file (not dir) is also rejected. + f := filepath.Join(t.TempDir(), "afile") + if err := os.WriteFile(f, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if got := ComputeBlastRadius([]string{"x.py"}, f); !reflect.DeepEqual(got, []string{}) { + t.Fatalf("repo is a file -> %v, want []", got) + } +} + +func TestExtractPythonImports(t *testing.T) { + src := "import os\nfrom sys import argv\n import indented\nimport a.b.c\n" + + "from . import rel\nfrom .pkg import x\nimport os, sys\n# import commented\n" + got := extractPythonImports(src) + // Captures: `import\s+([\w.]+)` / `from\s+([\w.]+)` — first token only, and + // the leading-# comment line still matches at `^\s*import` only if it begins + // with import/from (it does not: it begins with '#'). + want := []string{"os", "sys", "indented", "a.b.c", ".", ".pkg", "os"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("extractPythonImports = %v, want %v", got, want) + } +} + +func TestBuildPythonModuleMap(t *testing.T) { + repo := t.TempDir() + files := []string{ + filepath.Join(repo, "a", "b.py"), + filepath.Join(repo, "pkg", "__init__.py"), + filepath.Join(repo, "top.py"), + } + got := buildPythonModuleMap(files, repo) + want := map[string]string{ + "a.b": filepath.Join("a", "b.py"), + "pkg": filepath.Join("pkg", "__init__.py"), + "top": "top.py", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("module map = %v, want %v", got, want) + } +} + +func TestContainsSkipToken(t *testing.T) { + // Substring semantics, matching Python `skip in rel_root`. + trueCases := []string{".git", "a/.git/b", "node_modules", "src/venv", "myvenv", ".github", "x/.venv/y", "__pycache__"} + for _, c := range trueCases { + if !containsSkipToken(c) { + t.Errorf("containsSkipToken(%q) = false, want true", c) + } + } + falseCases := []string{".", "src", "app", "pkg/mod"} + for _, c := range falseCases { + if containsSkipToken(c) { + t.Errorf("containsSkipToken(%q) = true, want false", c) + } + } +} + +// TestBlastRadiusOutputSorted guards the sorted, non-nil return contract. +func TestBlastRadiusOutputSorted(t *testing.T) { + repo := t.TempDir() + writeFile(t, repo, "core.py", "X=1\n") + writeFile(t, repo, "z.py", "import core\n") + writeFile(t, repo, "a.py", "import core\n") + writeFile(t, repo, "m.py", "import core\n") + got := ComputeBlastRadius([]string{"core.py"}, repo) + want := []string{"a.py", "m.py", "z.py"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("blast radius = %v, want sorted %v", got, want) + } + if !sort.StringsAreSorted(got) { + t.Fatal("output not sorted") + } +} diff --git a/go/internal/config/ai.go b/go/internal/config/ai.go new file mode 100644 index 0000000..39855d5 --- /dev/null +++ b/go/internal/config/ai.go @@ -0,0 +1,141 @@ +// Package config ports PR-AF's config.py plus the app.py:62-77 +// _resolve_budget_caps cascade. Every env var is read at CALL time (inside the +// FromEnv / default constructors), never at package init, so a t.Setenv in a +// test is deterministic and no value is frozen at import. +package config + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +// AIIntegrationConfig ports config.py AIIntegrationConfig. Env precedence and +// defaults per design §C.2. The HarnessModel/AIModel fallback is the CODE +// default "minimax/minimax-m2.5" — deliberately different from the +// Docker/compose/manifest default (openrouter/moonshotai/kimi-k2.5); env always +// wins, and both facts ship intentionally (design §B.6). Do not "fix" it. +type AIIntegrationConfig struct { + Provider string `json:"provider"` + HarnessModel string `json:"harness_model"` + AIModel string `json:"ai_model"` + MaxTurns int `json:"max_turns"` + MaxRetries int `json:"max_retries"` + InitialBackoffSeconds float64 `json:"initial_backoff_seconds"` + MaxBackoffSeconds float64 `json:"max_backoff_seconds"` + OpencodeBin string `json:"opencode_bin"` + OpencodeServer *string `json:"opencode_server"` +} + +// AIConfigFromEnv resolves the AI integration config from the environment +// (config.py AIIntegrationConfig.from_env / its default_factory lambdas). +// A malformed numeric env value is an error, matching Python where the +// default_factory's int()/float() raises at model construction (which happens +// at module import in app.py — i.e. the node fails to boot). +func AIConfigFromEnv() (AIIntegrationConfig, error) { + maxTurns, err := intEnv("PR_AF_MAX_TURNS", 50) + if err != nil { + return AIIntegrationConfig{}, err + } + maxRetries, err := intEnv("PR_AF_AI_MAX_RETRIES", 3) + if err != nil { + return AIIntegrationConfig{}, err + } + initialBackoff, err := floatEnv("PR_AF_AI_INITIAL_BACKOFF_SECONDS", 2.0) + if err != nil { + return AIIntegrationConfig{}, err + } + maxBackoff, err := floatEnv("PR_AF_AI_MAX_BACKOFF_SECONDS", 8.0) + if err != nil { + return AIIntegrationConfig{}, err + } + return AIIntegrationConfig{ + Provider: strEnv("PR_AF_PROVIDER", "opencode"), + HarnessModel: strEnv("PR_AF_MODEL", "minimax/minimax-m2.5"), + // AI_MODEL falls back to PR_AF_MODEL, then to the code default. + AIModel: strEnv("PR_AF_AI_MODEL", strEnv("PR_AF_MODEL", "minimax/minimax-m2.5")), + MaxTurns: maxTurns, + MaxRetries: maxRetries, + InitialBackoffSeconds: initialBackoff, + MaxBackoffSeconds: maxBackoff, + OpencodeBin: strEnv("PR_AF_OPENCODE_BIN", "opencode"), + OpencodeServer: lookupPtr("PR_AF_OPENCODE_SERVER"), + }, nil +} + +// ProviderEnv builds the subprocess environment forwarded to the opencode +// harness (config.py provider_env): the LLM/GitHub credentials that are set, +// plus an XDG_DATA_HOME that is created if missing. +func (c AIIntegrationConfig) ProviderEnv() map[string]string { + env := map[string]string{} + for _, key := range []string{ + "OPENROUTER_API_KEY", + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "GOOGLE_API_KEY", + "GH_TOKEN", + } { + if v := os.Getenv(key); v != "" { + env[key] = v + } + } + xdg := os.Getenv("XDG_DATA_HOME") + if xdg == "" { + xdg = filepath.Join(os.TempDir(), "opencode-shared-data") + } + _ = os.MkdirAll(xdg, 0o755) + env["XDG_DATA_HOME"] = xdg + return env +} + +// --- shared env readers (call-time only) --- + +// strEnv returns the env value for key, or def when the key is unset. A key that +// is set (even to "") returns its value, matching Python's os.getenv(key, def). +func strEnv(key, def string) string { + if v, ok := os.LookupEnv(key); ok { + return v + } + return def +} + +// intEnv parses key as an int, falling back to def when unset. A set-but- +// malformed value is an error with Python's int() message shape — Python's +// int(os.getenv(...)) raises, it never silently defaults. +func intEnv(key string, def int) (int, error) { + v, ok := os.LookupEnv(key) + if !ok { + return def, nil + } + n, err := strconv.Atoi(strings.TrimSpace(v)) + if err != nil { + return 0, fmt.Errorf("invalid literal for int() with base 10: '%s'", v) + } + return n, nil +} + +// floatEnv parses key as a float64, falling back to def when unset. A set-but- +// malformed value is an error with Python's float() message shape — Python's +// float(os.getenv(...)) raises, it never silently defaults. +func floatEnv(key string, def float64) (float64, error) { + v, ok := os.LookupEnv(key) + if !ok { + return def, nil + } + f, err := strconv.ParseFloat(strings.TrimSpace(v), 64) + if err != nil { + return 0, fmt.Errorf("could not convert string to float: '%s'", v) + } + return f, nil +} + +// lookupPtr returns a pointer to the env value when the key is present (even if +// ""), or nil when unset — the Go analog of os.getenv returning None. +func lookupPtr(key string) *string { + if v, ok := os.LookupEnv(key); ok { + return &v + } + return nil +} diff --git a/go/internal/config/budget.go b/go/internal/config/budget.go new file mode 100644 index 0000000..68776cd --- /dev/null +++ b/go/internal/config/budget.go @@ -0,0 +1,101 @@ +package config + +import "strings" + +// BudgetConfig ports config.py BudgetConfig — global and per-phase budget caps. +// Numeric defaults are the §D "Config numeric tables" verbatim. Note +// max_duration_seconds defaults to 1800 here but is always overridden per call +// to the resolved value (300 by default) via ReviewConfig.FromInput. +type BudgetConfig struct { + MaxCostUSD float64 `json:"max_cost_usd"` + MaxDurationSeconds int `json:"max_duration_seconds"` + PhaseBudgets map[string]float64 `json:"phase_budgets"` + + MaxConcurrentReviewers int `json:"max_concurrent_reviewers"` + + MaxReferenceFollowsPerReviewer int `json:"max_reference_follows_per_reviewer"` + MaxChildSpawnsPerReviewer int `json:"max_child_spawns_per_reviewer"` + + MaxCrossRefDeepDives int `json:"max_cross_ref_deep_dives"` + + MaxCoverageIterations int `json:"max_coverage_iterations"` + + MaxReviewDepth int `json:"max_review_depth"` + + // EvidencePackReviewers pre-reads each dimension's target files and injects + // them so reviewers reason over a primed pack. Default ON: env + // PR_AF_EVIDENCE_PACK not in {"0","false","no"}. + EvidencePackReviewers bool `json:"evidence_pack_reviewers"` +} + +// DefaultPhaseBudgets is the per-phase USD allocation (config.py). Returned as a +// fresh map per call so callers cannot mutate a shared instance. +func DefaultPhaseBudgets() map[string]float64 { + return map[string]float64{ + "intake": 0.05, + "anatomy": 0.15, + "meta_selectors": 0.30, // 3 parallel lenses + "review": 0.90, // Most budget goes here + "adversary": 0.40, // Parallel batches + "cross_ref": 0.30, + "coverage": 0.10, + "synthesis": 0.00, // Code, no LLM cost + "output": 0.00, // Code, no LLM cost + } +} + +// DefaultBudgetConfig builds a BudgetConfig with the config.py defaults, reading +// PR_AF_EVIDENCE_PACK at call time. +func DefaultBudgetConfig() BudgetConfig { + return BudgetConfig{ + MaxCostUSD: 2.0, + MaxDurationSeconds: 1800, + PhaseBudgets: DefaultPhaseBudgets(), + MaxConcurrentReviewers: 8, + MaxReferenceFollowsPerReviewer: 3, + MaxChildSpawnsPerReviewer: 2, + MaxCrossRefDeepDives: 5, + MaxCoverageIterations: 2, + MaxReviewDepth: 2, + EvidencePackReviewers: evidencePackDefault(), + } +} + +// evidencePackDefault ports the PR_AF_EVIDENCE_PACK default_factory (default ON, +// disabled only by an explicit "0"/"false"/"no"). +func evidencePackDefault() bool { + switch strings.ToLower(strEnv("PR_AF_EVIDENCE_PACK", "1")) { + case "0", "false", "no": + return false + default: + return true + } +} + +// ResolveBudgetCaps ports app.py:62-77 _resolve_budget_caps. An explicit +// argument always wins; when nil, the PR_AF_MAX_COST_USD / PR_AF_MAX_DURATION_ +// SECONDS env vars are consulted; finally the historical defaults 2.0 / 300. +// A malformed env value is an error (Python's float()/int() raises inside +// review(), where the ValueError class maps to HTTP 400). +func ResolveBudgetCaps(maxCostUSD *float64, maxDurationSeconds *int) (float64, int, error) { + cost := 2.0 + if maxCostUSD != nil { + cost = *maxCostUSD + } else { + var err error + if cost, err = floatEnv("PR_AF_MAX_COST_USD", 2.0); err != nil { + return 0, 0, err + } + } + + dur := 300 + if maxDurationSeconds != nil { + dur = *maxDurationSeconds + } else { + var err error + if dur, err = intEnv("PR_AF_MAX_DURATION_SECONDS", 300); err != nil { + return 0, 0, err + } + } + return cost, dur, nil +} diff --git a/go/internal/config/comment.go b/go/internal/config/comment.go new file mode 100644 index 0000000..ac2bff5 --- /dev/null +++ b/go/internal/config/comment.go @@ -0,0 +1,62 @@ +package config + +import "strings" + +// CommentConfig ports config.py CommentConfig — comment formatting and posting +// preferences (§D numeric tables). PostWorthinessGate is env-driven (default +// OFF); every other default is a plain constant. +type CommentConfig struct { + MinSeverity string `json:"min_severity"` // Minimum severity to include + MaxComments int `json:"max_comments"` // Cap inline comments + IncludeSuggestions bool `json:"include_suggestions"` + IncludeDimensionAttribution bool `json:"include_dimension_attribution"` + IncludeConfidence bool `json:"include_confidence"` + SuggestionMode string `json:"suggestion_mode"` // comment | code + + // PolishEnabled runs a parallel .ai() pass that rewrites each comment body + // right before posting; on any per-call failure the original body is kept. + PolishEnabled bool `json:"polish_enabled"` + + // MergeGateEnabled runs a parallel .ai() pass classifying each finding as + // blocking vs advisory. Default ON; failures default to advisory. + MergeGateEnabled bool `json:"merge_gate_enabled"` + + // PostWorthinessGate: an experienced-reviewer precision pass. DEFAULT OFF; + // enabled by PR_AF_POSTWORTHINESS_GATE in {"1","true","yes"}. + PostWorthinessGate bool `json:"post_worthiness_gate"` + + SeverityEmojis map[string]string `json:"severity_emojis"` +} + +// DefaultCommentConfig builds the config.py comment defaults, reading +// PR_AF_POSTWORTHINESS_GATE at call time. +func DefaultCommentConfig() CommentConfig { + return CommentConfig{ + MinSeverity: "nitpick", + MaxComments: 25, + IncludeSuggestions: true, + IncludeDimensionAttribution: true, + IncludeConfidence: true, + SuggestionMode: "comment", + PolishEnabled: true, + MergeGateEnabled: true, + PostWorthinessGate: postWorthinessDefault(), + SeverityEmojis: map[string]string{ + "critical": "🔴", + "important": "🟠", + "suggestion": "🔵", + "nitpick": "⚪", + }, + } +} + +// postWorthinessDefault ports the PR_AF_POSTWORTHINESS_GATE default_factory +// (default OFF, enabled only by "1"/"true"/"yes"). +func postWorthinessDefault() bool { + switch strings.ToLower(strEnv("PR_AF_POSTWORTHINESS_GATE", "")) { + case "1", "true", "yes": + return true + default: + return false + } +} diff --git a/go/internal/config/config_test.go b/go/internal/config/config_test.go new file mode 100644 index 0000000..7f26da4 --- /dev/null +++ b/go/internal/config/config_test.go @@ -0,0 +1,499 @@ +package config + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// configEnvKeys is every env var this package reads. clearConfigEnv unsets them +// all (and restores on cleanup) so a table-driven env test starts from a known +// blank slate — t.Setenv can only set, not unset, and strEnv/LookupEnv treat a +// present-but-empty value differently from an absent one. +var configEnvKeys = []string{ + "PR_AF_PROVIDER", "PR_AF_MODEL", "PR_AF_AI_MODEL", + "PR_AF_MAX_TURNS", "PR_AF_AI_MAX_RETRIES", + "PR_AF_AI_INITIAL_BACKOFF_SECONDS", "PR_AF_AI_MAX_BACKOFF_SECONDS", + "PR_AF_OPENCODE_BIN", "PR_AF_OPENCODE_SERVER", + "PR_AF_MAX_COST_USD", "PR_AF_MAX_DURATION_SECONDS", + "PR_AF_EVIDENCE_PACK", "PR_AF_POSTWORTHINESS_GATE", + "HAX_API_KEY", "AGENTFIELD_APPROVAL_USER_ID", + "OPENROUTER_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY", + "GOOGLE_API_KEY", "GH_TOKEN", "XDG_DATA_HOME", +} + +func clearConfigEnv(t *testing.T) { + t.Helper() + for _, k := range configEnvKeys { + k := k + if prev, ok := os.LookupEnv(k); ok { + t.Cleanup(func() { _ = os.Setenv(k, prev) }) + } else { + t.Cleanup(func() { _ = os.Unsetenv(k) }) + } + _ = os.Unsetenv(k) + } +} + +func ptrF(f float64) *float64 { return &f } +func ptrI(i int) *int { return &i } + +// --------------------------------------------------------------------------- +// V7 — Budget cap resolution: explicit arg wins > env > defaults (2.0/300). +// --------------------------------------------------------------------------- + +// mustAIConfig / mustFromInput unwrap the error-returning constructors for +// tests whose env is known-wellformed. +func mustAIConfig(t *testing.T) AIIntegrationConfig { + t.Helper() + c, err := AIConfigFromEnv() + if err != nil { + t.Fatalf("AIConfigFromEnv: %v", err) + } + return c +} + +func mustFromInput(t *testing.T, in schemas.ReviewInput) ReviewConfig { + t.Helper() + c, err := ReviewConfig{}.FromInput(in) + if err != nil { + t.Fatalf("FromInput: %v", err) + } + return c +} + +func TestResolveBudgetCapsCascade(t *testing.T) { + cases := []struct { + name string + argCost *float64 + argDur *int + envCost string // "" means leave unset + envDur string + wantCost float64 + wantDur int + }{ + {"defaults when nil and no env", nil, nil, "", "", 2.0, 300}, + {"env used when arg nil", nil, nil, "3.3", "450", 3.3, 450}, + {"explicit arg wins over env", ptrF(5.0), ptrI(120), "3.3", "450", 5.0, 120}, + {"explicit arg wins over defaults", ptrF(1.25), ptrI(90), "", "", 1.25, 90}, + {"mixed: explicit cost, env duration", ptrF(9.0), nil, "3.3", "450", 9.0, 450}, + {"mixed: env cost, explicit duration", nil, ptrI(77), "3.3", "450", 3.3, 77}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + clearConfigEnv(t) + if c.envCost != "" { + t.Setenv("PR_AF_MAX_COST_USD", c.envCost) + } + if c.envDur != "" { + t.Setenv("PR_AF_MAX_DURATION_SECONDS", c.envDur) + } + gotCost, gotDur, err := ResolveBudgetCaps(c.argCost, c.argDur) + if err != nil { + t.Fatalf("ResolveBudgetCaps: %v", err) + } + if gotCost != c.wantCost || gotDur != c.wantDur { + t.Errorf("ResolveBudgetCaps = (%v, %d), want (%v, %d)", gotCost, gotDur, c.wantCost, c.wantDur) + } + }) + } + + // Malformed env raises, exactly like Python's float()/int() inside + // _resolve_budget_caps (ValueError -> HTTP 400 at the node layer), with + // Python's message shape. It must NOT silently fall back to the default. + t.Run("unparseable env is an error with the Python message", func(t *testing.T) { + clearConfigEnv(t) + t.Setenv("PR_AF_MAX_COST_USD", "abc") + _, _, err := ResolveBudgetCaps(nil, nil) + if err == nil || err.Error() != "could not convert string to float: 'abc'" { + t.Fatalf("cost err = %v, want Python float() message", err) + } + clearConfigEnv(t) + t.Setenv("PR_AF_MAX_DURATION_SECONDS", "xyz") + _, _, err = ResolveBudgetCaps(nil, nil) + if err == nil || err.Error() != "invalid literal for int() with base 10: 'xyz'" { + t.Fatalf("duration err = %v, want Python int() message", err) + } + // And the boot-time path: a malformed PR_AF_MAX_TURNS fails + // AIConfigFromEnv (Python crashes at import). + clearConfigEnv(t) + t.Setenv("PR_AF_MAX_TURNS", "many") + if _, err := AIConfigFromEnv(); err == nil { + t.Fatal("AIConfigFromEnv with PR_AF_MAX_TURNS=many: want error, got nil") + } + }) +} + +// --------------------------------------------------------------------------- +// AIIntegrationConfig env cascade + provider_env. +// --------------------------------------------------------------------------- + +func TestAIConfigFromEnvDefaults(t *testing.T) { + clearConfigEnv(t) + c := mustAIConfig(t) + if c.Provider != "opencode" { + t.Errorf("Provider = %q, want opencode", c.Provider) + } + // The CODE default is minimax — NOT the manifest's kimi default. env wins, + // but with no env this must be minimax (design §B.6). + if c.HarnessModel != "minimax/minimax-m2.5" { + t.Errorf("HarnessModel = %q, want minimax/minimax-m2.5", c.HarnessModel) + } + if c.AIModel != "minimax/minimax-m2.5" { + t.Errorf("AIModel = %q, want minimax/minimax-m2.5", c.AIModel) + } + if c.MaxTurns != 50 || c.MaxRetries != 3 { + t.Errorf("MaxTurns/MaxRetries = %d/%d, want 50/3", c.MaxTurns, c.MaxRetries) + } + if c.InitialBackoffSeconds != 2.0 || c.MaxBackoffSeconds != 8.0 { + t.Errorf("backoff = %v/%v, want 2.0/8.0", c.InitialBackoffSeconds, c.MaxBackoffSeconds) + } + if c.OpencodeBin != "opencode" { + t.Errorf("OpencodeBin = %q, want opencode", c.OpencodeBin) + } + if c.OpencodeServer != nil { + t.Errorf("OpencodeServer = %v, want nil", *c.OpencodeServer) + } +} + +func TestAIConfigFromEnvOverrides(t *testing.T) { + clearConfigEnv(t) + t.Setenv("PR_AF_PROVIDER", "claude-code") + t.Setenv("PR_AF_MODEL", "openrouter/moonshotai/kimi-k2.5") + t.Setenv("PR_AF_MAX_TURNS", "12") + t.Setenv("PR_AF_AI_MAX_RETRIES", "6") + t.Setenv("PR_AF_AI_INITIAL_BACKOFF_SECONDS", "1.5") + t.Setenv("PR_AF_AI_MAX_BACKOFF_SECONDS", "16") + t.Setenv("PR_AF_OPENCODE_BIN", "/usr/bin/opencode") + t.Setenv("PR_AF_OPENCODE_SERVER", "http://localhost:9000") + + c := mustAIConfig(t) + if c.Provider != "claude-code" { + t.Errorf("Provider = %q", c.Provider) + } + if c.HarnessModel != "openrouter/moonshotai/kimi-k2.5" { + t.Errorf("HarnessModel = %q", c.HarnessModel) + } + // AIModel falls back to PR_AF_MODEL when PR_AF_AI_MODEL is unset. + if c.AIModel != "openrouter/moonshotai/kimi-k2.5" { + t.Errorf("AIModel fallback = %q, want the PR_AF_MODEL value", c.AIModel) + } + if c.MaxTurns != 12 || c.MaxRetries != 6 { + t.Errorf("MaxTurns/MaxRetries = %d/%d, want 12/6", c.MaxTurns, c.MaxRetries) + } + if c.InitialBackoffSeconds != 1.5 || c.MaxBackoffSeconds != 16 { + t.Errorf("backoff = %v/%v", c.InitialBackoffSeconds, c.MaxBackoffSeconds) + } + if c.OpencodeServer == nil || *c.OpencodeServer != "http://localhost:9000" { + t.Errorf("OpencodeServer = %v", c.OpencodeServer) + } + + // PR_AF_AI_MODEL, when set, wins over PR_AF_MODEL for AIModel only. + t.Setenv("PR_AF_AI_MODEL", "anthropic/claude-x") + c2 := mustAIConfig(t) + if c2.AIModel != "anthropic/claude-x" { + t.Errorf("AIModel = %q, want anthropic/claude-x", c2.AIModel) + } + if c2.HarnessModel != "openrouter/moonshotai/kimi-k2.5" { + t.Errorf("HarnessModel should not be affected by PR_AF_AI_MODEL: %q", c2.HarnessModel) + } +} + +func TestProviderEnv(t *testing.T) { + clearConfigEnv(t) + xdg := t.TempDir() + t.Setenv("OPENROUTER_API_KEY", "or-key") + t.Setenv("GH_TOKEN", "gh-tok") + t.Setenv("XDG_DATA_HOME", xdg) + + env := mustAIConfig(t).ProviderEnv() + if env["OPENROUTER_API_KEY"] != "or-key" { + t.Errorf("OPENROUTER_API_KEY = %q", env["OPENROUTER_API_KEY"]) + } + if env["GH_TOKEN"] != "gh-tok" { + t.Errorf("GH_TOKEN = %q", env["GH_TOKEN"]) + } + // Unset credentials must not be forwarded. + if _, ok := env["ANTHROPIC_API_KEY"]; ok { + t.Errorf("ANTHROPIC_API_KEY should be absent") + } + if env["XDG_DATA_HOME"] != xdg { + t.Errorf("XDG_DATA_HOME = %q, want %q", env["XDG_DATA_HOME"], xdg) + } + + // With XDG_DATA_HOME unset, ProviderEnv falls back to a tmp dir and creates + // it. + t.Setenv("XDG_DATA_HOME", "") + _ = os.Unsetenv("XDG_DATA_HOME") + env2 := mustAIConfig(t).ProviderEnv() + wantXDG := filepath.Join(os.TempDir(), "opencode-shared-data") + if env2["XDG_DATA_HOME"] != wantXDG { + t.Errorf("fallback XDG_DATA_HOME = %q, want %q", env2["XDG_DATA_HOME"], wantXDG) + } + if st, err := os.Stat(wantXDG); err != nil || !st.IsDir() { + t.Errorf("fallback XDG dir not created: %v", err) + } +} + +// --------------------------------------------------------------------------- +// BudgetConfig / evidence-pack + numeric table. +// --------------------------------------------------------------------------- + +func TestDefaultBudgetConfig(t *testing.T) { + clearConfigEnv(t) + b := DefaultBudgetConfig() + if b.MaxCostUSD != 2.0 || b.MaxDurationSeconds != 1800 { + t.Errorf("caps = %v/%d, want 2.0/1800", b.MaxCostUSD, b.MaxDurationSeconds) + } + if b.MaxConcurrentReviewers != 8 || b.MaxReferenceFollowsPerReviewer != 3 || + b.MaxChildSpawnsPerReviewer != 2 || b.MaxCrossRefDeepDives != 5 || + b.MaxCoverageIterations != 2 || b.MaxReviewDepth != 2 { + t.Errorf("loop caps mismatch: %+v", b) + } + if !b.EvidencePackReviewers { + t.Errorf("EvidencePackReviewers = false, want true (default ON)") + } + wantPhases := map[string]float64{ + "intake": 0.05, "anatomy": 0.15, "meta_selectors": 0.30, "review": 0.90, + "adversary": 0.40, "cross_ref": 0.30, "coverage": 0.10, "synthesis": 0.0, "output": 0.0, + } + if !reflect.DeepEqual(b.PhaseBudgets, wantPhases) { + t.Errorf("PhaseBudgets = %v, want %v", b.PhaseBudgets, wantPhases) + } +} + +func TestEvidencePackToggle(t *testing.T) { + for _, tc := range []struct { + val string + want bool + }{ + {"0", false}, {"false", false}, {"no", false}, {"FALSE", false}, + {"1", true}, {"true", true}, {"yes", true}, {"anything", true}, + } { + t.Run(tc.val, func(t *testing.T) { + clearConfigEnv(t) + t.Setenv("PR_AF_EVIDENCE_PACK", tc.val) + if got := DefaultBudgetConfig().EvidencePackReviewers; got != tc.want { + t.Errorf("PR_AF_EVIDENCE_PACK=%q -> %v, want %v", tc.val, got, tc.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// CommentConfig / post-worthiness gate. +// --------------------------------------------------------------------------- + +func TestDefaultCommentConfig(t *testing.T) { + clearConfigEnv(t) + c := DefaultCommentConfig() + if c.MinSeverity != "nitpick" || c.MaxComments != 25 { + t.Errorf("MinSeverity/MaxComments = %q/%d", c.MinSeverity, c.MaxComments) + } + if !c.IncludeSuggestions || !c.IncludeDimensionAttribution || !c.IncludeConfidence { + t.Errorf("include flags = %+v, want all true", c) + } + if c.SuggestionMode != "comment" || !c.PolishEnabled || !c.MergeGateEnabled { + t.Errorf("suggestion/polish/merge = %+v", c) + } + if c.PostWorthinessGate { + t.Errorf("PostWorthinessGate = true, want false (default OFF)") + } + if c.SeverityEmojis["critical"] != "🔴" || c.SeverityEmojis["nitpick"] != "⚪" { + t.Errorf("SeverityEmojis = %v", c.SeverityEmojis) + } +} + +func TestPostWorthinessToggle(t *testing.T) { + for _, tc := range []struct { + val string + want bool + }{ + {"1", true}, {"true", true}, {"yes", true}, {"YES", true}, + {"0", false}, {"false", false}, {"", false}, {"maybe", false}, + } { + t.Run("v_"+tc.val, func(t *testing.T) { + clearConfigEnv(t) + t.Setenv("PR_AF_POSTWORTHINESS_GATE", tc.val) + if got := DefaultCommentConfig().PostWorthinessGate; got != tc.want { + t.Errorf("PR_AF_POSTWORTHINESS_GATE=%q -> %v, want %v", tc.val, got, tc.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// HITLConfig. +// --------------------------------------------------------------------------- + +func TestDefaultHITLConfig(t *testing.T) { + clearConfigEnv(t) + h := DefaultHITLConfig() + if h.Enabled { + t.Errorf("Enabled = true, want false (no HAX_API_KEY)") + } + if h.ApprovalUserID != nil { + t.Errorf("ApprovalUserID = %v, want nil", *h.ApprovalUserID) + } + if h.ApprovalExpiresInHours != 72 || h.MaxReviewRevisions != 2 { + t.Errorf("caps = %d/%d, want 72/2", h.ApprovalExpiresInHours, h.MaxReviewRevisions) + } + + // HAX_API_KEY set (non-blank) enables HITL; blank whitespace does not. + clearConfigEnv(t) + t.Setenv("HAX_API_KEY", "secret") + t.Setenv("AGENTFIELD_APPROVAL_USER_ID", "user-9") + h2 := DefaultHITLConfig() + if !h2.Enabled { + t.Errorf("Enabled = false with HAX_API_KEY set") + } + if h2.ApprovalUserID == nil || *h2.ApprovalUserID != "user-9" { + t.Errorf("ApprovalUserID = %v, want user-9", h2.ApprovalUserID) + } + + clearConfigEnv(t) + t.Setenv("HAX_API_KEY", " ") + if DefaultHITLConfig().Enabled { + t.Errorf("whitespace HAX_API_KEY should not enable HITL") + } +} + +// --------------------------------------------------------------------------- +// Depth profiles + thresholds (static). +// --------------------------------------------------------------------------- + +func TestDepthProfiles(t *testing.T) { + want := map[string]DepthProfile{ + "quick": {MaxDimensions: 3, ModelTier: "budget"}, + "standard": {MaxDimensions: 6, ModelTier: "standard"}, + "deep": {MaxDimensions: 12, ModelTier: "premium"}, + } + if !reflect.DeepEqual(DepthProfiles, want) { + t.Errorf("DepthProfiles = %v, want %v", DepthProfiles, want) + } + wantThresholds := []AutoDepthThreshold{{100, "quick"}, {500, "standard"}} + if !reflect.DeepEqual(AutoDepthThresholds, wantThresholds) { + t.Errorf("AutoDepthThresholds = %v, want %v", AutoDepthThresholds, wantThresholds) + } +} + +// --------------------------------------------------------------------------- +// ReviewConfig.FromInput merge semantics. +// --------------------------------------------------------------------------- + +func TestDefaultReviewConfigIgnorePaths(t *testing.T) { + clearConfigEnv(t) + c := DefaultReviewConfig() + if len(c.IgnorePaths) != 11 { + t.Errorf("IgnorePaths len = %d, want 11", len(c.IgnorePaths)) + } + if c.Hints == nil { + t.Errorf("Hints = nil, want non-nil empty") + } +} + +func TestFromInputBudgetCapsResolvedToPerCallDefault(t *testing.T) { + // Key parity check: with no explicit caps and no env, FromInput must set the + // per-call duration to 300 (the resolved default) — NOT the 1800 BudgetConfig + // default, which Python always overwrites. + clearConfigEnv(t) + c := mustFromInput(t, schemas.ReviewInput{MaxReviewDepth: 2}) + if c.Budget.MaxCostUSD != 2.0 { + t.Errorf("MaxCostUSD = %v, want 2.0", c.Budget.MaxCostUSD) + } + if c.Budget.MaxDurationSeconds != 300 { + t.Errorf("MaxDurationSeconds = %d, want 300 (per-call default, not 1800)", c.Budget.MaxDurationSeconds) + } +} + +func TestFromInputMergeSemantics(t *testing.T) { + clearConfigEnv(t) + in := schemas.ReviewInput{ + MaxCostUSD: ptrF(7.5), + MaxDurationSeconds: ptrI(150), + MaxConcurrentReviewers: ptrI(4), + MaxCoverageIterations: ptrI(5), + MaxReviewDepth: 10, // clamps to 3 + Models: map[string]string{"reviewer": "anthropic/claude-x", "bogus_field": "ignored"}, + IgnorePaths: []string{"custom/**", "*.md"}, // *.md dups a default + Hints: []string{"be strict", "check nil derefs"}, + SuggestionMode: "code", + } + c := mustFromInput(t, in) + + if c.Budget.MaxCostUSD != 7.5 || c.Budget.MaxDurationSeconds != 150 { + t.Errorf("explicit caps = %v/%d, want 7.5/150", c.Budget.MaxCostUSD, c.Budget.MaxDurationSeconds) + } + if c.Budget.MaxConcurrentReviewers != 4 { + t.Errorf("MaxConcurrentReviewers = %d, want 4", c.Budget.MaxConcurrentReviewers) + } + if c.Budget.MaxCoverageIterations != 5 { + t.Errorf("MaxCoverageIterations = %d, want 5", c.Budget.MaxCoverageIterations) + } + if c.Budget.MaxReviewDepth != 3 { + t.Errorf("MaxReviewDepth = %d, want 3 (clamped from 10)", c.Budget.MaxReviewDepth) + } + if c.Model.Reviewer != "anthropic/claude-x" { + t.Errorf("Model.Reviewer = %q, want anthropic/claude-x", c.Model.Reviewer) + } + // Unknown model key ignored; other fields keep defaults. + if c.Model.Planner != "premium" { + t.Errorf("Model.Planner = %q, want premium (unchanged)", c.Model.Planner) + } + // ignore_paths union deduped: *.md appears once, custom/** present. + if countOf(c.IgnorePaths, "*.md") != 1 { + t.Errorf("*.md count = %d, want 1 (deduped)", countOf(c.IgnorePaths, "*.md")) + } + if countOf(c.IgnorePaths, "custom/**") != 1 { + t.Errorf("custom/** missing from union: %v", c.IgnorePaths) + } + if len(c.IgnorePaths) != 12 { // 11 defaults + custom/** (*.md deduped) + t.Errorf("IgnorePaths len = %d, want 12", len(c.IgnorePaths)) + } + if !reflect.DeepEqual(c.Hints, []string{"be strict", "check nil derefs"}) { + t.Errorf("Hints = %v", c.Hints) + } + if c.Comments.SuggestionMode != "code" { + t.Errorf("SuggestionMode = %q, want code", c.Comments.SuggestionMode) + } +} + +func TestFromInputReviewDepthClampVariants(t *testing.T) { + clearConfigEnv(t) + for _, tc := range []struct{ in, want int }{ + {1, 1}, {2, 2}, {3, 3}, {4, 3}, {99, 3}, + } { + c := mustFromInput(t, schemas.ReviewInput{MaxReviewDepth: tc.in}) + if c.Budget.MaxReviewDepth != tc.want { + t.Errorf("FromInput MaxReviewDepth(%d) = %d, want %d", tc.in, c.Budget.MaxReviewDepth, tc.want) + } + } +} + +func TestFromInputEmptyOverridesKeepDefaults(t *testing.T) { + clearConfigEnv(t) + // Empty hints / suggestion_mode leave the defaults intact. + c := mustFromInput(t, schemas.ReviewInput{MaxReviewDepth: 2, Hints: nil, SuggestionMode: ""}) + if len(c.Hints) != 0 { + t.Errorf("Hints = %v, want empty (unchanged)", c.Hints) + } + if c.Comments.SuggestionMode != "comment" { + t.Errorf("SuggestionMode = %q, want comment (unchanged)", c.Comments.SuggestionMode) + } + if len(c.IgnorePaths) != 11 { + t.Errorf("IgnorePaths = %d, want 11 (no union)", len(c.IgnorePaths)) + } +} + +func countOf(xs []string, v string) int { + n := 0 + for _, x := range xs { + if x == v { + n++ + } + } + return n +} diff --git a/go/internal/config/depth.go b/go/internal/config/depth.go new file mode 100644 index 0000000..aff3688 --- /dev/null +++ b/go/internal/config/depth.go @@ -0,0 +1,32 @@ +package config + +// DepthProfile ports config.py DepthProfile — a pre-built review-depth profile. +type DepthProfile struct { + MaxDimensions int `json:"max_dimensions"` + ModelTier string `json:"model_tier"` // budget | standard | premium +} + +// DepthProfiles ports config.py DEPTH_PROFILES. These are static (no env), so a +// package var is fine. +var DepthProfiles = map[string]DepthProfile{ + "quick": {MaxDimensions: 3, ModelTier: "budget"}, + "standard": {MaxDimensions: 6, ModelTier: "standard"}, + "deep": {MaxDimensions: 12, ModelTier: "premium"}, +} + +// AutoDepthThreshold maps a lines-changed ceiling to a depth. Ordered ascending; +// the first threshold whose Lines value the change count is under selects the +// depth. A change over the last threshold (>500) falls through to "deep". +type AutoDepthThreshold struct { + Lines int + Depth string +} + +// AutoDepthThresholds ports config.py AUTO_DEPTH_THRESHOLDS: <100 -> quick, +// 100-500 -> standard, >500 -> deep. Kept as an ordered slice (Python relied on +// insertion order of the {100:..,500:..} dict) so consumers can range in order. +var AutoDepthThresholds = []AutoDepthThreshold{ + {Lines: 100, Depth: "quick"}, // < 100 lines -> quick + {Lines: 500, Depth: "standard"}, // 100-500 lines -> standard + // > 500 lines -> deep (fall-through) +} diff --git a/go/internal/config/hitl.go b/go/internal/config/hitl.go new file mode 100644 index 0000000..9373fd6 --- /dev/null +++ b/go/internal/config/hitl.go @@ -0,0 +1,42 @@ +package config + +import ( + "os" + "strings" +) + +// HITLConfig ports config.py HITLConfig — the human-in-the-loop review gate. +// Enabled and ApprovalUserID are env-driven (read at call time); the two numeric +// caps are plain defaults (§D: approval_expires_in_hours=72, +// max_review_revisions=2). HITL is actually active only when HAX_API_KEY is set +// — the same trigger the hax client uses; this struct mirrors it for +// observability. +type HITLConfig struct { + Enabled bool `json:"enabled"` + // ApprovalUserID optionally routes the request to a specific hax user (nil + // when unset/blank). + ApprovalUserID *string `json:"approval_user_id"` + ApprovalExpiresInHours int `json:"approval_expires_in_hours"` + MaxReviewRevisions int `json:"max_review_revisions"` +} + +// DefaultHITLConfig builds the config.py HITL defaults, reading HAX_API_KEY and +// AGENTFIELD_APPROVAL_USER_ID at call time. +func DefaultHITLConfig() HITLConfig { + return HITLConfig{ + Enabled: strings.TrimSpace(os.Getenv("HAX_API_KEY")) != "", + ApprovalUserID: approvalUserID(), + ApprovalExpiresInHours: 72, + MaxReviewRevisions: 2, + } +} + +// approvalUserID ports `os.getenv("AGENTFIELD_APPROVAL_USER_ID") or None`: an +// unset OR empty value yields nil. +func approvalUserID() *string { + v := os.Getenv("AGENTFIELD_APPROVAL_USER_ID") + if v == "" { + return nil + } + return &v +} diff --git a/go/internal/config/review.go b/go/internal/config/review.go new file mode 100644 index 0000000..9625f6e --- /dev/null +++ b/go/internal/config/review.go @@ -0,0 +1,186 @@ +package config + +import ( + "sort" + + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// ModelConfig ports config.py ModelConfig — model routing per agent. Budget +// models for gates/classification; premium models for planning/reviewing/ +// challenging (plan quality = review quality, so the planner gets premium). +type ModelConfig struct { + IntakeGate string `json:"intake_gate"` // .ai() fast classification + IntakeFallback string `json:"intake_fallback"` // .harness() when not confident + AnatomySemantic string `json:"anatomy_semantic"` // Narrative understanding + Planner string `json:"planner"` // THE critical agent + Reviewer string `json:"reviewer"` // Deep code analysis + CrossRef string `json:"cross_ref"` // Interaction detection + Adversary string `json:"adversary"` // Challenge quality + CoverageGate string `json:"coverage_gate"` // Simple completeness check + DedupGate string `json:"dedup_gate"` // Near-duplicate detection +} + +// DefaultModelConfig builds the config.py model-routing defaults. +func DefaultModelConfig() ModelConfig { + return ModelConfig{ + IntakeGate: "budget", + IntakeFallback: "mid", + AnatomySemantic: "mid", + Planner: "premium", + Reviewer: "premium", + CrossRef: "premium", + Adversary: "premium", + CoverageGate: "budget", + DedupGate: "budget", + } +} + +// setByJSONName sets the field identified by its snake_case json name, mirroring +// Python's `setattr(config.models, field_name, model_id) if hasattr(...)`. +// Unknown names are ignored (returns false). +func (m *ModelConfig) setByJSONName(name, value string) bool { + switch name { + case "intake_gate": + m.IntakeGate = value + case "intake_fallback": + m.IntakeFallback = value + case "anatomy_semantic": + m.AnatomySemantic = value + case "planner": + m.Planner = value + case "reviewer": + m.Reviewer = value + case "cross_ref": + m.CrossRef = value + case "adversary": + m.Adversary = value + case "coverage_gate": + m.CoverageGate = value + case "dedup_gate": + m.DedupGate = value + default: + return false + } + return true +} + +// ReviewConfig ports config.py ReviewConfig — the top-level config combining all +// sub-configs. The Go `Model` field carries the json tag "models" to match the +// Python field name. +type ReviewConfig struct { + Budget BudgetConfig `json:"budget"` + Model ModelConfig `json:"models"` + Scoring ScoringConfig `json:"scoring"` + Comments CommentConfig `json:"comments"` + HITL HITLConfig `json:"hitl"` + + IgnorePaths []string `json:"ignore_paths"` + Hints []string `json:"hints"` + DepthRules []map[string]any `json:"depth_rules"` +} + +// DefaultIgnorePaths is the config.py glob ignore list. Returned fresh per call. +func DefaultIgnorePaths() []string { + return []string{ + "*.md", + "*.txt", + ".github/**", + "vendor/**", + "node_modules/**", + "**/*.generated.*", + "**/*.min.js", + "**/*.min.css", + "**/package-lock.json", + "**/yarn.lock", + "**/poetry.lock", + } +} + +// DefaultReviewConfig assembles a ReviewConfig from the sub-config defaults +// (the Go analog of pydantic's ReviewConfig() with its default_factory fields). +func DefaultReviewConfig() ReviewConfig { + return ReviewConfig{ + Budget: DefaultBudgetConfig(), + Model: DefaultModelConfig(), + Scoring: DefaultScoringConfig(), + Comments: DefaultCommentConfig(), + HITL: DefaultHITLConfig(), + IgnorePaths: DefaultIgnorePaths(), + Hints: []string{}, + DepthRules: []map[string]any{}, + } +} + +// FromInput ports ReviewConfig.from_input — it merges per-call API overrides +// into the defaults. The value receiver is ignored (matching the Python +// classmethod semantics: it always starts from a fresh default config). +// +// Budget caps: Python resolves max_cost_usd / max_duration_seconds in review() +// BEFORE constructing the ReviewInput, so from_input always writes a concrete +// resolved value over the BudgetConfig defaults (which is why the 1800-second +// BudgetConfig default is dead — the per-call value is 300 by default). The Go +// ReviewInput carries these as *float64 / *int, so FromInput runs the same +// ResolveBudgetCaps cascade (explicit > env > 2.0/300) to reproduce that +// effective value exactly. +func (ReviewConfig) FromInput(in schemas.ReviewInput) (ReviewConfig, error) { + c := DefaultReviewConfig() + + cost, dur, err := ResolveBudgetCaps(in.MaxCostUSD, in.MaxDurationSeconds) + if err != nil { + return ReviewConfig{}, err + } + c.Budget.MaxCostUSD = cost + c.Budget.MaxDurationSeconds = dur + + if in.MaxConcurrentReviewers != nil { + c.Budget.MaxConcurrentReviewers = *in.MaxConcurrentReviewers + } + if in.MaxCoverageIterations != nil { + c.Budget.MaxCoverageIterations = *in.MaxCoverageIterations + } + // max_review_depth clamped to 3 (1=flat, 2=one sub-level, 3=max). review() + // also clamps at the node-bind layer, so this is the second of two clamps. + c.Budget.MaxReviewDepth = min(in.MaxReviewDepth, 3) + + if len(in.Models) > 0 { + for field, modelID := range in.Models { + c.Model.setByJSONName(field, modelID) + } + } + + if len(in.IgnorePaths) > 0 { + c.IgnorePaths = unionSorted(c.IgnorePaths, in.IgnorePaths) + } + + if len(in.Hints) > 0 { + // Python replaces (config.hints = review_input.hints), not merges. + c.Hints = append([]string(nil), in.Hints...) + } + + if in.SuggestionMode != "" { + c.Comments.SuggestionMode = in.SuggestionMode + } + + return c, nil +} + +// unionSorted returns the deduplicated union of a and b. Python uses +// list(set(...)), whose iteration order is unspecified (and varies per run); the +// contents are what the contract fixes, so Go sorts the result for a +// deterministic order (safe: ignore_paths feed order-independent glob matching). +func unionSorted(a, b []string) []string { + seen := make(map[string]struct{}, len(a)+len(b)) + for _, s := range a { + seen[s] = struct{}{} + } + for _, s := range b { + seen[s] = struct{}{} + } + out := make([]string, 0, len(seen)) + for s := range seen { + out = append(out, s) + } + sort.Strings(out) + return out +} diff --git a/go/internal/config/scoring.go b/go/internal/config/scoring.go new file mode 100644 index 0000000..b002eb9 --- /dev/null +++ b/go/internal/config/scoring.go @@ -0,0 +1,36 @@ +package config + +// ScoringConfig ports config.py ScoringConfig — deterministic scoring weights +// and multipliers (§D numeric tables). LLMs reason about issues; code computes +// scores, so the same findings always produce the same scores. Maps are +// returned fresh per call so a caller cannot mutate a shared default. +type ScoringConfig struct { + BaseWeights map[string]float64 `json:"base_weights"` + Multipliers map[string]float64 `json:"multipliers"` + ConfidenceThresholds map[string]float64 `json:"confidence_thresholds"` +} + +// DefaultScoringConfig builds the config.py scoring defaults. +func DefaultScoringConfig() ScoringConfig { + return ScoringConfig{ + BaseWeights: map[string]float64{ + "critical": 1.0, + "important": 0.7, + "suggestion": 0.3, + "nitpick": 0.1, + }, + Multipliers: map[string]float64{ + "cross_ref_compound": 1.5, // Cross-ref found compound risk + "adversary_confirmed": 1.3, // Adversary confirmed exploitation + "adversary_challenged": 0.5, // Adversary successfully challenged + "ai_generated_pr": 1.2, // Extra weight for AI-generated PRs + "blast_radius_high": 1.2, // Change affects many files (>10) + }, + ConfidenceThresholds: map[string]float64{ + "critical": 0.2, + "important": 0.3, + "suggestion": 0.4, + "nitpick": 0.4, + }, + } +} diff --git a/go/internal/diffengine/cluster.go b/go/internal/diffengine/cluster.go new file mode 100644 index 0000000..a3b6b33 --- /dev/null +++ b/go/internal/diffengine/cluster.go @@ -0,0 +1,84 @@ +package diffengine + +import ( + "fmt" + "sort" + "strings" + + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// ClusterChanges ports cluster_changes: group files by their directory (the +// path up to the last "/"; "root" when there is no "/"), then emit one cluster +// per directory in sorted-directory order with deterministic ids "cluster_0", +// "cluster_1", ... The primary language is the most frequent language in the +// group. +// +// Divergence note: Python computes primary_language via +// `max(set(langs), key=langs.count)`, whose tie-break depends on set iteration +// order and is therefore non-deterministic across Python processes (str hash +// randomization). This port instead breaks ties deterministically by +// first-appearance order within the group. For a unique mode (the overwhelming +// common case, including single-language groups) both agree exactly. +func ClusterChanges(files []schemas.FileChange) []schemas.ChangeCluster { + // Group by directory. Go maps do not preserve insertion order, so the + // directory keys are sorted explicitly below to match Python's + // sorted(dir_groups.items()). Per-group file order is preserved by append. + groups := map[string][]schemas.FileChange{} + for _, f := range files { + directory := "root" + if idx := strings.LastIndex(f.Path, "/"); idx >= 0 { + directory = f.Path[:idx] + } + groups[directory] = append(groups[directory], f) + } + + dirs := make([]string, 0, len(groups)) + for d := range groups { + dirs = append(dirs, d) + } + sort.Strings(dirs) + + clusters := make([]schemas.ChangeCluster, 0, len(dirs)) + for i, directory := range dirs { + groupFiles := groups[directory] + paths := make([]string, 0, len(groupFiles)) + for _, f := range groupFiles { + paths = append(paths, f.Path) + } + clusters = append(clusters, schemas.ChangeCluster{ + ID: fmt.Sprintf("cluster_%d", i), + Name: directory, + Files: paths, + PrimaryLanguage: primaryLanguage(groupFiles), + }) + } + return clusters +} + +// primaryLanguage returns the most frequent non-empty language among the files, +// breaking count ties by first appearance in the group (deterministic). +func primaryLanguage(files []schemas.FileChange) string { + langs := make([]string, 0, len(files)) + for _, f := range files { + if f.Language != "" { + langs = append(langs, f.Language) + } + } + if len(langs) == 0 { + return "" + } + counts := map[string]int{} + for _, l := range langs { + counts[l]++ + } + best := "" + bestCount := -1 + for _, l := range langs { + if counts[l] > bestCount { + bestCount = counts[l] + best = l + } + } + return best +} diff --git a/go/internal/diffengine/diff.go b/go/internal/diffengine/diff.go new file mode 100644 index 0000000..4171430 --- /dev/null +++ b/go/internal/diffengine/diff.go @@ -0,0 +1,249 @@ +// Package diffengine ports src/pr_af/diff_engine.py — the deterministic +// (non-LLM) diff parser and clustering engine that feeds the anatomy phase. +// +// It is a byte-for-byte behavioral port of the Python module. Where the Python +// implementation has quirks (see notes below) they are reproduced exactly +// rather than "fixed", because downstream golden comparisons and cluster/finding +// ordering depend on identical output: +// +// - parse_unified_diff never emits a "renamed" status. Git rename headers +// (`rename from`/`rename to`, `similarity index`) are not special-cased; a +// rename surfaces as status "modified" with the path taken from the +// `+++ b/` line (or "" for a pure content-free rename that has no `+++ b/`). +// - A deleted file (`+++ /dev/null`) never sets a path, so its Path is "". +// - Binary files (no `---`/`+++` lines) yield a FileChange with Path "" and no +// hunks. +// - Line splitting matches Python's str.splitlines() (not strings.Split on +// "\n"): all Unicode line boundaries split, and a trailing line break does +// NOT produce a final empty element. This keeps hunk `content` byte-exact. +package diffengine + +import ( + "regexp" + "strconv" + "strings" + + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// hunkRe mirrors the Python regex `@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)` +// used with re.match (anchored at start). The trailing `(.*)` group is captured +// but unused in Python, so it is dropped here — its presence never changes +// whether the pattern matches. +var hunkRe = regexp.MustCompile(`^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@`) + +// fileAccum is the mutable per-file accumulator, mirroring the Python +// `current_file` dict. +type fileAccum struct { + path string + status string + hunks []*hunkAccum + added int + removed int +} + +// hunkAccum is the mutable per-hunk accumulator, mirroring `current_hunk`. +type hunkAccum struct { + oldStart int + oldCount int + newStart int + newCount int + header string + lines []string +} + +// ParseUnifiedDiff parses unified diff text into structured FileChange objects. +// It is a direct port of Python parse_unified_diff, preserving branch order and +// the accumulation quirks documented on the package. +func ParseUnifiedDiff(diffText string) []schemas.FileChange { + files := make([]schemas.FileChange, 0) + var currentFile *fileAccum + var currentHunk *hunkAccum + + for _, line := range splitLines(diffText) { + switch { + // New file header — note: no `and current_file` guard in Python, this + // branch fires for any line starting with "diff --git". + case strings.HasPrefix(line, "diff --git"): + if currentFile != nil { + files = append(files, buildFileChange(currentFile)) + } + currentFile = &fileAccum{status: "modified"} + currentHunk = nil + + case strings.HasPrefix(line, "--- a/") && currentFile != nil: + // old file path (handled by the +++ line) — intentionally ignored. + + case strings.HasPrefix(line, "--- /dev/null") && currentFile != nil: + currentFile.status = "added" + + case strings.HasPrefix(line, "+++ b/") && currentFile != nil: + currentFile.path = line[6:] + + case strings.HasPrefix(line, "+++ /dev/null") && currentFile != nil: + currentFile.status = "removed" + + case strings.HasPrefix(line, "@@") && currentFile != nil: + if m := hunkRe.FindStringSubmatch(line); m != nil { + currentHunk = &hunkAccum{ + oldStart: atoiOr(m[1], 0), + oldCount: atoiOr(m[2], 1), + newStart: atoiOr(m[3], 0), + newCount: atoiOr(m[4], 1), + header: line, + lines: []string{}, + } + currentFile.hunks = append(currentFile.hunks, currentHunk) + } + // A malformed "@@" line (no regex match) is dropped: current_hunk is + // left unchanged and the line is not accumulated — matching Python. + + case currentHunk != nil && currentFile != nil: + if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") { + currentFile.added++ + } else if strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") { + currentFile.removed++ + } + currentHunk.lines = append(currentHunk.lines, line) + } + } + + if currentFile != nil { + files = append(files, buildFileChange(currentFile)) + } + + return files +} + +// buildFileChange ports _build_file_change: joins each hunk's lines with "\n" +// and resolves the language from the path. +func buildFileChange(raw *fileAccum) schemas.FileChange { + hunks := make([]schemas.Hunk, 0, len(raw.hunks)) + for _, h := range raw.hunks { + hunks = append(hunks, schemas.Hunk{ + OldStart: h.oldStart, + OldCount: h.oldCount, + NewStart: h.newStart, + NewCount: h.newCount, + Header: h.header, + Content: strings.Join(h.lines, "\n"), + }) + } + return schemas.FileChange{ + Path: raw.path, + Status: raw.status, + Language: detectLanguage(raw.path), + LinesAdded: raw.added, + LinesRemoved: raw.removed, + Hunks: hunks, + } +} + +// atoiOr returns the integer value of s, or def when s is empty. The regex +// guarantees s is either empty or all digits, so a parse error is impossible; +// def is returned defensively if one ever occurs. +func atoiOr(s string, def int) int { + if s == "" { + return def + } + n, err := strconv.Atoi(s) + if err != nil { + return def + } + return n +} + +// extLang is one (extension, language) mapping. A slice preserves Python's dict +// insertion order, which the "first suffix match wins" lookup depends on (Go +// map iteration order is randomized and would break parity). +type extLang struct { + ext string + lang string +} + +// langTable mirrors the Python _detect_language ext_map, in declaration order. +var langTable = []extLang{ + {".py", "python"}, + {".js", "javascript"}, + {".ts", "typescript"}, + {".tsx", "typescript"}, + {".jsx", "javascript"}, + {".go", "go"}, + {".rs", "rust"}, + {".java", "java"}, + {".rb", "ruby"}, + {".cpp", "cpp"}, + {".c", "c"}, + {".cs", "csharp"}, + {".swift", "swift"}, + {".kt", "kotlin"}, + {".scala", "scala"}, + {".php", "php"}, + {".sh", "bash"}, + {".yaml", "yaml"}, + {".yml", "yaml"}, + {".json", "json"}, + {".md", "markdown"}, + {".sql", "sql"}, + {".html", "html"}, + {".css", "css"}, +} + +// detectLanguage ports _detect_language: returns the language for the first +// table extension the path ends with, or "" if none match. +func detectLanguage(path string) string { + for _, e := range langTable { + if strings.HasSuffix(path, e.ext) { + return e.lang + } + } + return "" +} + +// splitLines reproduces Python's str.splitlines(): it splits on every Unicode +// line boundary CPython recognizes, treats "\r\n" as a single boundary, and — +// crucially — does NOT emit a trailing empty element when the text ends with a +// line break. This keeps hunk content byte-identical to the Python port. +func splitLines(s string) []string { + var lines []string + var b strings.Builder + runes := []rune(s) + for i := 0; i < len(runes); { + c := runes[i] + if isLineBoundary(c) { + lines = append(lines, b.String()) + b.Reset() + if c == '\r' && i+1 < len(runes) && runes[i+1] == '\n' { + i += 2 + } else { + i++ + } + continue + } + b.WriteRune(c) + i++ + } + if b.Len() > 0 { + lines = append(lines, b.String()) + } + return lines +} + +// isLineBoundary reports whether r is one of the line boundaries recognized by +// Python's str.splitlines(). +func isLineBoundary(r rune) bool { + switch r { + case '\n', // Line Feed + '\r', // Carriage Return + '\v', // Line Tabulation (0x0b) + '\f', // Form Feed (0x0c) + 0x1c, // File Separator + 0x1d, // Group Separator + 0x1e, // Record Separator + 0x85, // Next Line (C1) + 0x2028, // Line Separator + 0x2029: // Paragraph Separator + return true + } + return false +} diff --git a/go/internal/diffengine/diffengine_test.go b/go/internal/diffengine/diffengine_test.go new file mode 100644 index 0000000..7299bc8 --- /dev/null +++ b/go/internal/diffengine/diffengine_test.go @@ -0,0 +1,425 @@ +package diffengine + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// Expected values in this file are ground truth captured from the Python source +// of truth (src/pr_af/diff_engine.py) run against each fixture — see +// scratchpad/gen_truth.py. Tests are derived from observable behavior (exact +// FileChange/Hunk fields, DiffStats numbers, cluster membership/ids/order), not +// from the Go implementation. + +// hunk is a terse Hunk constructor for expected values. +func hunk(oldStart, oldCount, newStart, newCount int, header, content string) schemas.Hunk { + return schemas.Hunk{ + OldStart: oldStart, OldCount: oldCount, + NewStart: newStart, NewCount: newCount, + Header: header, Content: content, + } +} + +// jsonEq marshals two values to JSON and reports whether they are identical, +// returning both renderings for a readable failure message. Both arguments are +// the same concrete Go type, so JSON key ordering is identical and any +// difference is a genuine value difference. +func jsonEq(t *testing.T, got, want any) (string, string, bool) { + t.Helper() + g, err := json.MarshalIndent(got, "", " ") + if err != nil { + t.Fatalf("marshal got: %v", err) + } + w, err := json.MarshalIndent(want, "", " ") + if err != nil { + t.Fatalf("marshal want: %v", err) + } + return string(g), string(w), reflect.DeepEqual(got, want) +} + +type diffCase struct { + name string + diff string + wantFiles []schemas.FileChange + wantStats schemas.DiffStats + wantClusters []schemas.ChangeCluster +} + +func diffCases() []diffCase { + return []diffCase{ + { + name: "empty", + diff: "", + wantFiles: []schemas.FileChange{}, + wantStats: schemas.DiffStats{ + TotalFiles: 0, TotalAdditions: 0, TotalDeletions: 0, + FilesAdded: 0, FilesModified: 0, FilesRemoved: 0, FilesRenamed: 0, + TestFilesChanged: 0, TestToCodeRatio: 0.0, + }, + wantClusters: []schemas.ChangeCluster{}, + }, + { + name: "single_modified", + diff: "diff --git a/src/app.py b/src/app.py\n" + + "index 111..222 100644\n" + + "--- a/src/app.py\n" + + "+++ b/src/app.py\n" + + "@@ -10,3 +10,4 @@ def handler():\n" + + " context line\n" + + "-old line\n" + + "+new line one\n" + + "+new line two\n", + wantFiles: []schemas.FileChange{{ + Path: "src/app.py", Status: "modified", Language: "python", + LinesAdded: 2, LinesRemoved: 1, + Hunks: []schemas.Hunk{hunk(10, 3, 10, 4, "@@ -10,3 +10,4 @@ def handler():", + " context line\n-old line\n+new line one\n+new line two")}, + }}, + wantStats: schemas.DiffStats{ + TotalFiles: 1, TotalAdditions: 2, TotalDeletions: 1, + FilesModified: 1, TestToCodeRatio: 0.0, + }, + wantClusters: []schemas.ChangeCluster{ + {ID: "cluster_0", Name: "src", Files: []string{"src/app.py"}, PrimaryLanguage: "python"}, + }, + }, + { + name: "omitted_counts", + diff: "diff --git a/x.go b/x.go\n--- a/x.go\n+++ b/x.go\n@@ -5 +6 @@\n-a\n+b\n", + wantFiles: []schemas.FileChange{{ + Path: "x.go", Status: "modified", Language: "go", + LinesAdded: 1, LinesRemoved: 1, + Hunks: []schemas.Hunk{hunk(5, 1, 6, 1, "@@ -5 +6 @@", "-a\n+b")}, + }}, + wantStats: schemas.DiffStats{TotalFiles: 1, TotalAdditions: 1, TotalDeletions: 1, FilesModified: 1}, + wantClusters: []schemas.ChangeCluster{ + {ID: "cluster_0", Name: "root", Files: []string{"x.go"}, PrimaryLanguage: "go"}, + }, + }, + { + name: "new_file", + diff: "diff --git a/newmod.ts b/newmod.ts\n" + + "new file mode 100644\nindex 000..abc\n" + + "--- /dev/null\n+++ b/newmod.ts\n" + + "@@ -0,0 +1,2 @@\n+export const x = 1;\n+export const y = 2;\n", + wantFiles: []schemas.FileChange{{ + Path: "newmod.ts", Status: "added", Language: "typescript", + LinesAdded: 2, LinesRemoved: 0, + Hunks: []schemas.Hunk{hunk(0, 0, 1, 2, "@@ -0,0 +1,2 @@", + "+export const x = 1;\n+export const y = 2;")}, + }}, + wantStats: schemas.DiffStats{TotalFiles: 1, TotalAdditions: 2, FilesAdded: 1}, + wantClusters: []schemas.ChangeCluster{ + {ID: "cluster_0", Name: "root", Files: []string{"newmod.ts"}, PrimaryLanguage: "typescript"}, + }, + }, + { + name: "deleted_file", + diff: "diff --git a/old.rb b/old.rb\ndeleted file mode 100644\nindex abc..000\n" + + "--- a/old.rb\n+++ /dev/null\n@@ -1,3 +0,0 @@\n-line1\n-line2\n-line3\n", + // Deleted files never set a path (no +++ b/ line): Path is "". + wantFiles: []schemas.FileChange{{ + Path: "", Status: "removed", Language: "", + LinesAdded: 0, LinesRemoved: 3, + Hunks: []schemas.Hunk{hunk(1, 3, 0, 0, "@@ -1,3 +0,0 @@", "-line1\n-line2\n-line3")}, + }}, + wantStats: schemas.DiffStats{TotalFiles: 1, TotalDeletions: 3, FilesRemoved: 1}, + wantClusters: []schemas.ChangeCluster{ + {ID: "cluster_0", Name: "root", Files: []string{""}, PrimaryLanguage: ""}, + }, + }, + { + name: "rename_content", + diff: "diff --git a/old_name.py b/new_name.py\nsimilarity index 90%\n" + + "rename from old_name.py\nrename to new_name.py\n" + + "--- a/old_name.py\n+++ b/new_name.py\n@@ -1,3 +1,3 @@\n ctx\n-old\n+new\n", + // Renames are NOT special-cased: status stays "modified", path from +++ b/. + wantFiles: []schemas.FileChange{{ + Path: "new_name.py", Status: "modified", Language: "python", + LinesAdded: 1, LinesRemoved: 1, + Hunks: []schemas.Hunk{hunk(1, 3, 1, 3, "@@ -1,3 +1,3 @@", " ctx\n-old\n+new")}, + }}, + wantStats: schemas.DiffStats{TotalFiles: 1, TotalAdditions: 1, TotalDeletions: 1, FilesModified: 1}, + wantClusters: []schemas.ChangeCluster{ + {ID: "cluster_0", Name: "root", Files: []string{"new_name.py"}, PrimaryLanguage: "python"}, + }, + }, + { + name: "pure_rename", + diff: "diff --git a/a.py b/b.py\nsimilarity index 100%\nrename from a.py\nrename to b.py\n", + // No +++ b/ and no hunks: Path "", status "modified", empty hunks. + wantFiles: []schemas.FileChange{{ + Path: "", Status: "modified", Language: "", Hunks: []schemas.Hunk{}, + }}, + wantStats: schemas.DiffStats{TotalFiles: 1, FilesModified: 1}, + wantClusters: []schemas.ChangeCluster{ + {ID: "cluster_0", Name: "root", Files: []string{""}, PrimaryLanguage: ""}, + }, + }, + { + name: "binary", + diff: "diff --git a/img.png b/img.png\nindex abc..def 100644\n" + + "Binary files a/img.png and b/img.png differ\n", + wantFiles: []schemas.FileChange{{ + Path: "", Status: "modified", Language: "", Hunks: []schemas.Hunk{}, + }}, + wantStats: schemas.DiffStats{TotalFiles: 1, FilesModified: 1}, + wantClusters: []schemas.ChangeCluster{ + {ID: "cluster_0", Name: "root", Files: []string{""}, PrimaryLanguage: ""}, + }, + }, + { + name: "multi_file", + diff: "diff --git a/pkg/a.go b/pkg/a.go\n--- a/pkg/a.go\n+++ b/pkg/a.go\n" + + "@@ -1,2 +1,3 @@\n x\n+added1\n" + + "@@ -10,2 +11,2 @@\n-removed10\n+added10\n" + + "diff --git a/pkg/b_test.go b/pkg/b_test.go\n--- a/pkg/b_test.go\n+++ b/pkg/b_test.go\n" + + "@@ -1,1 +1,2 @@\n+added_b\n", + wantFiles: []schemas.FileChange{ + { + Path: "pkg/a.go", Status: "modified", Language: "go", + LinesAdded: 2, LinesRemoved: 1, + Hunks: []schemas.Hunk{ + hunk(1, 2, 1, 3, "@@ -1,2 +1,3 @@", " x\n+added1"), + hunk(10, 2, 11, 2, "@@ -10,2 +11,2 @@", "-removed10\n+added10"), + }, + }, + { + Path: "pkg/b_test.go", Status: "modified", Language: "go", + LinesAdded: 1, LinesRemoved: 0, + Hunks: []schemas.Hunk{hunk(1, 1, 1, 2, "@@ -1,1 +1,2 @@", "+added_b")}, + }, + }, + wantStats: schemas.DiffStats{ + TotalFiles: 2, TotalAdditions: 3, TotalDeletions: 1, + FilesModified: 2, TestFilesChanged: 1, TestToCodeRatio: 1.0, + }, + wantClusters: []schemas.ChangeCluster{ + {ID: "cluster_0", Name: "pkg", Files: []string{"pkg/a.go", "pkg/b_test.go"}, PrimaryLanguage: "go"}, + }, + }, + { + name: "content_quirks", + diff: "diff --git a/q.py b/q.py\n--- a/q.py\n+++ b/q.py\n@@ -1,4 +1,4 @@\n" + + "+++normal_add\n---normal_del\n+real_add\n-real_del\n" + + "--- a/swallowed\n\\ No newline at end of file\n", + // "+++normal_add" / "---normal_del" are NOT counted; "--- a/swallowed" + // is swallowed by the header branch (absent from content and uncounted); + // the "\ No newline" marker is appended to content but uncounted. + wantFiles: []schemas.FileChange{{ + Path: "q.py", Status: "modified", Language: "python", + LinesAdded: 1, LinesRemoved: 1, + Hunks: []schemas.Hunk{hunk(1, 4, 1, 4, "@@ -1,4 +1,4 @@", + "+++normal_add\n---normal_del\n+real_add\n-real_del\n\\ No newline at end of file")}, + }}, + wantStats: schemas.DiffStats{TotalFiles: 1, TotalAdditions: 1, TotalDeletions: 1, FilesModified: 1}, + wantClusters: []schemas.ChangeCluster{ + {ID: "cluster_0", Name: "root", Files: []string{"q.py"}, PrimaryLanguage: "python"}, + }, + }, + { + name: "malformed_hunk", + diff: "diff --git a/m.py b/m.py\n--- a/m.py\n+++ b/m.py\n@@ -1,1 +1,1 @@\n" + + "+first\n@@ malformed hunk header\n+second\n", + // The malformed "@@" line does not match the hunk regex, so current_hunk + // is left unchanged and "+second" folds into the first hunk. + wantFiles: []schemas.FileChange{{ + Path: "m.py", Status: "modified", Language: "python", + LinesAdded: 2, LinesRemoved: 0, + Hunks: []schemas.Hunk{hunk(1, 1, 1, 1, "@@ -1,1 +1,1 @@", "+first\n+second")}, + }}, + wantStats: schemas.DiffStats{TotalFiles: 1, TotalAdditions: 2, FilesModified: 1}, + wantClusters: []schemas.ChangeCluster{ + {ID: "cluster_0", Name: "root", Files: []string{"m.py"}, PrimaryLanguage: "python"}, + }, + }, + { + name: "malformed_no_header", + diff: "this is not a diff\njust some text\n@@ -1 +1 @@\n+foo\n", + wantFiles: []schemas.FileChange{}, + wantStats: schemas.DiffStats{}, + wantClusters: []schemas.ChangeCluster{}, + }, + { + name: "header_only", + diff: "diff --git a/foo b/foo\n", + wantFiles: []schemas.FileChange{{ + Path: "", Status: "modified", Language: "", Hunks: []schemas.Hunk{}, + }}, + wantStats: schemas.DiffStats{TotalFiles: 1, FilesModified: 1}, + wantClusters: []schemas.ChangeCluster{ + {ID: "cluster_0", Name: "root", Files: []string{""}, PrimaryLanguage: ""}, + }, + }, + { + name: "cluster_mix", + diff: "diff --git a/README.md b/README.md\n--- a/README.md\n+++ b/README.md\n@@ -1 +1 @@\n+r\n" + + "diff --git a/src/a.py b/src/a.py\n--- a/src/a.py\n+++ b/src/a.py\n@@ -1 +1 @@\n+a\n" + + "diff --git a/src/b.py b/src/b.py\n--- a/src/b.py\n+++ b/src/b.py\n@@ -1 +1 @@\n+b\n" + + "diff --git a/src/c.js b/src/c.js\n--- a/src/c.js\n+++ b/src/c.js\n@@ -1 +1 @@\n+c\n", + wantFiles: []schemas.FileChange{ + {Path: "README.md", Status: "modified", Language: "markdown", LinesAdded: 1, + Hunks: []schemas.Hunk{hunk(1, 1, 1, 1, "@@ -1 +1 @@", "+r")}}, + {Path: "src/a.py", Status: "modified", Language: "python", LinesAdded: 1, + Hunks: []schemas.Hunk{hunk(1, 1, 1, 1, "@@ -1 +1 @@", "+a")}}, + {Path: "src/b.py", Status: "modified", Language: "python", LinesAdded: 1, + Hunks: []schemas.Hunk{hunk(1, 1, 1, 1, "@@ -1 +1 @@", "+b")}}, + {Path: "src/c.js", Status: "modified", Language: "javascript", LinesAdded: 1, + Hunks: []schemas.Hunk{hunk(1, 1, 1, 1, "@@ -1 +1 @@", "+c")}}, + }, + wantStats: schemas.DiffStats{TotalFiles: 4, TotalAdditions: 4, FilesModified: 4}, + wantClusters: []schemas.ChangeCluster{ + // Sorted by directory: "root" < "src". + {ID: "cluster_0", Name: "root", Files: []string{"README.md"}, PrimaryLanguage: "markdown"}, + {ID: "cluster_1", Name: "src", Files: []string{"src/a.py", "src/b.py", "src/c.js"}, PrimaryLanguage: "python"}, + }, + }, + } +} + +func TestParseUnifiedDiff(t *testing.T) { + for _, tc := range diffCases() { + t.Run(tc.name, func(t *testing.T) { + got := ParseUnifiedDiff(tc.diff) + if g, w, ok := jsonEq(t, got, tc.wantFiles); !ok { + t.Errorf("ParseUnifiedDiff files mismatch\n got: %s\nwant: %s", g, w) + } + }) + } +} + +func TestComputeDiffStats(t *testing.T) { + for _, tc := range diffCases() { + t.Run(tc.name, func(t *testing.T) { + files := ParseUnifiedDiff(tc.diff) + got := ComputeDiffStats(files) + if g, w, ok := jsonEq(t, got, tc.wantStats); !ok { + t.Errorf("ComputeDiffStats mismatch\n got: %s\nwant: %s", g, w) + } + }) + } +} + +func TestClusterChanges(t *testing.T) { + for _, tc := range diffCases() { + t.Run(tc.name, func(t *testing.T) { + files := ParseUnifiedDiff(tc.diff) + got := ClusterChanges(files) + if g, w, ok := jsonEq(t, got, tc.wantClusters); !ok { + t.Errorf("ClusterChanges mismatch\n got: %s\nwant: %s", g, w) + } + }) + } +} + +// TestParseUnifiedDiffCRLF locks in the str.splitlines() parity: a diff with +// CRLF (\r\n) line endings must parse identically to the LF version — the \r is +// consumed as part of the boundary and never leaks into headers or content. +func TestParseUnifiedDiffCRLF(t *testing.T) { + lf := "diff --git a/src/app.py b/src/app.py\n--- a/src/app.py\n+++ b/src/app.py\n" + + "@@ -1,2 +1,2 @@\n context\n-old\n+new\n" + crlf := "" + for _, r := range lf { + if r == '\n' { + crlf += "\r\n" + } else { + crlf += string(r) + } + } + gotLF := ParseUnifiedDiff(lf) + gotCRLF := ParseUnifiedDiff(crlf) + if !reflect.DeepEqual(gotLF, gotCRLF) { + lfJSON, _ := json.MarshalIndent(gotLF, "", " ") + crlfJSON, _ := json.MarshalIndent(gotCRLF, "", " ") + t.Errorf("CRLF parse differs from LF\n LF: %s\nCRLF: %s", lfJSON, crlfJSON) + } + if len(gotCRLF) != 1 || gotCRLF[0].Hunks[0].Content != " context\n-old\n+new" { + t.Errorf("CRLF content = %q, want %q", gotCRLF[0].Hunks[0].Content, " context\n-old\n+new") + } +} + +// TestParseUnifiedDiffTrailingNewline verifies that a trailing "\n" does not add +// a spurious empty final line to the last hunk's content (splitlines behavior). +func TestParseUnifiedDiffTrailingNewline(t *testing.T) { + withNL := ParseUnifiedDiff("diff --git a/t.py b/t.py\n--- a/t.py\n+++ b/t.py\n@@ -1 +1 @@\n-a\n+b\n") + withoutNL := ParseUnifiedDiff("diff --git a/t.py b/t.py\n--- a/t.py\n+++ b/t.py\n@@ -1 +1 @@\n-a\n+b") + if !reflect.DeepEqual(withNL, withoutNL) { + t.Fatalf("trailing newline changed result: %+v vs %+v", withNL, withoutNL) + } + if got := withNL[0].Hunks[0].Content; got != "-a\n+b" { + t.Errorf("content = %q, want %q (no trailing empty line)", got, "-a\n+b") + } +} + +func TestDetectLanguage(t *testing.T) { + // Ground truth from Python _detect_language. + want := map[string]string{ + "a.py": "python", "a.js": "javascript", "a.ts": "typescript", + "a.tsx": "typescript", "a.jsx": "javascript", "a.go": "go", + "a.rs": "rust", "a.java": "java", "a.rb": "ruby", "a.cpp": "cpp", + "a.c": "c", "a.cs": "csharp", "a.swift": "swift", "a.kt": "kotlin", + "a.scala": "scala", "a.php": "php", "a.sh": "bash", "a.yaml": "yaml", + "a.yml": "yaml", "a.json": "json", "a.md": "markdown", "a.sql": "sql", + "a.html": "html", "a.css": "css", "a.unknown": "", "noext": "", + } + for path, w := range want { + if got := detectLanguage(path); got != w { + t.Errorf("detectLanguage(%q) = %q, want %q", path, got, w) + } + } +} + +func TestIsTestFile(t *testing.T) { + // Ground truth from Python _is_test_file. Note "latest/foo.py" is true — the + // patterns are substrings, and "latest/" contains "test/". Case-insensitive. + want := map[string]bool{ + "src/test_foo.py": true, "foo_test.go": true, "foo.test.js": true, + "tests/x.py": true, "test/x.py": true, "__tests__/x.js": true, + "spec/x.rb": true, "latest/foo.py": true, "src/app.py": false, + "TEST_FOO.PY": true, "Tests/X.py": true, "": false, + } + for path, w := range want { + if got := isTestFile(path); got != w { + t.Errorf("isTestFile(%q) = %v, want %v", path, got, w) + } + } +} + +// TestPrimaryLanguageTieBreak documents the one deliberate divergence from +// Python: on a language-count tie, Python's max(set(langs), key=langs.count) +// tie-break is set-iteration-order dependent (non-deterministic across Python +// processes due to str hash randomization). This port breaks ties by +// first-appearance order within the group, which is deterministic. A unique +// mode (the common case) agrees with Python regardless. +func TestPrimaryLanguageTieBreak(t *testing.T) { + goFirst := []schemas.FileChange{ + {Path: "d/x.go", Language: "go"}, + {Path: "d/y.py", Language: "python"}, + } + if got := primaryLanguage(goFirst); got != "go" { + t.Errorf("tie primaryLanguage(go,python) = %q, want go (first-in-list)", got) + } + pyFirst := []schemas.FileChange{ + {Path: "d/y.py", Language: "python"}, + {Path: "d/x.go", Language: "go"}, + } + if got := primaryLanguage(pyFirst); got != "python" { + t.Errorf("tie primaryLanguage(python,go) = %q, want python (first-in-list)", got) + } + // Unique mode: python appears twice, go once. + mode := []schemas.FileChange{ + {Path: "d/a.go", Language: "go"}, + {Path: "d/b.py", Language: "python"}, + {Path: "d/c.py", Language: "python"}, + } + if got := primaryLanguage(mode); got != "python" { + t.Errorf("mode primaryLanguage = %q, want python", got) + } + // No languages -> "". + if got := primaryLanguage([]schemas.FileChange{{Path: "d/x.unknown"}}); got != "" { + t.Errorf("empty-lang primaryLanguage = %q, want \"\"", got) + } +} diff --git a/go/internal/diffengine/stats.go b/go/internal/diffengine/stats.go new file mode 100644 index 0000000..7a62936 --- /dev/null +++ b/go/internal/diffengine/stats.go @@ -0,0 +1,81 @@ +package diffengine + +import ( + "strings" + + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// testPatterns ports the Python _is_test_file patterns. Membership is a +// case-insensitive SUBSTRING test (not a path-component test), so e.g. +// "latest/foo.py" matches on "test/" — a Python quirk reproduced verbatim. +var testPatterns = []string{ + "test_", + "_test.", + ".test.", + "tests/", + "test/", + "__tests__/", + "spec/", +} + +// isTestFile ports _is_test_file: true when the lowercased path contains any +// test pattern as a substring. +func isTestFile(path string) bool { + lower := strings.ToLower(path) + for _, p := range testPatterns { + if strings.Contains(lower, p) { + return true + } + } + return false +} + +// ComputeDiffStats ports compute_diff_stats: aggregate statistics over the +// parsed files. The test-to-code ratio divides the test-file count by +// max(code_files, 1) so it never divides by zero. +func ComputeDiffStats(files []schemas.FileChange) schemas.DiffStats { + testFiles := 0 + totalAdditions := 0 + totalDeletions := 0 + filesAdded := 0 + filesModified := 0 + filesRemoved := 0 + filesRenamed := 0 + + for _, f := range files { + if isTestFile(f.Path) { + testFiles++ + } + totalAdditions += f.LinesAdded + totalDeletions += f.LinesRemoved + switch f.Status { + case "added": + filesAdded++ + case "modified": + filesModified++ + case "removed": + filesRemoved++ + case "renamed": + filesRenamed++ + } + } + + codeFiles := len(files) - testFiles + denom := codeFiles + if denom < 1 { + denom = 1 + } + + return schemas.DiffStats{ + TotalFiles: len(files), + TotalAdditions: totalAdditions, + TotalDeletions: totalDeletions, + FilesAdded: filesAdded, + FilesModified: filesModified, + FilesRemoved: filesRemoved, + FilesRenamed: filesRenamed, + TestFilesChanged: testFiles, + TestToCodeRatio: float64(testFiles) / float64(denom), + } +} diff --git a/go/internal/evidence/evidence.go b/go/internal/evidence/evidence.go new file mode 100644 index 0000000..4347fa9 --- /dev/null +++ b/go/internal/evidence/evidence.go @@ -0,0 +1,950 @@ +// Package evidence ports pr_af/evidence.py: ground-truth code extraction that +// grounds each finding in the actual repository source. It shells out to the +// same `grep` binary with the same arguments Python uses (so caller/import +// discovery is byte-identical regardless of the grep flavor installed), reads +// snippets around finding lines, and pre-reads a review dimension's target +// files into a primed context pack. +// +// Divergences from Python (all output-neutral or unreachable in practice): +// - The Python (abspath, mtime) file-read cache (_FILE_CACHE) is a pure +// performance optimization ("zero quality cost") and is NOT ported; files +// are read fresh. Output is identical. +// - Python opens files with errors="ignore" (dropping undecodable bytes); Go +// reads raw bytes. This only differs on non-UTF-8 text files, where the +// regex/line-number logic is unaffected in practice. +// - _extract_diff_hunk's fallback scan over diff_patches follows Python's +// dict INSERTION order to pick the first key that normalizes to the target; +// Go maps have no order, so the scan uses SORTED key order. This only +// matters when two distinct keys normalize identically with differing patch +// values — a pathological input that does not occur for real diff maps. +package evidence + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" + "unicode" + "unicode/utf8" + + "golang.org/x/sync/semaphore" + + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// EvidencePackage ports the pydantic EvidencePackage: ground-truth code +// evidence for a single finding. Slice fields default to non-nil empty slices +// (Field(default_factory=list)) so they marshal to [] rather than null. +type EvidencePackage struct { + FindingTitle string `json:"finding_title"` + PrimaryCode string `json:"primary_code"` + CallerSnippets []string `json:"caller_snippets"` + CrossRefSnippets []string `json:"cross_ref_snippets"` + DiffHunk string `json:"diff_hunk"` + ImportContext string `json:"import_context"` + RelatedCode string `json:"related_code"` +} + +// evidenceSemaphoreWeight is asyncio.Semaphore(10) from +// extract_evidence_for_findings. +const evidenceSemaphoreWeight = 10 + +// extractForFindingFn is the per-finding worker, indirected through a package +// var so tests can observe concurrency (semaphore bound) and ordering without +// touching real files. Production behavior is identical to calling +// extractForFinding directly. +var extractForFindingFn = extractForFinding + +// grepTimeout mirrors subprocess.run(..., timeout=10). +const grepTimeout = 10 * time.Second + +var skipDirGrepArgs = []string{ + "--exclude-dir=.git", + "--exclude-dir=node_modules", + "--exclude-dir=__pycache__", + "--exclude-dir=.venv", + "--exclude-dir=vendor", + "--exclude-dir=venv", +} + +// textExtensions ports _TEXT_EXTENSIONS (extensions carry the leading dot). +var textExtensions = map[string]struct{}{ + ".py": {}, ".js": {}, ".jsx": {}, ".ts": {}, ".tsx": {}, ".go": {}, ".rs": {}, + ".java": {}, ".rb": {}, ".php": {}, ".c": {}, ".h": {}, ".cpp": {}, ".hpp": {}, + ".cs": {}, ".swift": {}, ".kt": {}, ".scala": {}, ".sh": {}, ".yaml": {}, + ".yml": {}, ".json": {}, ".toml": {}, ".ini": {}, ".cfg": {}, ".md": {}, + ".sql": {}, ".html": {}, ".css": {}, ".scss": {}, ".txt": {}, +} + +// commonIdentifierWords ports _COMMON_IDENTIFIER_WORDS. +var commonIdentifierWords = map[string]struct{}{ + "the": {}, "this": {}, "that": {}, "with": {}, "from": {}, "when": {}, + "where": {}, "which": {}, "there": {}, "their": {}, "returns": {}, + "return": {}, "found": {}, "check": {}, "line": {}, "file": {}, "code": {}, + "issue": {}, "error": {}, "value": {}, "values": {}, "class": {}, + "function": {}, "method": {}, "should": {}, "could": {}, "would": {}, + "into": {}, "over": {}, "under": {}, "each": {}, "name": {}, "data": {}, + "test": {}, "tests": {}, +} + +var ( + identRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + backtickIdentRe = regexp.MustCompile("`([A-Za-z_][A-Za-z0-9_]*)`") + capWordsRe = regexp.MustCompile(`\b([A-Z][a-zA-Z0-9]{2,})\b`) + snakeCallRe = regexp.MustCompile(`\b([a-z_][a-z0-9_]{2,})\s*\(`) + backtickPathRe = regexp.MustCompile("`([^`]*?/[^`]*?)`") + pathLikeRe = regexp.MustCompile(`([A-Za-z0-9_./-]+\.[A-Za-z0-9]+)`) + hunkHeaderRe = regexp.MustCompile(`^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@`) +) + +// ExtractEvidenceForFindings ports extract_evidence_for_findings. It processes +// findings concurrently, bounded by a weighted semaphore of 10, and returns a +// map keyed by finding title. Results are written into a PRE-INDEXED slice +// (never appended on completion) so that on a title collision the last finding +// in input order wins, exactly as Python's {p.finding_title: p for p in gather}. +func ExtractEvidenceForFindings( + ctx context.Context, + findings []schemas.ReviewFinding, + repoPath string, + diffPatches map[string]string, + blastRadius []string, +) (map[string]EvidencePackage, error) { + if len(findings) == 0 { + return map[string]EvidencePackage{}, nil + } + + sem := semaphore.NewWeighted(evidenceSemaphoreWeight) + results := make([]EvidencePackage, len(findings)) + + var wg sync.WaitGroup + var mu sync.Mutex + var firstErr error + + for i := range findings { + if err := sem.Acquire(ctx, 1); err != nil { + mu.Lock() + if firstErr == nil { + firstErr = err + } + mu.Unlock() + break + } + wg.Add(1) + go func(idx int) { + defer wg.Done() + defer sem.Release(1) + results[idx] = extractForFindingFn(ctx, findings[idx], repoPath, diffPatches, blastRadius) + }(i) + } + + wg.Wait() + + if firstErr != nil { + return nil, firstErr + } + + out := make(map[string]EvidencePackage, len(results)) + for _, pkg := range results { + out[pkg.FindingTitle] = pkg + } + return out, nil +} + +// extractForFinding ports the inner _extract_for_finding closure. Python runs +// the sub-tasks concurrently via asyncio.to_thread + gather; because each +// sub-task writes a distinct, order-independent field (and the caller/cross-ref +// lists are assembled in the SAME input order gather preserves), running them +// sequentially here yields byte-identical results. +func extractForFinding( + ctx context.Context, + finding schemas.ReviewFinding, + repoPath string, + diffPatches map[string]string, + blastRadius []string, +) EvidencePackage { + normalizedFile := normalizeRelativePath(repoPath, finding.FilePath) + textBlob := strings.Join([]string{finding.Title, finding.Body, finding.Evidence}, "\n") + identifiers := extractMentionedIdentifiers(textBlob) + + primaryCode := readCodeSnippet(repoPath, normalizedFile, finding.LineStart, 30) + diffHunk := extractDiffHunk(diffPatches, normalizedFile, &finding.LineStart) + importContext := buildImportContext(ctx, repoPath, normalizedFile) + mentionedFiles := extractMentionedFilePaths(textBlob, repoPath) + relatedCode := extractBlastRadiusCode(repoPath, normalizedFile, identifiers, blastRadius) + + var callerGroups [][]string + for _, ident := range identifiers { + callerGroups = append(callerGroups, findFunctionCallers(ctx, repoPath, ident, normalizedFile)) + } + var callerFlat []string + for _, group := range callerGroups { + callerFlat = append(callerFlat, group...) + } + callerSnippets := dedupeStrings(callerFlat) + if len(callerSnippets) > 10 { + callerSnippets = callerSnippets[:10] + } + + var crossRefResults []string + for _, path := range firstN(mentionedFiles, 10) { + crossRefResults = append(crossRefResults, readCodeSnippet(repoPath, path, 1, 30)) + } + var crossRefNonEmpty []string + for _, item := range crossRefResults { + if item != "" { + crossRefNonEmpty = append(crossRefNonEmpty, item) + } + } + crossRefSnippets := dedupeStrings(crossRefNonEmpty) + + return EvidencePackage{ + FindingTitle: finding.Title, + PrimaryCode: primaryCode, + CallerSnippets: callerSnippets, + CrossRefSnippets: crossRefSnippets, + DiffHunk: diffHunk, + ImportContext: importContext, + RelatedCode: relatedCode, + } +} + +// ReadCodeSnippet exposes _read_code_snippet (±context_lines around line). +func ReadCodeSnippet(repoPath, filePath string, line, contextLines int) string { + return readCodeSnippet(repoPath, filePath, line, contextLines) +} + +func readCodeSnippet(repoPath, filePath string, line, contextLines int) string { + normalized := normalizeRelativePath(repoPath, filePath) + absPath := filepath.Join(repoPath, normalized) + if !isTextFile(absPath) { + return "" + } + lines := readFileLines(absPath) + if len(lines) == 0 { + return "" + } + + targetLine := line + if targetLine < 1 { + targetLine = 1 + } + startIdx := targetLine - 1 - contextLines + if startIdx < 0 { + startIdx = 0 + } + endIdx := targetLine + contextLines + if endIdx > len(lines) { + endIdx = len(lines) + } + + var b strings.Builder + for idx := startIdx; idx < endIdx; idx++ { + if idx > startIdx { + b.WriteByte('\n') + } + b.WriteString(fmt.Sprintf("%d: %s", idx+1, rstrip(lines[idx]))) + } + return b.String() +} + +// FindFunctionCallers exposes _find_function_callers. +func FindFunctionCallers(ctx context.Context, repoPath, functionName, excludeFile string) []string { + return findFunctionCallers(ctx, repoPath, functionName, excludeFile) +} + +func findFunctionCallers(ctx context.Context, repoPath, functionName, excludeFile string) []string { + ident := strings.TrimSpace(functionName) + if ident == "" || !identRe.MatchString(ident) { + return []string{} + } + + // ident is guaranteed [A-Za-z_][A-Za-z0-9_]*, so re.escape(ident) == ident. + pattern := `\b` + ident + `\s*\(` + args := append([]string{"-RInE", pattern, "."}, skipDirGrepArgs...) + stdout, ran := runGrep(ctx, repoPath, args) + if !ran { + return []string{} + } + + normalizedExclude := normalizeRelativePath(repoPath, excludeFile) + var snippets []string + + for _, rawLine := range splitLines(stdout) { + parts := strings.SplitN(rawLine, ":", 3) + if len(parts) < 3 { + continue + } + relPath := normalizeRelativePath(repoPath, parts[0]) + if relPath == normalizedExclude { + continue + } + if !isTextFile(filepath.Join(repoPath, relPath)) { + continue + } + lineNo, err := strconv.Atoi(parts[1]) + if err != nil { + continue + } + snippet := readCodeSnippet(repoPath, relPath, lineNo, 5) + if snippet != "" { + header := fmt.Sprintf("%s:%d", relPath, lineNo) + snippets = append(snippets, header+"\n"+snippet) + } + if len(snippets) >= 10 { + break + } + } + + return dedupeStrings(snippets) +} + +// extractMentionedIdentifiers ports _extract_mentioned_identifiers. +func extractMentionedIdentifiers(text string) []string { + var candidates []string + for _, m := range backtickIdentRe.FindAllStringSubmatch(text, -1) { + candidates = append(candidates, m[1]) + } + for _, m := range capWordsRe.FindAllStringSubmatch(text, -1) { + candidates = append(candidates, m[1]) + } + for _, m := range snakeCallRe.FindAllStringSubmatch(text, -1) { + candidates = append(candidates, m[1]) + } + + seen := map[string]struct{}{} + var out []string + for _, raw := range candidates { + name := strings.Trim(raw, "\x60 ") // strip backticks and spaces + if len(name) < 3 { + continue + } + if _, ok := commonIdentifierWords[strings.ToLower(name)]; ok { + continue + } + if !identRe.MatchString(name) { + continue + } + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + out = append(out, name) + } + return out +} + +// extractMentionedFilePaths ports _extract_mentioned_file_paths. +func extractMentionedFilePaths(text, repoPath string) []string { + var values []string + for _, m := range backtickPathRe.FindAllStringSubmatch(text, -1) { + values = append(values, m[1]) + } + for _, m := range pathLikeRe.FindAllStringSubmatch(text, -1) { + values = append(values, m[1]) + } + + candidates := map[string]struct{}{} + for _, value := range values { + if !strings.Contains(value, "/") { + continue + } + if strings.Contains(value, " ") { + continue + } + normalized := normalizeRelativePath(repoPath, value) + absPath := filepath.Join(repoPath, normalized) + if isFile(absPath) { + candidates[normalized] = struct{}{} + } + } + + out := make([]string, 0, len(candidates)) + for k := range candidates { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// ExtractDiffHunk exposes _extract_diff_hunk. A nil line reproduces Python's +// line=None (return the whole patch, capped at 200 lines). +func ExtractDiffHunk(diffPatches map[string]string, filePath string, line *int) string { + return extractDiffHunk(diffPatches, filePath, line) +} + +func extractDiffHunk(diffPatches map[string]string, filePath string, line *int) string { + normalized := normalizePatchKey(filePath) + patch := diffPatches[normalized] + + if patch == "" { + // Python scans dict.items() in insertion order; Go maps have no order, + // so scan sorted keys for determinism (see package divergence note). + keys := make([]string, 0, len(diffPatches)) + for k := range diffPatches { + keys = append(keys, k) + } + sort.Strings(keys) + for _, key := range keys { + if normalizePatchKey(key) == normalized { + patch = diffPatches[key] + break + } + } + } + + if patch == "" { + return "" + } + + patchLines := splitLines(patch) + if line == nil { + return strings.Join(firstN(patchLines, 200), "\n") + } + + hunkLines := extractHunkForLine(patchLines, *line) + if len(hunkLines) == 0 { + return strings.Join(firstN(patchLines, 200), "\n") + } + return strings.Join(firstN(hunkLines, 200), "\n") +} + +// BuildImportContext exposes _build_import_context. +func BuildImportContext(ctx context.Context, repoPath, filePath string) string { + return buildImportContext(ctx, repoPath, filePath) +} + +func buildImportContext(ctx context.Context, repoPath, filePath string) string { + normalized := normalizeRelativePath(repoPath, filePath) + absPath := filepath.Join(repoPath, normalized) + + var imports []string + if isTextFile(absPath) { + if data, err := os.ReadFile(absPath); err == nil { + for _, rawLine := range splitLines(string(data)) { + stripped := strings.TrimSpace(rawLine) + if strings.HasPrefix(stripped, "import ") || strings.HasPrefix(stripped, "from ") { + imports = append(imports, stripped) + } + } + } + } + + moduleName := pathToModule(normalized) + var importedBy []string + + if moduleName != "" { + regex := `^\s*(?:from\s+` + reEscape(moduleName) + `\b|import\s+` + reEscape(moduleName) + `\b)` + args := append([]string{"-RIlE", regex, ".", "--include=*.py"}, skipDirGrepArgs...) + if stdout, ran := runGrep(ctx, repoPath, args); ran { + for _, rawPath := range splitLines(stdout) { + rel := normalizeRelativePath(repoPath, rawPath) + if rel != normalized { + importedBy = append(importedBy, rel) + } + } + } + } + + importsList := "none" + if len(imports) > 0 { + importsList = strings.Join(firstN(imports, 30), ", ") + } + + importedByList := "none" + if len(importedBy) > 0 { + importedByList = strings.Join(firstN(sortedUnique(importedBy), 30), ", ") + } + + return "IMPORTS: " + importsList + "\nIMPORTED BY: " + importedByList +} + +// extractBlastRadiusCode ports _extract_blast_radius_code. +func extractBlastRadiusCode(repoPath, filePath string, identifiers, blastRadius []string) string { + if len(identifiers) == 0 || len(blastRadius) == 0 { + return "" + } + + normalizedTarget := normalizeRelativePath(repoPath, filePath) + + identRegexps := make([]*regexp.Regexp, len(identifiers)) + for i, ident := range identifiers { + identRegexps[i] = regexp.MustCompile(`\b` + reEscape(ident) + `\b`) + } + + var snippets []string + for _, candidate := range blastRadius { + normalized := normalizeRelativePath(repoPath, candidate) + if normalized == normalizedTarget { + continue + } + absPath := filepath.Join(repoPath, normalized) + if !isTextFile(absPath) { + continue + } + data, err := os.ReadFile(absPath) + if err != nil { + continue + } + lines := splitLinesKeepEnds(string(data)) + if len(lines) == 0 { + continue + } + + for _, re := range identRegexps { + matchIdx := -1 + for i, value := range lines { + if re.MatchString(value) { + matchIdx = i + break + } + } + if matchIdx < 0 { + continue + } + snippet := formatLinesWithNumbers(lines, matchIdx+1, 10) + if snippet != "" { + snippets = append(snippets, normalized+":"+strconv.Itoa(matchIdx+1)+"\n"+snippet) + } + break + } + + if len(snippets) >= 5 { + break + } + } + + return strings.Join(firstN(snippets, 5), "\n\n") +} + +// buildDimensionPack tuning constants (Python default kwargs — no caller +// overrides them, see design §G / orch.py:707). +const ( + dimPackMaxFiles = 6 + dimPackMaxLinesPerFile = 400 + dimPackMaxChars = 16000 +) + +// BuildDimensionPack ports build_dimension_pack. diffPatches is accepted for +// call-site parity but is unused by the Python implementation too. +func BuildDimensionPack(ctx context.Context, repoPath string, targetFiles []string, diffPatches map[string]string) string { + _ = diffPatches + if repoPath == "" || len(targetFiles) == 0 { + return "" + } + var parts []string + for _, fp := range firstN(targetFiles, dimPackMaxFiles) { + rel := strings.TrimLeft(strings.TrimSpace(fp), "/") + absPath := filepath.Join(repoPath, rel) + if !isFile(absPath) { + alt := normalizeRelativePath(repoPath, fp) + if alt != "" { + rel, absPath = alt, filepath.Join(repoPath, alt) + } + } + if !(isFile(absPath) && isTextFile(absPath)) { + continue + } + rawLines := readFileLines(absPath) + lines := make([]string, len(rawLines)) + for i, ln := range rawLines { + lines[i] = strings.TrimRight(ln, "\n") // Python rstrip("\n") — keeps a trailing \r + } + if len(lines) == 0 { + continue + } + shown := firstN(lines, dimPackMaxLinesPerFile) + var body strings.Builder + for i, ln := range shown { + if i > 0 { + body.WriteByte('\n') + } + body.WriteString(fmt.Sprintf("%d: %s", i+1, ln)) + } + trunc := "" + if len(lines) > dimPackMaxLinesPerFile { + trunc = fmt.Sprintf(" (showing first %d of %d)", dimPackMaxLinesPerFile, len(lines)) + } + imp := buildImportContext(ctx, repoPath, rel) + block := "### " + rel + trunc + "\n```\n" + body.String() + "\n```" + if imp != "" { + block += "\n_import/usage context:_ " + truncateChars(imp, 1200) + } + parts = append(parts, block) + } + blob := strings.Join(parts, "\n\n") + return truncateChars(blob, dimPackMaxChars) +} + +// normalizeRelativePath ports _normalize_relative_path. +func normalizeRelativePath(repoPath, filePath string) string { + path := strings.ReplaceAll(strings.TrimSpace(filePath), "\\", "/") + if path == "" { + return "" + } + + if strings.HasPrefix(path, "/workspaces/") { + path = strings.Replace(path, "/workspaces/", "", 1) + } + if strings.HasPrefix(path, "./") { + path = path[2:] + } + + repoAbs := "" + if repoPath != "" { + if abs, err := filepath.Abs(repoPath); err == nil { + repoAbs = abs + } + } + pathAbs := "" + if filepath.IsAbs(path) { + if abs, err := filepath.Abs(path); err == nil { + pathAbs = abs + } + } + + if repoAbs != "" && strings.HasPrefix(pathAbs, repoAbs) { + if rel, err := filepath.Rel(repoAbs, pathAbs); err == nil { + path = rel + } + } else if strings.HasPrefix(path, "/") { + path = strings.TrimLeft(path, "/") + } + + repoName := "" + if repoAbs != "" { + repoName = filepath.Base(repoAbs) + } + marker := repoName + "/" + if marker != "" && strings.Contains(path, marker) { + if idx := strings.Index(path, marker); idx >= 0 { + path = path[idx+len(marker):] + } + } + + return filepath.ToSlash(filepath.Clean(path)) +} + +// normalizePatchKey ports _normalize_patch_key. +func normalizePatchKey(filePath string) string { + normalized := strings.TrimSpace(strings.ReplaceAll(filePath, "\\", "/")) + for _, prefix := range []string{"a/", "b/"} { + if strings.HasPrefix(normalized, prefix) { + normalized = normalized[len(prefix):] + } + } + return strings.TrimLeft(normalized, "/") +} + +// extractHunkForLine ports _extract_hunk_for_line. +func extractHunkForLine(patchLines []string, line int) []string { + var currentHunk []string + currentStart := 0 + currentCount := 0 + + for _, raw := range patchLines { + if strings.HasPrefix(raw, "@@") { + if len(currentHunk) > 0 && currentCount > 0 && currentStart <= line && line < currentStart+currentCount { + return currentHunk + } + currentHunk = []string{raw} + if m := hunkHeaderRe.FindStringSubmatch(raw); m != nil { + currentStart = mustAtoi(m[1]) + countStr := m[2] + if countStr == "" { + countStr = "1" + } + currentCount = mustAtoi(countStr) + } else { + currentStart = 0 + currentCount = 0 + } + } else if len(currentHunk) > 0 { + currentHunk = append(currentHunk, raw) + } + } + + if len(currentHunk) > 0 && currentCount > 0 && currentStart <= line && line < currentStart+currentCount { + return currentHunk + } + return nil +} + +// pathToModule ports _path_to_module. +func pathToModule(filePath string) string { + if !strings.HasSuffix(filePath, ".py") { + return "" + } + module := strings.ReplaceAll(filePath, "/", ".") + switch { + case strings.HasSuffix(module, ".__init__.py"): + module = module[:len(module)-len(".__init__.py")] + case strings.HasSuffix(module, ".py"): + module = module[:len(module)-len(".py")] + } + return module +} + +// formatLinesWithNumbers ports _format_lines_with_numbers. +func formatLinesWithNumbers(lines []string, targetLine, contextLines int) string { + if len(lines) == 0 { + return "" + } + startIdx := targetLine - 1 - contextLines + if startIdx < 0 { + startIdx = 0 + } + endIdx := targetLine + contextLines + if endIdx > len(lines) { + endIdx = len(lines) + } + var b strings.Builder + for idx := startIdx; idx < endIdx; idx++ { + if idx > startIdx { + b.WriteByte('\n') + } + b.WriteString(fmt.Sprintf("%d: %s", idx+1, rstrip(lines[idx]))) + } + return b.String() +} + +// dedupeStrings ports _dedupe_strings: order-preserving unique non-empty +// strings. Returns a non-nil slice. +func dedupeStrings(values []string) []string { + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for _, v := range values { + if v == "" { + continue + } + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + return out +} + +// isTextFile ports _is_text_file. +func isTextFile(path string) bool { + if path == "" { + return false + } + info, err := os.Stat(path) + if err != nil || !info.Mode().IsRegular() { + return false + } + ext := strings.ToLower(splitExt(path)) + if _, ok := textExtensions[ext]; ok { + return true + } + if ext != "" { + return false + } + f, err := os.Open(path) + if err != nil { + return false + } + defer f.Close() + sample := make([]byte, 1024) + n, _ := f.Read(sample) + return !bytes.Contains(sample[:n], []byte{0}) +} + +// isFile mirrors os.path.isfile (follows symlinks, regular files only). +func isFile(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Mode().IsRegular() +} + +// runGrep shells out to `grep` with cwd=repoPath and a 10s timeout, returning +// stdout and whether the process actually ran (ran=false reproduces Python +// catching OSError / TimeoutExpired -> []). Any exit code (0/1/2) counts as ran +// since Python uses check=False and only reads stdout. +func runGrep(ctx context.Context, repoPath string, args []string) (string, bool) { + cctx, cancel := context.WithTimeout(ctx, grepTimeout) + defer cancel() + + cmd := exec.CommandContext(cctx, "grep", args...) + cmd.Dir = repoPath + var out bytes.Buffer + cmd.Stdout = &out + err := cmd.Run() + + if cctx.Err() != nil { + return "", false // timeout or cancellation + } + var execErr *exec.Error + if errors.As(err, &execErr) { + return "", false // could not start (e.g. grep not found) == OSError + } + return out.String(), true +} + +// readFileLines ports _read_file_lines (minus the perf cache): the file split +// into lines WITH their terminators, or nil on read error. +func readFileLines(absPath string) []string { + data, err := os.ReadFile(absPath) + if err != nil { + return nil + } + return splitLinesKeepEnds(string(data)) +} + +// rstrip mirrors Python str.rstrip() (strip trailing Unicode whitespace). +func rstrip(s string) string { + return strings.TrimRightFunc(s, unicode.IsSpace) +} + +// splitExt ports os.path.splitext(path)[1]: the extension WITH its dot, or "". +// Unlike filepath.Ext, a leading-dot filename (".gitignore") has no extension. +func splitExt(path string) string { + sepIdx := strings.LastIndexByte(path, '/') + dotIdx := strings.LastIndexByte(path, '.') + if dotIdx > sepIdx { + filenameIdx := sepIdx + 1 + for filenameIdx < dotIdx { + if path[filenameIdx] != '.' { + return path[dotIdx:] + } + filenameIdx++ + } + } + return "" +} + +// firstN returns s[:n] bounded by len(s), like Python list slicing s[:n]. +func firstN[T any](s []T, n int) []T { + if len(s) < n { + return s + } + return s[:n] +} + +// truncateChars returns the first n runes of s, like Python str slicing s[:n]. +func truncateChars(s string, n int) string { + if n <= 0 { + return "" + } + count := 0 + for i := range s { + if count == n { + return s[:i] + } + count++ + } + return s +} + +// sortedUnique returns the sorted unique values (like sorted(set(...))). +func sortedUnique(values []string) []string { + set := map[string]struct{}{} + for _, v := range values { + set[v] = struct{}{} + } + out := make([]string, 0, len(set)) + for v := range set { + out = append(out, v) + } + sort.Strings(out) + return out +} + +func mustAtoi(s string) int { + n, _ := strconv.Atoi(s) + return n +} + +// reEscape mirrors Python 3.7+ re.escape: escapes only the special-character +// set CPython uses (_special_chars_map). For validated identifiers/module names +// this only ever escapes '.' (and, defensively, '-'). +func reEscape(s string) string { + var b strings.Builder + for _, r := range s { + if r < 128 && reSpecialChars[byte(r)] { + b.WriteByte('\\') + } + b.WriteRune(r) + } + return b.String() +} + +var reSpecialChars = func() map[byte]bool { + m := map[byte]bool{} + for _, c := range []byte("()[]{}?*+-|^$\\.&~# \t\n\r\v\f") { + m[c] = true + } + return m +}() + +// splitLinesKeepEnds ports str.splitlines(keepends=True): split on Python's +// universal newline set, keeping each terminator with its line, no trailing +// empty element. +func splitLinesKeepEnds(s string) []string { + var lines []string + var b strings.Builder + runes := []rune(s) + for i := 0; i < len(runes); i++ { + r := runes[i] + b.WriteRune(r) + if r == '\r' { + if i+1 < len(runes) && runes[i+1] == '\n' { + b.WriteRune('\n') + i++ + } + lines = append(lines, b.String()) + b.Reset() + } else if isLineBoundary(r) { + lines = append(lines, b.String()) + b.Reset() + } + } + if b.Len() > 0 { + lines = append(lines, b.String()) + } + return lines +} + +// splitLines ports str.splitlines() (keepends=False). +func splitLines(s string) []string { + kept := splitLinesKeepEnds(s) + out := make([]string, len(kept)) + for i, ln := range kept { + out[i] = stripLineEnding(ln) + } + return out +} + +func stripLineEnding(s string) string { + if strings.HasSuffix(s, "\r\n") { + return s[:len(s)-2] + } + if s == "" { + return s + } + // splitLinesKeepEnds only ever appends a boundary rune (or \r\n) as the + // terminator, so the last rune is the ending to strip when it is one + // (handles multi-byte NEL/LS/PS correctly). + r, size := utf8.DecodeLastRuneInString(s) + if r == '\r' || isLineBoundary(r) { + return s[:len(s)-size] + } + return s +} + +func isLineBoundary(r rune) bool { + switch r { + case '\n', '\v', '\f', 0x1c, 0x1d, 0x1e, 0x85, 0x2028, 0x2029: + return true + } + return false +} diff --git a/go/internal/evidence/evidence_test.go b/go/internal/evidence/evidence_test.go new file mode 100644 index 0000000..e716a41 --- /dev/null +++ b/go/internal/evidence/evidence_test.go @@ -0,0 +1,505 @@ +package evidence + +import ( + "context" + "os" + "path/filepath" + "reflect" + "sort" + "strconv" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +func writeFile(t *testing.T, repo, rel, content string) { + t.Helper() + abs := filepath.Join(repo, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(abs, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", abs, err) + } +} + +// --- pure-function tests (grep-independent, fully portable) --- + +func TestNormalizeRelativePath(t *testing.T) { + repo := t.TempDir() + cases := []struct { + name, repo, in, want string + }{ + {"dot-slash", repo, "./a/b.py", "a/b.py"}, + {"workspaces-prefix", repo, "/workspaces/x/y.py", "x/y.py"}, + {"abs-in-repo", repo, repo + "/utils.py", "utils.py"}, + {"repo-name-marker", "/home/x/keycloak", "org/keycloak/src/Foo.java", "src/Foo.java"}, + {"backslashes", repo, `a\b\c.py`, "a/b/c.py"}, + {"blank", repo, " ", ""}, + {"abs-outside-repo", repo, "/etc/passwd", "etc/passwd"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := normalizeRelativePath(tc.repo, tc.in); got != tc.want { + t.Fatalf("normalizeRelativePath(%q,%q) = %q, want %q", tc.repo, tc.in, got, tc.want) + } + }) + } +} + +func TestNormalizePatchKey(t *testing.T) { + cases := []struct{ in, want string }{ + {"a/src/foo.py", "src/foo.py"}, + {"b/src/foo.py", "src/foo.py"}, + {"a/b/foo.py", "foo.py"}, + {"src/foo.py", "src/foo.py"}, + {`b\src\foo.py`, "src/foo.py"}, + {"/leading/slash.py", "leading/slash.py"}, + } + for _, tc := range cases { + if got := normalizePatchKey(tc.in); got != tc.want { + t.Errorf("normalizePatchKey(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestPathToModule(t *testing.T) { + cases := []struct{ in, want string }{ + {"pkg/utils.py", "pkg.utils"}, + {"pkg/__init__.py", "pkg"}, + {"pkg/foo.txt", ""}, + {"top.py", "top"}, + } + for _, tc := range cases { + if got := pathToModule(tc.in); got != tc.want { + t.Errorf("pathToModule(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestExtractMentionedIdentifiers(t *testing.T) { + got := extractMentionedIdentifiers("The `util_func` calls MyClass and helper_fn() but not the value") + want := []string{"util_func", "MyClass", "helper_fn"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("identifiers = %v, want %v", got, want) + } + + // Ordering across the three regex passes and common-word filtering. + got2 := extractMentionedIdentifiers("`alpha` Beta gamma_call() the value error `alpha`") + // backtick: alpha, alpha ; capwords: Beta ; snake-call: gamma_call. + // "value","error","the" are common words. Dedup preserves first-seen order. + want2 := []string{"alpha", "Beta", "gamma_call"} + if !reflect.DeepEqual(got2, want2) { + t.Fatalf("identifiers2 = %v, want %v", got2, want2) + } +} + +func TestExtractMentionedFilePaths(t *testing.T) { + repo := t.TempDir() + writeFile(t, repo, "pkg/mod.py", "x=1\n") + writeFile(t, repo, "utils.py", "y=2\n") + // Only slash-containing, space-free, existing files are kept. utils.py and + // app.py have no slash -> filtered; only `pkg/mod.py` survives. + got := extractMentionedFilePaths("see `pkg/mod.py` and utils.py and app.py referenced", repo) + want := []string{"pkg/mod.py"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("mentioned files = %v, want %v", got, want) + } +} + +func TestReadCodeSnippet(t *testing.T) { + repo := t.TempDir() + writeFile(t, repo, "app.py", "from utils import util_func\nimport helpers\n\ndef main():\n return util_func()\n") + got := readCodeSnippet(repo, "app.py", 4, 2) + want := "2: import helpers\n3: \n4: def main():\n5: return util_func()" + if got != want { + t.Fatalf("readCodeSnippet =\n%q\nwant\n%q", got, want) + } + + // Non-text / missing file -> empty. + if s := readCodeSnippet(repo, "nope.bin", 1, 5); s != "" { + t.Fatalf("non-text snippet = %q, want empty", s) + } +} + +func TestExtractHunkForLine(t *testing.T) { + patch := "@@ -1,3 +1,4 @@\n line1\n+added\n line2\n@@ -10,2 +12,3 @@\n line12\n+added2\n" + lines := splitLines(patch) + + cases := []struct { + line int + want string + }{ + {3, "@@ -1,3 +1,4 @@\n line1\n+added\n line2"}, + {13, "@@ -10,2 +12,3 @@\n line12\n+added2"}, + } + for _, tc := range cases { + got := joinLines(extractHunkForLine(lines, tc.line)) + if got != tc.want { + t.Errorf("hunk for line %d =\n%q\nwant\n%q", tc.line, got, tc.want) + } + } + // Line matching no hunk -> empty slice. + if h := extractHunkForLine(lines, 99); len(h) != 0 { + t.Fatalf("line 99 hunk = %v, want empty", h) + } +} + +func TestExtractDiffHunk(t *testing.T) { + patch := "@@ -1,3 +1,4 @@\n line1\n+added\n line2\n@@ -10,2 +12,3 @@\n line12\n+added2\n" + l3, l13, l99 := 3, 13, 99 + + if got := extractDiffHunk(map[string]string{"app.py": patch}, "app.py", &l3); got != "@@ -1,3 +1,4 @@\n line1\n+added\n line2" { + t.Fatalf("line3 = %q", got) + } + if got := extractDiffHunk(map[string]string{"app.py": patch}, "app.py", &l13); got != "@@ -10,2 +12,3 @@\n line12\n+added2" { + t.Fatalf("line13 = %q", got) + } + // No matching hunk -> whole patch (capped at 200 lines). + wantWhole := "@@ -1,3 +1,4 @@\n line1\n+added\n line2\n@@ -10,2 +12,3 @@\n line12\n+added2" + if got := extractDiffHunk(map[string]string{"app.py": patch}, "app.py", &l99); got != wantWhole { + t.Fatalf("line99 = %q", got) + } + // Key stored with a/ prefix is found via the normalize-scan fallback. + if got := extractDiffHunk(map[string]string{"a/app.py": patch}, "app.py", &l3); got != "@@ -1,3 +1,4 @@\n line1\n+added\n line2" { + t.Fatalf("a/ key = %q", got) + } + // nil line -> whole patch. + if got := extractDiffHunk(map[string]string{"app.py": patch}, "app.py", nil); got != wantWhole { + t.Fatalf("nil line = %q", got) + } + // Missing file -> empty. + if got := extractDiffHunk(map[string]string{}, "app.py", &l3); got != "" { + t.Fatalf("missing = %q", got) + } +} + +func TestSplitExt(t *testing.T) { + cases := []struct{ in, want string }{ + {"foo.py", ".py"}, + {"a/b/foo.py", ".py"}, + {"foo.tar.gz", ".gz"}, + {".gitignore", ""}, // leading-dot filename has no extension (os.path.splitext) + {"dir/.env", ""}, + {"noext", ""}, + {"a.b/c", ""}, + } + for _, tc := range cases { + if got := splitExt(tc.in); got != tc.want { + t.Errorf("splitExt(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestIsTextFile(t *testing.T) { + repo := t.TempDir() + writeFile(t, repo, "code.py", "print(1)\n") + writeFile(t, repo, "data.bin", "x\x00y") + writeFile(t, repo, "noext_text", "hello no null\n") + writeFile(t, repo, "noext_binary", "he\x00llo") + + if !isTextFile(filepath.Join(repo, "code.py")) { + t.Error(".py should be text") + } + if isTextFile(filepath.Join(repo, "data.bin")) { + t.Error(".bin (unknown ext) should be non-text") + } + if !isTextFile(filepath.Join(repo, "noext_text")) { + t.Error("extensionless null-free file should be text") + } + if isTextFile(filepath.Join(repo, "noext_binary")) { + t.Error("extensionless file with null byte should be non-text") + } + if isTextFile(repo) { + t.Error("directory should be non-text") + } +} + +func TestReEscape(t *testing.T) { + cases := []struct{ in, want string }{ + {"pkg.mod", `pkg\.mod`}, + {"my-pkg", `my\-pkg`}, + {"plain", "plain"}, + {"a.b.c", `a\.b\.c`}, + } + for _, tc := range cases { + if got := reEscape(tc.in); got != tc.want { + t.Errorf("reEscape(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestSplitLinesKeepEnds(t *testing.T) { + got := splitLinesKeepEnds("a\nb\r\nc\rd") + want := []string{"a\n", "b\r\n", "c\r", "d"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("keepends = %v, want %v", got, want) + } + // Trailing terminator produces no empty final element. + if g := splitLinesKeepEnds("x\n"); !reflect.DeepEqual(g, []string{"x\n"}) { + t.Fatalf("trailing nl = %v", g) + } + if g := splitLinesKeepEnds(""); len(g) != 0 { + t.Fatalf("empty = %v", g) + } +} + +func TestSplitLines(t *testing.T) { + got := splitLines("a\nb\r\nc\rd\n") + want := []string{"a", "b", "c", "d"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("splitLines = %v, want %v", got, want) + } +} + +func TestTruncateChars(t *testing.T) { + if got := truncateChars("hello", 3); got != "hel" { + t.Errorf("ascii = %q", got) + } + if got := truncateChars("hello", 10); got != "hello" { + t.Errorf("over = %q", got) + } + // Multi-byte: 3 runes, each 3 bytes. + if got := truncateChars("αβγδ", 2); got != "αβ" { + t.Errorf("multibyte = %q", got) + } +} + +func TestDedupeStrings(t *testing.T) { + got := dedupeStrings([]string{"a", "", "b", "a", "c", "b"}) + want := []string{"a", "b", "c"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("dedupe = %v, want %v", got, want) + } + if got := dedupeStrings(nil); got == nil || len(got) != 0 { + t.Fatalf("nil dedupe should be non-nil empty, got %#v", got) + } +} + +// --- grep-dependent tests (fixtures use import-style so results are stable +// across grep flavors) --- + +func TestFindFunctionCallers(t *testing.T) { + repo := t.TempDir() + writeFile(t, repo, "deffile.py", "def target_fn():\n return 1\n") + writeFile(t, repo, "caller.py", "import deffile\n\ndef run():\n return deffile.target_fn()\n") + + got := findFunctionCallers(context.Background(), repo, "target_fn", "deffile.py") + want := []string{"caller.py:4\n1: import deffile\n2: \n3: def run():\n4: return deffile.target_fn()"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("callers =\n%#v\nwant\n%#v", got, want) + } + + // Invalid identifier -> empty. + if s := findFunctionCallers(context.Background(), repo, "1bad", ""); len(s) != 0 { + t.Fatalf("invalid ident -> %v", s) + } +} + +func TestBuildImportContext(t *testing.T) { + repo := t.TempDir() + writeFile(t, repo, "deffile.py", "def target_fn():\n return 1\n") + writeFile(t, repo, "caller.py", "import deffile\n\ndef run():\n return deffile.target_fn()\n") + writeFile(t, repo, "mymod.py", "import os\n") + writeFile(t, repo, "uses_mymod.py", "import mymod\n") + writeFile(t, repo, "solo.py", "from somewhere import thing\n") + + cases := []struct{ file, want string }{ + {"deffile.py", "IMPORTS: none\nIMPORTED BY: caller.py"}, + {"mymod.py", "IMPORTS: import os\nIMPORTED BY: uses_mymod.py"}, + {"solo.py", "IMPORTS: from somewhere import thing\nIMPORTED BY: none"}, + } + for _, tc := range cases { + t.Run(tc.file, func(t *testing.T) { + got := buildImportContext(context.Background(), repo, tc.file) + if got != tc.want { + t.Fatalf("import_context(%s) =\n%q\nwant\n%q", tc.file, got, tc.want) + } + }) + } +} + +func TestBuildDimensionPack(t *testing.T) { + repo := t.TempDir() + writeFile(t, repo, "lonely.py", "import os\nimport sys\n\ndef helper():\n return os.getcwd()\n") + writeFile(t, repo, "uses_lonely.py", "import lonely\n") + + got := BuildDimensionPack(context.Background(), repo, []string{"lonely.py"}, nil) + want := "### lonely.py\n```\n1: import os\n2: import sys\n3: \n4: def helper():\n5: return os.getcwd()\n```\n" + + "_import/usage context:_ IMPORTS: import os, import sys\nIMPORTED BY: uses_lonely.py" + if got != want { + t.Fatalf("dimension pack =\n%q\nwant\n%q", got, want) + } + + // Empty inputs -> empty. + if s := BuildDimensionPack(context.Background(), "", []string{"x.py"}, nil); s != "" { + t.Fatalf("empty repo -> %q", s) + } + if s := BuildDimensionPack(context.Background(), repo, nil, nil); s != "" { + t.Fatalf("no targets -> %q", s) + } +} + +func TestExtractEvidenceForFindingsIntegration(t *testing.T) { + repo := t.TempDir() + writeFile(t, repo, "deffile.py", "def target_fn():\n return 1\n") + writeFile(t, repo, "caller.py", "import deffile\n\ndef run():\n return deffile.target_fn()\n") + + f := schemas.ReviewFinding{ + DimensionID: "d", + DimensionName: "D", + FilePath: "deffile.py", + LineStart: 1, + LineEnd: 2, + Title: "Bug in `target_fn`", + Body: "the function `target_fn` is wrong", + Severity: "important", + Confidence: 0.9, + } + diff := map[string]string{"deffile.py": "@@ -1,2 +1,2 @@\n-def target_fn():\n+def target_fn(x):\n"} + + res, err := ExtractEvidenceForFindings(context.Background(), []schemas.ReviewFinding{f}, repo, diff, nil) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(res) != 1 { + t.Fatalf("expected 1 package, got %d", len(res)) + } + pkg, ok := res["Bug in `target_fn`"] + if !ok { + t.Fatalf("missing key; keys=%v", keysOf(res)) + } + + if pkg.PrimaryCode != "1: def target_fn():\n2: return 1" { + t.Errorf("primary_code = %q", pkg.PrimaryCode) + } + if pkg.DiffHunk != "@@ -1,2 +1,2 @@\n-def target_fn():\n+def target_fn(x):" { + t.Errorf("diff_hunk = %q", pkg.DiffHunk) + } + wantCallers := []string{"caller.py:4\n1: import deffile\n2: \n3: def run():\n4: return deffile.target_fn()"} + if !reflect.DeepEqual(pkg.CallerSnippets, wantCallers) { + t.Errorf("caller_snippets = %#v", pkg.CallerSnippets) + } + if pkg.ImportContext != "IMPORTS: none\nIMPORTED BY: caller.py" { + t.Errorf("import_context = %q", pkg.ImportContext) + } + // default_factory=list -> non-nil empty slices. + if pkg.CrossRefSnippets == nil { + t.Error("cross_ref_snippets must be non-nil []") + } + if pkg.RelatedCode != "" { + t.Errorf("related_code = %q", pkg.RelatedCode) + } +} + +func TestExtractEvidenceEmpty(t *testing.T) { + res, err := ExtractEvidenceForFindings(context.Background(), nil, "/tmp", nil, nil) + if err != nil { + t.Fatal(err) + } + if res == nil || len(res) != 0 { + t.Fatalf("empty findings -> %#v, want non-nil empty map", res) + } +} + +// --- semaphore bound + order preservation (via test seam) --- + +func TestExtractEvidenceSemaphoreBoundAndOrder(t *testing.T) { + const n = 40 + + var current, maxSeen int64 + orig := extractForFindingFn + t.Cleanup(func() { extractForFindingFn = orig }) + extractForFindingFn = func(_ context.Context, finding schemas.ReviewFinding, _ string, _ map[string]string, _ []string) EvidencePackage { + cur := atomic.AddInt64(¤t, 1) + for { + m := atomic.LoadInt64(&maxSeen) + if cur <= m || atomic.CompareAndSwapInt64(&maxSeen, m, cur) { + break + } + } + time.Sleep(3 * time.Millisecond) // hold concurrency to observe the bound + atomic.AddInt64(¤t, -1) + // Echo the title so we can verify identity/order in the result map. + return EvidencePackage{FindingTitle: finding.Title, PrimaryCode: finding.Title} + } + + findings := make([]schemas.ReviewFinding, n) + for i := range findings { + findings[i] = schemas.ReviewFinding{Title: title(i)} + } + + res, err := ExtractEvidenceForFindings(context.Background(), findings, "/repo", nil, nil) + if err != nil { + t.Fatal(err) + } + if m := atomic.LoadInt64(&maxSeen); m > evidenceSemaphoreWeight { + t.Fatalf("max concurrency %d exceeded semaphore weight %d", m, evidenceSemaphoreWeight) + } + if m := atomic.LoadInt64(&maxSeen); m < 2 { + t.Fatalf("expected concurrency > 1, got %d (bound not exercised)", m) + } + // Every finding i must map to evidence built from finding i. + for i := 0; i < n; i++ { + pkg, ok := res[title(i)] + if !ok { + t.Fatalf("missing evidence for %s", title(i)) + } + if pkg.PrimaryCode != title(i) { + t.Fatalf("evidence for %s carried %q (mismatched finding)", title(i), pkg.PrimaryCode) + } + } +} + +// TestExtractEvidenceTitleCollisionLastWins asserts the pre-indexed slice keeps +// input order so a duplicated title resolves to the LAST finding's evidence. +func TestExtractEvidenceTitleCollisionLastWins(t *testing.T) { + orig := extractForFindingFn + t.Cleanup(func() { extractForFindingFn = orig }) + extractForFindingFn = func(_ context.Context, finding schemas.ReviewFinding, _ string, _ map[string]string, _ []string) EvidencePackage { + return EvidencePackage{FindingTitle: finding.Title, PrimaryCode: finding.Body} + } + + findings := []schemas.ReviewFinding{ + {Title: "dup", Body: "first"}, + {Title: "dup", Body: "second"}, + } + res, err := ExtractEvidenceForFindings(context.Background(), findings, "/repo", nil, nil) + if err != nil { + t.Fatal(err) + } + if len(res) != 1 { + t.Fatalf("expected 1 key, got %d", len(res)) + } + if res["dup"].PrimaryCode != "second" { + t.Fatalf("collision resolved to %q, want last (%q)", res["dup"].PrimaryCode, "second") + } +} + +// --- helpers --- + +func title(i int) string { + return "f" + strconv.Itoa(i) +} + +func keysOf[V any](m map[string]V) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func joinLines(ss []string) string { + out := "" + for i, s := range ss { + if i > 0 { + out += "\n" + } + out += s + } + return out +} diff --git a/go/internal/fatal/fatal.go b/go/internal/fatal/fatal.go new file mode 100644 index 0000000..1de3046 --- /dev/null +++ b/go/internal/fatal/fatal.go @@ -0,0 +1,103 @@ +// Package fatal detects non-retryable harness failures (billing exhaustion, +// invalid credentials, disabled accounts, codex model/auth mismatches). +// +// It is a 1:1 port of swe_af/execution/fatal_error.py. When the underlying API +// returns a fatal error, retrying is pointless and wastes time, so +// FatalHarnessError is a distinct error type that short-circuits every retry +// layer. CheckFatalHarnessError inspects a *harness.Result and returns a +// FatalHarnessError immediately on match, so the real error message surfaces +// instead of a misleading generic one. +// +// Ref: https://github.com/Agent-Field/SWE-AF/issues/49 +package fatal + +import ( + "fmt" + "regexp" + + "github.com/Agent-Field/agentfield/sdk/go/harness" +) + +// fatalPatterns lists the patterns that indicate a non-retryable API failure. +// They are matched case-insensitively (the (?i) flag mirrors Python's +// re.IGNORECASE) against error_message strings. The pattern set is a verbatim +// port of _FATAL_PATTERNS in fatal_error.py — keep it byte-for-byte identical. +var fatalPatterns = compilePatterns( + `credit balance is too low`, + `insufficient.{0,20}credits?`, + `billing.{0,20}(expired|inactive|suspended)`, + `invalid.{0,10}api.?key`, + `invalid.{0,10}x-api-key`, + `(your )?api key is not valid`, + `authentication failed`, + `account has been disabled`, + `account.{0,10}is disabled`, + `unauthorized`, + `quota.{0,20}exceeded`, + // Codex model/auth mismatches: retrying with the same model + auth mode + // fails identically. Treating these as fatal surfaces the real reason + // (instead of a silent empty build that burns the retry cap) — e.g. the + // default `-codex` model under ChatGPT-account auth (#82 Gap 3). + `not supported when using codex with a chatgpt account`, + `requires a newer version of codex`, +) + +// compilePatterns compiles each pattern once with the case-insensitive flag. +func compilePatterns(patterns ...string) []*regexp.Regexp { + compiled := make([]*regexp.Regexp, len(patterns)) + for i, p := range patterns { + compiled[i] = regexp.MustCompile(`(?i)` + p) + } + return compiled +} + +// FatalHarnessError is returned when the harness encounters a non-retryable API +// error. It is designed to propagate through all retry layers (schema retries, +// SDK execution retries, pipeline retries) without being swallowed by generic +// error handling that would otherwise silently retry. +// +// It mirrors the Python FatalHarnessError(RuntimeError): Error() reproduces the +// exact "Fatal API error (non-retryable): " text, and OriginalMessage +// carries the untouched underlying message (Python's .original_message). +type FatalHarnessError struct { + OriginalMessage string +} + +// Error returns the wrapped message, byte-identical to the Python exception's +// str() form. +func (e *FatalHarnessError) Error() string { + return fmt.Sprintf("Fatal API error (non-retryable): %s", e.OriginalMessage) +} + +// IsFatalError reports whether errorMessage matches a known fatal API error +// pattern. An empty string is never fatal (mirrors the Python guard). +func IsFatalError(errorMessage string) bool { + if errorMessage == "" { + return false + } + for _, p := range fatalPatterns { + if p.MatchString(errorMessage) { + return true + } + } + return false +} + +// CheckFatalHarnessError inspects a harness result and returns a +// *FatalHarnessError if it indicates a non-retryable API failure, or nil +// otherwise. +// +// It should be called immediately after the harness returns, before any +// Result.Parsed == nil check, so the real error message surfaces instead of a +// misleading generic one. It reads Result.IsError and Result.ErrorMessage (the +// Go equivalents of the Python HarnessResult's is_error / error_message). +func CheckFatalHarnessError(result *harness.Result) error { + if result == nil || !result.IsError { + return nil + } + msg := result.ErrorMessage + if IsFatalError(msg) { + return &FatalHarnessError{OriginalMessage: msg} + } + return nil +} diff --git a/go/internal/fatal/fatal_test.go b/go/internal/fatal/fatal_test.go new file mode 100644 index 0000000..c5b0b15 --- /dev/null +++ b/go/internal/fatal/fatal_test.go @@ -0,0 +1,111 @@ +package fatal + +import ( + "errors" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/harness" +) + +// TestIsFatalError_EachPatternMatchesCaseInsensitively derives one case per +// fatal pattern (contract: each pattern matches case-insensitively). The inputs +// use mixed/upper case to prove the (?i) flag is honored. +func TestIsFatalError_EachPatternMatchesCaseInsensitively(t *testing.T) { + cases := []struct { + name string + msg string + }{ + {"credit_balance", "Your CREDIT Balance Is Too Low to proceed"}, + {"insufficient_credits", "Error: INSUFFICIENT account credits"}, + {"insufficient_credit_singular", "insufficient credit"}, + {"billing_expired", "BILLING subscription has EXPIRED"}, + {"billing_inactive", "billing is inactive"}, + {"billing_suspended", "billing account suspended"}, + {"invalid_api_key", "Invalid API Key provided"}, + {"invalid_apikey_no_sep", "invalid apikey"}, + {"invalid_x_api_key", "Invalid X-API-Key header"}, + {"api_key_not_valid_your", "Your API key is not valid"}, + {"api_key_not_valid_bare", "api key is not valid"}, + {"authentication_failed", "AUTHENTICATION Failed for request"}, + {"account_has_been_disabled", "This ACCOUNT Has Been Disabled"}, + {"account_is_disabled", "account is disabled"}, + {"unauthorized", "HTTP 401 UNAUTHORIZED"}, + {"quota_exceeded", "API QUOTA has been exceeded"}, + {"codex_chatgpt_account", "Model not supported when using codex with a chatgpt account"}, + {"codex_newer_version", "This requires a newer version of codex"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if !IsFatalError(tc.msg) { + t.Errorf("IsFatalError(%q) = false, want true", tc.msg) + } + }) + } +} + +// TestIsFatalError_BenignMessages verifies the negative half of the contract: a +// benign message (and the empty string) is not classified as fatal. +func TestIsFatalError_BenignMessages(t *testing.T) { + benign := []string{ + "", + "everything is fine, tests passed", + "rate limited, please retry shortly", + "connection reset by peer", + "the coder produced valid output", + } + for _, msg := range benign { + if IsFatalError(msg) { + t.Errorf("IsFatalError(%q) = true, want false", msg) + } + } +} + +// TestFatalHarnessError_Message asserts the wrapped message is byte-identical to +// the Python exception's str() form and that OriginalMessage is untouched. +func TestFatalHarnessError_Message(t *testing.T) { + e := &FatalHarnessError{OriginalMessage: "credit balance is too low"} + want := "Fatal API error (non-retryable): credit balance is too low" + if got := e.Error(); got != want { + t.Errorf("Error() = %q, want %q", got, want) + } + if e.OriginalMessage != "credit balance is too low" { + t.Errorf("OriginalMessage = %q, want %q", e.OriginalMessage, "credit balance is too low") + } +} + +// TestCheckFatalHarnessError covers the three branches: fatal error -> typed +// error via errors.As; error-but-benign -> nil; no error -> nil; nil result -> +// nil. +func TestCheckFatalHarnessError(t *testing.T) { + t.Run("fatal_error_returns_typed_error", func(t *testing.T) { + r := &harness.Result{IsError: true, ErrorMessage: "your credit balance is too low"} + err := CheckFatalHarnessError(r) + var fhe *FatalHarnessError + if !errors.As(err, &fhe) { + t.Fatalf("expected *FatalHarnessError, got %v", err) + } + if fhe.OriginalMessage != "your credit balance is too low" { + t.Errorf("OriginalMessage = %q", fhe.OriginalMessage) + } + }) + + t.Run("error_but_benign_returns_nil", func(t *testing.T) { + r := &harness.Result{IsError: true, ErrorMessage: "temporary network blip"} + if err := CheckFatalHarnessError(r); err != nil { + t.Errorf("expected nil, got %v", err) + } + }) + + t.Run("not_an_error_returns_nil", func(t *testing.T) { + r := &harness.Result{IsError: false, ErrorMessage: "your credit balance is too low"} + if err := CheckFatalHarnessError(r); err != nil { + t.Errorf("expected nil (is_error false short-circuits), got %v", err) + } + }) + + t.Run("nil_result_returns_nil", func(t *testing.T) { + if err := CheckFatalHarnessError(nil); err != nil { + t.Errorf("expected nil, got %v", err) + } + }) +} diff --git a/go/internal/gates/gates_test.go b/go/internal/gates/gates_test.go new file mode 100644 index 0000000..c5811ff --- /dev/null +++ b/go/internal/gates/gates_test.go @@ -0,0 +1,363 @@ +package gates + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// textResponse builds an *ai.Response whose Text() returns s, matching the +// shape the SDK produces (first choice, one text content part). +func textResponse(s string) *ai.Response { + return &ai.Response{ + Choices: []ai.Choice{ + {Message: ai.Message{Content: []ai.ContentPart{{Type: "text", Text: s}}}}, + }, + } +} + +// fakeAI is a test double for the AICaller seam. fn maps each prompt to a +// response/error; calls counts every invocation (thread-safe). +type fakeAI struct { + mu sync.Mutex + calls int + fn func(prompt string) (*ai.Response, error) +} + +func (f *fakeAI) AI(_ context.Context, prompt string, _ ...ai.Option) (*ai.Response, error) { + f.mu.Lock() + f.calls++ + f.mu.Unlock() + return f.fn(prompt) +} + +func (f *fakeAI) callCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.calls +} + +// -------------------- parseVerdict (contract V6) -------------------- + +func TestParseVerdictCleanJSON(t *testing.T) { + v := parseVerdict(`{"blocking": true, "reason": "breaks the build"}`) + if !v.blocking { + t.Fatalf("expected blocking=true, got false") + } + if v.reason != "breaks the build" { + t.Fatalf("reason = %q, want %q", v.reason, "breaks the build") + } +} + +func TestParseVerdictBlockingFalse(t *testing.T) { + v := parseVerdict(`{"blocking": false, "reason": "style only"}`) + if v.blocking { + t.Fatalf("expected blocking=false") + } + if v.reason != "style only" { + t.Fatalf("reason = %q", v.reason) + } +} + +func TestParseVerdictFencedJSON(t *testing.T) { + raw := "```json\n{\"blocking\": true, \"reason\": \"regression\"}\n```" + v := parseVerdict(raw) + if !v.blocking || v.reason != "regression" { + t.Fatalf("fenced json parse failed: %+v", v) + } +} + +func TestParseVerdictFencedNoLang(t *testing.T) { + raw := "```\n{\"blocking\": false, \"reason\": \"ok\"}\n```" + v := parseVerdict(raw) + if v.blocking || v.reason != "ok" { + t.Fatalf("fenced (no lang) parse failed: %+v", v) + } +} + +func TestParseVerdictEmbeddedInProse(t *testing.T) { + raw := `Here is my verdict: {"blocking": true, "reason": "auth bypass"} — thanks!` + v := parseVerdict(raw) + if !v.blocking || v.reason != "auth bypass" { + t.Fatalf("prose-embedded parse failed: %+v", v) + } +} + +func TestParseVerdictNonObjectArray(t *testing.T) { + v := parseVerdict(`[1, 2, 3]`) + if v.blocking { + t.Fatalf("non-object must be blocking=false") + } + if v.reason != "gate non-object" { + t.Fatalf("reason = %q, want %q", v.reason, "gate non-object") + } +} + +func TestParseVerdictNonObjectScalars(t *testing.T) { + // Valid JSON that is not an object → "gate non-object". + for _, raw := range []string{`42`, `"hi"`, `null`, `true`} { + v := parseVerdict(raw) + if v.blocking || v.reason != "gate non-object" { + t.Fatalf("raw %q: got %+v, want non-object", raw, v) + } + } +} + +func TestParseVerdictGarbage(t *testing.T) { + for _, raw := range []string{`not json at all`, `{broken`, ``, ` `} { + v := parseVerdict(raw) + if v.blocking { + t.Fatalf("raw %q: blocking must be false", raw) + } + if v.reason != "gate parse error" { + t.Fatalf("raw %q: reason = %q, want %q", raw, v.reason, "gate parse error") + } + } +} + +func TestParseVerdictReasonTruncatedTo400(t *testing.T) { + long := strings.Repeat("x", 500) + v := parseVerdict(fmt.Sprintf(`{"blocking": true, "reason": %q}`, long)) + if got := len([]rune(v.reason)); got != 400 { + t.Fatalf("reason length = %d, want 400", got) + } + if v.reason != strings.Repeat("x", 400) { + t.Fatalf("reason not truncated to first 400 chars") + } +} + +func TestParseVerdictReasonTruncatedByRune(t *testing.T) { + // 500 multi-byte runes must truncate to 400 runes (not 400 bytes). + long := strings.Repeat("é", 500) + v := parseVerdict(fmt.Sprintf(`{"blocking": false, "reason": %q}`, long)) + if got := len([]rune(v.reason)); got != 400 { + t.Fatalf("rune-truncated reason length = %d, want 400", got) + } +} + +func TestParseVerdictMissingReasonDefaultsEmpty(t *testing.T) { + v := parseVerdict(`{"blocking": true}`) + if !v.blocking { + t.Fatalf("expected blocking=true") + } + if v.reason != "" { + t.Fatalf("absent reason must default to empty, got %q", v.reason) + } +} + +func TestParseVerdictBlockingTruthiness(t *testing.T) { + // Python bool() coercion over non-bool JSON values. + cases := map[string]bool{ + `{"blocking": 1}`: true, + `{"blocking": 0}`: false, + `{"blocking": "yes"}`: true, + `{"blocking": ""}`: false, + `{"blocking": null}`: false, + `{}`: false, + } + for raw, want := range cases { + if got := parseVerdict(raw).blocking; got != want { + t.Fatalf("raw %q: blocking = %v, want %v", raw, got, want) + } + } +} + +// -------------------- ClassifyFindings (contract V6) -------------------- + +func scored(id, title string) schemas.ScoredFinding { + return schemas.ScoredFinding{ID: id, Title: title, Severity: "important", Confidence: 0.5} +} + +func TestClassifyEmptyIsPassthroughZeroCalls(t *testing.T) { + f := &fakeAI{fn: func(string) (*ai.Response, error) { + t.Fatalf("AI must not be called for empty findings") + return nil, nil + }} + out := ClassifyFindings(context.Background(), f, nil) + if len(out) != 0 { + t.Fatalf("expected empty passthrough, got %d", len(out)) + } + if f.callCount() != 0 { + t.Fatalf("expected zero AI calls, got %d", f.callCount()) + } +} + +func TestClassifyOneCallPerFinding(t *testing.T) { + f := &fakeAI{fn: func(string) (*ai.Response, error) { + return textResponse(`{"blocking": false, "reason": "ok"}`), nil + }} + findings := []schemas.ScoredFinding{scored("f1", "a"), scored("f2", "b"), scored("f3", "c")} + out := ClassifyFindings(context.Background(), f, findings) + if f.callCount() != 3 { + t.Fatalf("expected exactly 3 AI calls, got %d", f.callCount()) + } + if len(out) != 3 { + t.Fatalf("expected 3 findings out, got %d", len(out)) + } +} + +func TestClassifyAIFailureIsAdvisory(t *testing.T) { + f := &fakeAI{fn: func(string) (*ai.Response, error) { + return nil, errors.New("boom") + }} + out := ClassifyFindings(context.Background(), f, []schemas.ScoredFinding{scored("f1", "a")}) + if out[0].Blocking { + t.Fatalf("AI failure must produce blocking=false (advisory)") + } + if out[0].BlockingReason != "gate error" { + t.Fatalf("BlockingReason = %q, want %q", out[0].BlockingReason, "gate error") + } +} + +func TestClassifyOrderPreservedAndInputNotMutated(t *testing.T) { + // Each finding titled "T"; the fake echoes the index back as the reason + // and blocks even indices. Correct positional alignment is only possible if + // results land at results[i] regardless of goroutine completion order. + const n = 12 + findings := make([]schemas.ScoredFinding, n) + for i := 0; i < n; i++ { + findings[i] = scored(fmt.Sprintf("f%d", i), fmt.Sprintf("T%d", i)) + } + f := &fakeAI{fn: func(prompt string) (*ai.Response, error) { + idx := indexFromPrompt(t, prompt) + blocking := idx%2 == 0 + return textResponse(fmt.Sprintf(`{"blocking": %t, "reason": "r%d"}`, blocking, idx)), nil + }} + out := ClassifyFindings(context.Background(), f, findings) + for i := 0; i < n; i++ { + wantReason := fmt.Sprintf("r%d", i) + if out[i].BlockingReason != wantReason { + t.Fatalf("out[%d].BlockingReason = %q, want %q", i, out[i].BlockingReason, wantReason) + } + if want := i%2 == 0; out[i].Blocking != want { + t.Fatalf("out[%d].Blocking = %v, want %v", i, out[i].Blocking, want) + } + // Input must be untouched (pure function). + if findings[i].Blocking || findings[i].BlockingReason != "" { + t.Fatalf("input finding %d was mutated: %+v", i, findings[i]) + } + } +} + +// indexFromPrompt extracts the integer N from the "Title: TN" line the +// merge-gate user prompt embeds. +func indexFromPrompt(t *testing.T, prompt string) int { + t.Helper() + marker := "Title: T" + i := strings.Index(prompt, marker) + if i < 0 { + t.Fatalf("prompt missing %q marker: %q", marker, prompt) + } + rest := prompt[i+len(marker):] + if nl := strings.IndexByte(rest, '\n'); nl >= 0 { + rest = rest[:nl] + } + var n int + if _, err := fmt.Sscanf(rest, "%d", &n); err != nil { + t.Fatalf("cannot parse index from %q: %v", rest, err) + } + return n +} + +// -------------------- PolishComments (contract V6) -------------------- + +func comment(path, body string) schemas.GitHubComment { + return schemas.GitHubComment{Path: path, Line: 10, Side: "RIGHT", Body: body} +} + +func TestPolishEmptyIsPassthroughZeroCalls(t *testing.T) { + f := &fakeAI{fn: func(string) (*ai.Response, error) { + t.Fatalf("AI must not be called for empty comments") + return nil, nil + }} + out := PolishComments(context.Background(), f, nil) + if len(out) != 0 { + t.Fatalf("expected empty passthrough, got %d", len(out)) + } + if f.callCount() != 0 { + t.Fatalf("expected zero AI calls, got %d", f.callCount()) + } +} + +func TestPolishRewritesBodyPreservesPathAndFields(t *testing.T) { + f := &fakeAI{fn: func(string) (*ai.Response, error) { + return textResponse("polished text"), nil + }} + in := []schemas.GitHubComment{comment("src/a.go", "original body")} + out := PolishComments(context.Background(), f, in) + if out[0].Body != "polished text" { + t.Fatalf("body not rewritten: %q", out[0].Body) + } + if out[0].Path != "src/a.go" || out[0].Line != 10 || out[0].Side != "RIGHT" { + t.Fatalf("non-body fields not preserved: %+v", out[0]) + } + // Input not mutated. + if in[0].Body != "original body" { + t.Fatalf("input comment mutated: %q", in[0].Body) + } +} + +func TestPolishFailureKeepsOriginalBody(t *testing.T) { + f := &fakeAI{fn: func(string) (*ai.Response, error) { + return nil, errors.New("boom") + }} + out := PolishComments(context.Background(), f, []schemas.GitHubComment{comment("p", "keep me")}) + if out[0].Body != "keep me" { + t.Fatalf("failure must keep original body, got %q", out[0].Body) + } +} + +func TestPolishEmptyRewriteKeepsOriginalBody(t *testing.T) { + // Whitespace-only rewrite → strip to "" → keep original (Python `text or body`). + f := &fakeAI{fn: func(string) (*ai.Response, error) { + return textResponse(" \n "), nil + }} + out := PolishComments(context.Background(), f, []schemas.GitHubComment{comment("p", "keep me too")}) + if out[0].Body != "keep me too" { + t.Fatalf("empty rewrite must keep original body, got %q", out[0].Body) + } +} + +func TestPolishPreservesCalloutVerbatim(t *testing.T) { + // The polish prompt instructs the model to preserve callouts; a faithful + // rewrite that echoes the callout must survive intact through the code path. + body := "> [!CAUTION] **Must-fix before merge.**\n\nFix the null deref." + f := &fakeAI{fn: func(string) (*ai.Response, error) { + return textResponse(body), nil + }} + out := PolishComments(context.Background(), f, []schemas.GitHubComment{comment("p", body)}) + if !strings.HasPrefix(out[0].Body, "> [!CAUTION] **Must-fix before merge.**") { + t.Fatalf("callout not preserved: %q", out[0].Body) + } +} + +func TestPolishOrderPreserved(t *testing.T) { + const n = 10 + in := make([]schemas.GitHubComment, n) + for i := 0; i < n; i++ { + in[i] = comment(fmt.Sprintf("p%d", i), fmt.Sprintf("body-%d", i)) + } + f := &fakeAI{fn: func(prompt string) (*ai.Response, error) { + // Echo the original body suffixed, so alignment is checkable. + marker := "body-" + i := strings.Index(prompt, marker) + if i < 0 { + t.Fatalf("prompt missing body marker: %q", prompt) + } + return textResponse(prompt[i:] + "|polished"), nil + }} + out := PolishComments(context.Background(), f, in) + for i := 0; i < n; i++ { + want := fmt.Sprintf("body-%d|polished", i) + if out[i].Body != want { + t.Fatalf("out[%d].Body = %q, want %q", i, out[i].Body, want) + } + } +} diff --git a/go/internal/gates/merge_gate.go b/go/internal/gates/merge_gate.go new file mode 100644 index 0000000..0744c79 --- /dev/null +++ b/go/internal/gates/merge_gate.go @@ -0,0 +1,224 @@ +// Package gates ports the two parallel `.ai()` post-processing passes from the +// Python PR-AF pipeline: the merge-blocker gate (merge_gate.py) and the comment +// polish pass (polish.py). Both fan out one AI call per item, preserve input +// order, and degrade gracefully on per-item failure. +// +// Merge gate — a separate lens from severity. Severity asks "how bad is this +// issue?"; the merge gate asks "must this be fixed BEFORE the PR ships, or can +// it ship and be addressed in a follow-up?". Failure mode: default to +// blocking=false on every error path, because a false negative (a real blocker +// slips through as advisory) is recoverable by human review, whereas a false +// positive (advisory issue flagged blocking) erodes trust and blocks merge. +package gates + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + "sync" + + "github.com/Agent-Field/agentfield/sdk/go/agent" + "github.com/Agent-Field/agentfield/sdk/go/ai" + + "github.com/Agent-Field/pr-af/go/internal/prompts" + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// AICaller is the minimal one-method seam the two `.ai()` gates drive. It +// mirrors the AgentField Go SDK Agent.AI (sdk/go/agent/agent.go), so the live +// *agent.Agent satisfies it unchanged and tests supply a fake. This is the Go +// analogue of Python's `app.ai(prompt, system=..., response_format="json")`: +// the system prompt is passed as ai.WithSystem and JSON mode as ai.WithJSONMode. +type AICaller interface { + AI(ctx context.Context, prompt string, opts ...ai.Option) (*ai.Response, error) +} + +// Compile-time proof the real SDK agent is an AICaller, so the orchestrator can +// hand the live *agent.Agent to these gates unchanged. +var _ AICaller = (*agent.Agent)(nil) + +// mergeGateVerdict ports merge_gate.MergeGateVerdict: the per-finding gate +// output. blocking defaults false; reason is capped at 400 chars. +type mergeGateVerdict struct { + blocking bool + reason string +} + +// responseText extracts the text body from an AI response — the Go analogue of +// Python's `getattr(out, "text", None) or getattr(out, "content", None) or +// str(out)`. The Go *ai.Response exposes a single Text() accessor, so the +// Python `.content`/`str(out)` fallbacks (which key off the Python SDK object's +// alternate attributes) collapse to this one call. A nil response yields "". +func responseText(out *ai.Response) string { + if out == nil { + return "" + } + return out.Text() +} + +// parseVerdict ports merge_gate._parse_verdict: tolerant JSON parsing that +// strips markdown fences, extracts the first `{...}` block from surrounding +// prose, and falls back to a non-blocking advisory verdict on any malformed +// input. The three fallback reason strings ("gate parse error", +// "gate non-object", "gate error") are load-bearing parity contracts. +func parseVerdict(raw string) mergeGateVerdict { + text := strings.TrimSpace(raw) + if strings.HasPrefix(text, "```") { + text = strings.Trim(text, "`") + if strings.HasPrefix(text, "json") { + text = strings.TrimSpace(text[4:]) + } + } + // Find first `{...}` block (Python: text.find("{") / text.rfind("}")). + start := strings.Index(text, "{") + end := strings.LastIndex(text, "}") + if start >= 0 && end > start { + text = text[start : end+1] + } + var data any + if err := json.Unmarshal([]byte(text), &data); err != nil { + return mergeGateVerdict{blocking: false, reason: "gate parse error"} + } + obj, ok := data.(map[string]any) + if !ok { + // Valid JSON that is not an object (array, number, string, bool, null). + return mergeGateVerdict{blocking: false, reason: "gate non-object"} + } + return mergeGateVerdict{ + // Python: bool(data.get("blocking", False)) — apply Python truthiness. + blocking: pyTruthy(obj["blocking"]), + // Python: str(data.get("reason", ""))[:400] — code-point truncation. + reason: truncateRunes(reasonStr(obj), 400), + } +} + +// pyTruthy mirrors Python's bool() coercion over the value types that +// encoding/json produces (nil, bool, float64, string, []any, map[string]any): +// zero/empty is false, everything else is true. +func pyTruthy(v any) bool { + switch t := v.(type) { + case nil: + return false + case bool: + return t + case float64: + return t != 0 + case string: + return t != "" + case []any: + return len(t) > 0 + case map[string]any: + return len(t) > 0 + default: + return false + } +} + +// reasonStr reproduces Python's `str(data.get("reason", ""))`: an absent key +// yields the default "" (NOT "None"), whereas a present JSON null yields +// str(None) == "None". +func reasonStr(obj map[string]any) string { + v, ok := obj["reason"] + if !ok { + return "" + } + return pyStr(v) +} + +// pyStr approximates Python's str() over JSON scalars. Strings pass through; +// null/bool/number match CPython's str() for the realistic cases. Whole floats +// render without a decimal point (matching str(int)); containers fall back to a +// JSON rendering. (Divergence: CPython's int-vs-float distinction for numeric +// reasons cannot be recovered because encoding/json decodes every JSON number +// to float64 — an unreachable edge since reasons are always strings.) +func pyStr(v any) string { + switch t := v.(type) { + case nil: + return "None" + case bool: + if t { + return "True" + } + return "False" + case string: + return t + case float64: + return strconv.FormatFloat(t, 'f', -1, 64) + default: + if b, err := json.Marshal(t); err == nil { + return string(b) + } + return fmt.Sprintf("%v", t) + } +} + +// truncateRunes mirrors Python's str[:n] — truncation by code point, not byte. +func truncateRunes(s string, n int) string { + if n < 0 { + n = 0 + } + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) +} + +// gateOne ports merge_gate._gate_one: one AI call for a single finding. Any AI +// error is swallowed and mapped to the non-blocking "gate error" verdict so a +// gate outage never blocks merge (mirrors Python's try/except returning +// MergeGateVerdict(blocking=False, reason="gate error")). +func gateOne(ctx context.Context, app AICaller, f schemas.ScoredFinding) mergeGateVerdict { + // response_format=json (WithJSONMode) forces JSON output so reasoning models + // don't leak chain-of-thought before the verdict and truncate the answer. + out, err := app.AI(ctx, prompts.MergeGateUserPrompt(f), + ai.WithSystem(prompts.MergeGateSystem), + ai.WithJSONMode(), + ) + if err != nil { + fmt.Printf("[PR-AF] Merge-gate skipped for %s: %T\n", f.ID, err) + return mergeGateVerdict{blocking: false, reason: "gate error"} + } + return parseVerdict(responseText(out)) +} + +// ClassifyFindings ports merge_gate.classify_findings: one AI call per finding, +// fired in parallel, original order preserved. Returns a NEW slice with +// Blocking and BlockingReason populated; the input slice and its elements are +// not mutated (pure, mirroring pydantic model_copy). +// +// Concurrency: Python uses asyncio.gather WITHOUT return_exceptions, but +// _gate_one catches every exception internally and never raises, so no error +// ever propagates through gather. The faithful Go mirror is a WaitGroup writing +// into a pre-indexed results slice — order is positional, not completion-order. +func ClassifyFindings(ctx context.Context, app AICaller, findings []schemas.ScoredFinding) []schemas.ScoredFinding { + if len(findings) == 0 { + return findings + } + verdicts := make([]mergeGateVerdict, len(findings)) + var wg sync.WaitGroup + for i := range findings { + wg.Add(1) + go func(i int) { + defer wg.Done() + verdicts[i] = gateOne(ctx, app, findings[i]) + }(i) + } + wg.Wait() + + classified := make([]schemas.ScoredFinding, len(findings)) + blockingCount := 0 + for i, f := range findings { + nf := f // shallow struct copy — sets only the two scalar gate fields. + nf.Blocking = verdicts[i].blocking + nf.BlockingReason = verdicts[i].reason + classified[i] = nf + if nf.Blocking { + blockingCount++ + } + } + fmt.Printf("[PR-AF] Merge-gate: %d/%d findings classified blocking\n", blockingCount, len(classified)) + return classified +} diff --git a/go/internal/gates/polish.go b/go/internal/gates/polish.go new file mode 100644 index 0000000..bb3ee1c --- /dev/null +++ b/go/internal/gates/polish.go @@ -0,0 +1,72 @@ +package gates + +// Parallel polish pass (port of polish.py) — rewrites each inline comment body +// to be concise and developer-focused right before posting to GitHub. One AI +// call per comment, fired in parallel. On any per-comment failure the original +// body is kept unchanged. + +import ( + "context" + "fmt" + "strings" + "sync" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + + "github.com/Agent-Field/pr-af/go/internal/prompts" + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// polishOne ports polish._polish_one: rewrite a single comment body. Any AI +// error, or an empty rewritten body, falls back to the original body verbatim. +func polishOne(ctx context.Context, app AICaller, body string) string { + out, err := app.AI(ctx, prompts.PolishUserPrompt(body), ai.WithSystem(prompts.PolishSystem)) + if err != nil { + fmt.Printf("[PR-AF] Polish skipped: %T\n", err) + return body + } + text := strings.TrimSpace(responseText(out)) + if text == "" { + return body + } + return text +} + +// PolishComments ports polish.polish_comments: rewrite each comment body in +// parallel, original order preserved. Returns a NEW slice; each comment's Body +// is replaced with the rewritten text while every other field (Path, Line, +// Side) is copied unchanged — path preservation is guaranteed structurally +// here, and callout/markdown preservation is enforced by the polish system +// prompt. Pure — the input slice and its elements are not mutated. +// +// Concurrency mirrors classify_findings: Python's asyncio.gather never sees an +// exception because _polish_one catches its own AI errors, so a WaitGroup over +// a pre-indexed results slice is the faithful order-preserving fan-out. +func PolishComments(ctx context.Context, app AICaller, comments []schemas.GitHubComment) []schemas.GitHubComment { + if len(comments) == 0 { + return comments + } + newBodies := make([]string, len(comments)) + var wg sync.WaitGroup + for i := range comments { + wg.Add(1) + go func(i int) { + defer wg.Done() + newBodies[i] = polishOne(ctx, app, comments[i].Body) + }(i) + } + wg.Wait() + + polished := make([]schemas.GitHubComment, len(comments)) + changed := 0 + for i, c := range comments { + nc := c // shallow struct copy — replaces only Body. + nc.Body = newBodies[i] + polished[i] = nc + if newBodies[i] != c.Body { + changed++ + } + } + fmt.Printf("[PR-AF] Polish complete: %d/%d comments rewritten\n", changed, len(comments)) + return polished +} diff --git a/go/internal/github/client.go b/go/internal/github/client.go new file mode 100644 index 0000000..6966363 --- /dev/null +++ b/go/internal/github/client.go @@ -0,0 +1,520 @@ +// Package github is a small REST client for the GitHub API used by the PR-AF +// review pipeline: fetch a PR (metadata + changed files + commits + unified +// diff), and post a review with inline comments. +// +// It is a faithful port of src/pr_af/github/client.py. Behavioural contract +// (byte-exact with Python): +// - ParsePRURL: regexp ^https?://github\.com/([^/]+)/([^/]+)/pull/(\d+). +// - FetchPR: 4 attempts, sleep 2.0*(attempt+1)s between them, retry on +// transport errors + 5xx + 403/429, fail fast on any other 4xx. +// - Per-request timeouts: 30s (fetch), 15s (installation token), 60s (post). +// - Redirects followed (Go's default http.Client) — matches httpx +// follow_redirects=True on the fetch client (see divergence notes below). +// - Pagination of /files and /commits at per_page=100. +// - Diff via Accept: application/vnd.github.v3.diff. +// - Auth: PAT (constructor arg or GH_TOKEN) OR GitHub App (RS256 JWT + +// cached installation tokens) when GITHUB_APP_ID and +// GITHUB_APP_PRIVATE_KEY are both set. +// - PostReview surfaces HTTP failures (incl. 422 "own pull request") as a +// typed *APIError carrying StatusCode + Body so the orchestrator can +// detect the 422 and downgrade the event to COMMENT. +// +// Divergences from Python (intentional, behaviourally inert in production): +// - The installation-token endpoints are routed through the client's +// baseURL instead of a hardcoded https://api.github.com, so tests can +// point them at an httptest server. In production baseURL IS +// https://api.github.com, so behaviour is identical. +// - Go's http.Client follows redirects for every request; Python leaves +// follow_redirects at its default (off) for the installation-token and +// post-review calls and only enables it for fetch. Those endpoints never +// redirect on api.github.com, so this is inert. +// - The installation-token cache is guarded by a mutex (Python's asyncio +// single-thread has none); FetchPR/PostReview may run concurrently in Go. +// - Debug print() lines from the Python client are dropped (they are stdout +// diagnostics, not behaviour). +package github + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "regexp" + "strconv" + "sync" + "time" + + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +const defaultBaseURL = "https://api.github.com" + +var prURLRe = regexp.MustCompile(`^https?://github\.com/([^/]+)/([^/]+)/pull/(\d+)`) + +// APIError is returned for any GitHub response with status >= 400. It carries +// the status code and raw response body so callers can classify failures — in +// particular the orchestrator inspects StatusCode==422 with Body containing +// "own pull request" to downgrade the review event to COMMENT and retry. +type APIError struct { + StatusCode int + Body string + URL string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("github api error: status=%d url=%s body=%s", e.StatusCode, e.URL, e.Body) +} + +// transportError wraps a connection/read/timeout failure (anything returned by +// http.Client.Do or reading the response body). It mirrors httpx.TransportError +// so the FetchPR retry loop can distinguish retryable transport failures from +// non-retryable local errors (e.g. JSON decode), which Python does not catch. +type transportError struct{ err error } + +func (e *transportError) Error() string { return e.err.Error() } +func (e *transportError) Unwrap() error { return e.err } + +// Client is the GitHub surface the review pipeline depends on. +type Client interface { + ParsePRURL(url string) (owner, repo string, number int, err error) + FetchPR(ctx context.Context, owner, repo string, number int) (schemas.GitHubPRData, error) + PostReview(ctx context.Context, owner, repo string, number int, review schemas.GitHubReview, commitID string) (map[string]any, error) +} + +type cachedToken struct { + token string + expiresAt time.Time +} + +type client struct { + token string + appID string + appPrivateKey string + useAppAuth bool + + baseURL string + httpClient *http.Client + + cacheMu sync.Mutex + tokenCache map[int]cachedToken + + // sleepFn overrides the inter-attempt backoff sleep (tests set a no-op). + // nil => real ctx-aware sleep. + sleepFn func(ctx context.Context, d time.Duration) error +} + +var _ Client = (*client)(nil) + +// NewClient builds a GitHub client. Auth is resolved at construction, matching +// Python's per-instance _use_app_auth: +// - PAT: the token argument, or GH_TOKEN when the argument is empty. +// - GitHub App: used when GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY are both +// set; the installation token then takes precedence over the PAT. +func NewClient(token string) *client { + if token == "" { + token = os.Getenv("GH_TOKEN") + } + appID := os.Getenv("GITHUB_APP_ID") + appKey := os.Getenv("GITHUB_APP_PRIVATE_KEY") + return &client{ + token: token, + appID: appID, + appPrivateKey: appKey, + useAppAuth: isGitHubAppConfigured(appID, appKey), + baseURL: defaultBaseURL, + httpClient: &http.Client{}, + tokenCache: map[int]cachedToken{}, + } +} + +// ParsePRURL extracts owner, repo and PR number from a GitHub PR URL. +func (c *client) ParsePRURL(url string) (owner, repo string, number int, err error) { + m := prURLRe.FindStringSubmatch(url) + if m == nil { + return "", "", 0, fmt.Errorf("Invalid GitHub PR URL: %s", url) + } + n, convErr := strconv.Atoi(m[3]) + if convErr != nil { + return "", "", 0, fmt.Errorf("Invalid GitHub PR URL: %s", url) + } + return m[1], m[2], n, nil +} + +// sleep waits d, honouring ctx cancellation. Overridable via sleepFn for tests. +func (c *client) sleep(ctx context.Context, d time.Duration) error { + if c.sleepFn != nil { + return c.sleepFn(ctx, d) + } + t := time.NewTimer(d) + defer t.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + return nil + } +} + +// request performs a single HTTP request with a per-call timeout, returning the +// response body. On status >= 400 it returns (*APIError). On a transport/read +// failure it returns (*transportError). jsonBody, when non-nil, is JSON-encoded +// as the request body with Content-Type: application/json. +func (c *client) request(ctx context.Context, method, rawurl string, headers map[string]string, jsonBody any, timeout time.Duration) ([]byte, error) { + reqCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + var body io.Reader + if jsonBody != nil { + b, err := json.Marshal(jsonBody) + if err != nil { + return nil, err + } + body = bytes.NewReader(b) + } + req, err := http.NewRequestWithContext(reqCtx, method, rawurl, body) + if err != nil { + return nil, err + } + for k, v := range headers { + req.Header.Set(k, v) + } + if jsonBody != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, &transportError{err: err} + } + defer resp.Body.Close() + data, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return nil, &transportError{err: readErr} + } + if resp.StatusCode >= 400 { + return data, &APIError{StatusCode: resp.StatusCode, Body: string(data), URL: rawurl} + } + return data, nil +} + +// generateAppJWT is defined in jwt.go. + +// isGitHubAppConfigured reports whether both GitHub App credentials are present. +func isGitHubAppConfigured(appID, key string) bool { + return appID != "" && key != "" +} + +// getInstallationToken exchanges the App JWT for a scoped installation token +// for owner/repo. The installation id is always looked up first; the token +// itself is cached until 5 minutes before expiry. +func (c *client) getInstallationToken(ctx context.Context, owner, repo string) (string, error) { + appJWT, err := generateAppJWT(c.appID, c.appPrivateKey) + if err != nil { + return "", err + } + headers := map[string]string{ + "Authorization": "Bearer " + appJWT, + "Accept": "application/vnd.github+json", + } + + // Find the installation for this repo. + instBody, err := c.request(ctx, http.MethodGet, + fmt.Sprintf("%s/repos/%s/%s/installation", c.baseURL, owner, repo), + headers, nil, 15*time.Second) + if err != nil { + return "", err + } + var inst struct { + ID int `json:"id"` + } + if err := json.Unmarshal(instBody, &inst); err != nil { + return "", err + } + installationID := inst.ID + + // Cache check (5 min buffer before expiry). + c.cacheMu.Lock() + if ct, ok := c.tokenCache[installationID]; ok { + if time.Now().Before(ct.expiresAt.Add(-5 * time.Minute)) { + c.cacheMu.Unlock() + return ct.token, nil + } + } + c.cacheMu.Unlock() + + // Mint a fresh installation token. + tokBody, err := c.request(ctx, http.MethodPost, + fmt.Sprintf("%s/app/installations/%d/access_tokens", c.baseURL, installationID), + headers, nil, 15*time.Second) + if err != nil { + return "", err + } + var tokResp struct { + Token string `json:"token"` + ExpiresAt string `json:"expires_at"` + } + if err := json.Unmarshal(tokBody, &tokResp); err != nil { + return "", err + } + expiresAt, err := time.Parse(time.RFC3339, tokResp.ExpiresAt) + if err != nil { + return "", err + } + + c.cacheMu.Lock() + c.tokenCache[installationID] = cachedToken{token: tokResp.Token, expiresAt: expiresAt} + c.cacheMu.Unlock() + return tokResp.Token, nil +} + +// headersForRepo builds request headers, preferring a GitHub App installation +// token over the PAT. +func (c *client) headersForRepo(ctx context.Context, owner, repo string) (map[string]string, error) { + headers := map[string]string{"Accept": "application/vnd.github+json"} + if c.useAppAuth { + token, err := c.getInstallationToken(ctx, owner, repo) + if err != nil { + return nil, err + } + headers["Authorization"] = "Bearer " + token + } else if c.token != "" { + headers["Authorization"] = "Bearer " + c.token + } + return headers, nil +} + +// FetchPR fetches PR metadata, changed files, commit messages and the unified +// diff, retrying transient failures. +func (c *client) FetchPR(ctx context.Context, owner, repo string, number int) (schemas.GitHubPRData, error) { + var lastErr error + for attempt := 0; attempt < 4; attempt++ { + data, err := c.fetchPROnce(ctx, owner, repo, number) + if err == nil { + return data, nil + } + + var apiErr *APIError + if errors.As(err, &apiErr) { + s := apiErr.StatusCode + if s >= 400 && s < 500 && s != 403 && s != 429 { + return schemas.GitHubPRData{}, err // fail fast on other 4xx + } + // 5xx / 403 / 429 => retryable + } else { + var te *transportError + if !errors.As(err, &te) { + // Not an HTTP status error and not a transport error (e.g. a + // JSON decode error). Python never catches these, so propagate + // immediately rather than retrying. + return schemas.GitHubPRData{}, err + } + // transport => retryable + } + + lastErr = err + if serr := c.sleep(ctx, time.Duration(2*(attempt+1))*time.Second); serr != nil { + return schemas.GitHubPRData{}, serr + } + } + return schemas.GitHubPRData{}, lastErr +} + +// prMetadataResp mirrors the fields read from the PR-metadata JSON. +type prMetadataResp struct { + Title string `json:"title"` + Body *string `json:"body"` + Labels []struct { + Name string `json:"name"` + } `json:"labels"` + User struct { + Login string `json:"login"` + } `json:"user"` + Base struct { + SHA string `json:"sha"` + } `json:"base"` + Head struct { + SHA string `json:"sha"` + } `json:"head"` +} + +type fileResp struct { + Filename string `json:"filename"` + Status *string `json:"status"` // pointer: absent => default "modified" + Additions int `json:"additions"` + Deletions int `json:"deletions"` + Patch string `json:"patch"` + PreviousFilename *string `json:"previous_filename"` +} + +type commitResp struct { + Commit struct { + Message string `json:"message"` + } `json:"commit"` +} + +func (c *client) fetchPROnce(ctx context.Context, owner, repo string, number int) (schemas.GitHubPRData, error) { + authHeaders, err := c.headersForRepo(ctx, owner, repo) + if err != nil { + return schemas.GitHubPRData{}, err + } + + // 1. PR metadata. + metaBody, err := c.request(ctx, http.MethodGet, + fmt.Sprintf("%s/repos/%s/%s/pulls/%d", c.baseURL, owner, repo, number), + authHeaders, nil, 30*time.Second) + if err != nil { + return schemas.GitHubPRData{}, err + } + var meta prMetadataResp + if err := json.Unmarshal(metaBody, &meta); err != nil { + return schemas.GitHubPRData{}, err + } + + // 2. Changed files (paginated at per_page=100). + changedFiles := []schemas.ChangedFile{} + for page := 1; ; page++ { + filesBody, err := c.request(ctx, http.MethodGet, + fmt.Sprintf("%s/repos/%s/%s/pulls/%d/files?per_page=100&page=%d", c.baseURL, owner, repo, number, page), + authHeaders, nil, 30*time.Second) + if err != nil { + return schemas.GitHubPRData{}, err + } + var filesPage []fileResp + if err := json.Unmarshal(filesBody, &filesPage); err != nil { + return schemas.GitHubPRData{}, err + } + if len(filesPage) == 0 { + break + } + for _, f := range filesPage { + status := "modified" + if f.Status != nil { + status = *f.Status + } + changedFiles = append(changedFiles, schemas.ChangedFile{ + Path: f.Filename, + Status: status, + Additions: f.Additions, + Deletions: f.Deletions, + Patch: f.Patch, + PreviousPath: f.PreviousFilename, + }) + } + if len(filesPage) < 100 { + break + } + } + + // 3. Commit messages (paginated at per_page=100). + commitMessages := []string{} + for page := 1; ; page++ { + commitsBody, err := c.request(ctx, http.MethodGet, + fmt.Sprintf("%s/repos/%s/%s/pulls/%d/commits?per_page=100&page=%d", c.baseURL, owner, repo, number, page), + authHeaders, nil, 30*time.Second) + if err != nil { + return schemas.GitHubPRData{}, err + } + var commitsData []commitResp + if err := json.Unmarshal(commitsBody, &commitsData); err != nil { + return schemas.GitHubPRData{}, err + } + if len(commitsData) == 0 { + break + } + for _, cm := range commitsData { + if cm.Commit.Message != "" { + commitMessages = append(commitMessages, cm.Commit.Message) + } + } + if len(commitsData) < 100 { + break + } + } + + // 4. Unified diff (same endpoint, diff Accept header). + diffHeaders := make(map[string]string, len(authHeaders)) + for k, v := range authHeaders { + diffHeaders[k] = v + } + diffHeaders["Accept"] = "application/vnd.github.v3.diff" + diffBody, err := c.request(ctx, http.MethodGet, + fmt.Sprintf("%s/repos/%s/%s/pulls/%d", c.baseURL, owner, repo, number), + diffHeaders, nil, 30*time.Second) + if err != nil { + return schemas.GitHubPRData{}, err + } + + labels := []string{} + for _, l := range meta.Labels { + if l.Name != "" { + labels = append(labels, l.Name) + } + } + description := "" + if meta.Body != nil { + description = *meta.Body + } + + return schemas.GitHubPRData{ + Owner: owner, + Repo: repo, + Number: number, + Title: meta.Title, + Description: description, + Labels: labels, + Author: meta.User.Login, + BaseSHA: meta.Base.SHA, + HeadSHA: meta.Head.SHA, + CommitMessages: commitMessages, + Diff: string(diffBody), + ChangedFiles: changedFiles, + }, nil +} + +// PostReview posts a review with inline comments to a PR. Comments are included +// only when their original list is non-empty, filtered to those with a path and +// a positive line. On any HTTP failure (including the 422 "own pull request" +// case the orchestrator downgrades) it returns a typed *APIError. +func (c *client) PostReview(ctx context.Context, owner, repo string, number int, review schemas.GitHubReview, commitID string) (map[string]any, error) { + payload := map[string]any{ + "body": review.Body, + "event": review.Event, + } + if commitID != "" { + payload["commit_id"] = commitID + } + if len(review.Comments) > 0 { + comments := make([]map[string]any, 0, len(review.Comments)) + for _, cm := range review.Comments { + if cm.Path != "" && cm.Line > 0 { + comments = append(comments, map[string]any{ + "path": cm.Path, + "line": cm.Line, + "side": cm.Side, + "body": cm.Body, + }) + } + } + payload["comments"] = comments + } + + authHeaders, err := c.headersForRepo(ctx, owner, repo) + if err != nil { + return nil, err + } + body, err := c.request(ctx, http.MethodPost, + fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews", c.baseURL, owner, repo, number), + authHeaders, payload, 60*time.Second) + if err != nil { + return nil, err + } + var result map[string]any + if err := json.Unmarshal(body, &result); err != nil { + return nil, err + } + return result, nil +} diff --git a/go/internal/github/client_test.go b/go/internal/github/client_test.go new file mode 100644 index 0000000..f77b49b --- /dev/null +++ b/go/internal/github/client_test.go @@ -0,0 +1,797 @@ +package github + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/pr-af/go/internal/schemas" + "github.com/golang-jwt/jwt/v5" +) + +// ---- helpers --------------------------------------------------------------- + +func testClient(baseURL, token string) *client { + return &client{ + token: token, + baseURL: baseURL, + httpClient: &http.Client{}, + tokenCache: map[int]cachedToken{}, + sleepFn: func(context.Context, time.Duration) error { return nil }, + } +} + +func testAppClient(baseURL, appID, pem string) *client { + return &client{ + appID: appID, + appPrivateKey: pem, + useAppAuth: true, + baseURL: baseURL, + httpClient: &http.Client{}, + tokenCache: map[int]cachedToken{}, + sleepFn: func(context.Context, time.Duration) error { return nil }, + } +} + +func genRSAKeyPEM(t *testing.T) (privPEM string, pub *rsa.PublicKey) { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("rsa.GenerateKey: %v", err) + } + der := x509.MarshalPKCS1PrivateKey(key) + block := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: der}) + return string(block), &key.PublicKey +} + +// ---- ParsePRURL ------------------------------------------------------------ + +func TestParsePRURL(t *testing.T) { + c := testClient("http://x", "") + cases := []struct { + url string + owner string + repo string + number int + wantErr bool + }{ + {"https://github.com/octocat/hello/pull/123", "octocat", "hello", 123, false}, + {"http://github.com/foo/bar/pull/7", "foo", "bar", 7, false}, + {"https://github.com/o/r/pull/123/files", "o", "r", 123, false}, // trailing path allowed + {"https://github.com/o/r/pull/123#discussion_r1", "o", "r", 123, false}, // fragment allowed + {"https://github.com/o/r/pull/12abc", "o", "r", 12, false}, // not end-anchored: digits only + {"https://gitlab.com/o/r/pull/1", "", "", 0, true}, + {"https://github.com/o/r/pulls/1", "", "", 0, true}, + {"https://github.com/o/r/pull/abc", "", "", 0, true}, + {"not a url", "", "", 0, true}, + {"", "", "", 0, true}, + } + for _, tc := range cases { + owner, repo, number, err := c.ParsePRURL(tc.url) + if tc.wantErr { + if err == nil { + t.Errorf("ParsePRURL(%q): expected error, got none", tc.url) + } + continue + } + if err != nil { + t.Errorf("ParsePRURL(%q): unexpected error: %v", tc.url, err) + continue + } + if owner != tc.owner || repo != tc.repo || number != tc.number { + t.Errorf("ParsePRURL(%q) = (%q,%q,%d), want (%q,%q,%d)", + tc.url, owner, repo, number, tc.owner, tc.repo, tc.number) + } + } +} + +// ---- fake GitHub for FetchPR ---------------------------------------------- + +const metaJSON = `{"title":"Add feature","body":"Body text","labels":[{"name":"bug"},{"name":""},{"name":"enhancement"}],"user":{"login":"octocat"},"base":{"sha":"basesha111"},"head":{"sha":"headsha222"}}` + +const diffText = "diff --git a/x b/x\n@@ -0,0 +1 @@\n+hi\n" + +type ghRec struct { + mu sync.Mutex + metaCalls int + filesPages map[int]bool + perPageFile string + filesAccept string + auth string + metaAccept string + diffAccept string + sawDiff bool + metaStatus func(call int) int +} + +func filesPage(page int) []map[string]any { + switch page { + case 1: + out := make([]map[string]any, 100) + for i := range out { + out[i] = map[string]any{ + "filename": fmt.Sprintf("f%d.go", i), + "status": "modified", + "additions": i, + "deletions": 0, + "patch": "@@", + } + } + return out + case 2: + return []map[string]any{ + {"filename": "normal.go", "status": "added", "additions": 1, "deletions": 0, "patch": "@@"}, + {"filename": "renamed_new.go", "status": "renamed", "previous_filename": "renamed_old.go", "additions": 0, "deletions": 0, "patch": ""}, + {"filename": "nostatus.go", "additions": 2, "deletions": 1, "patch": "@@"}, // no "status" key -> default "modified" + } + default: + return []map[string]any{} + } +} + +func commitsPage(page int) []map[string]any { + switch page { + case 1: + out := make([]map[string]any, 100) + for i := range out { + msg := fmt.Sprintf("c%d", i) + if i == 0 { + msg = "" // empty message -> dropped + } + out[i] = map[string]any{"commit": map[string]any{"message": msg}} + } + return out + case 2: + out := make([]map[string]any, 100) + for i := range out { + out[i] = map[string]any{"commit": map[string]any{"message": fmt.Sprintf("d%d", i)}} + } + return out + default: + return []map[string]any{} // empty page -> break + } +} + +func newFetchServer(t *testing.T, rec *ghRec) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + registerFetchHandlers(mux, rec) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +// registerFetchHandlers wires the PR metadata/files/commits/diff endpoints onto +// mux so they can be combined with the App installation-token endpoints. +func registerFetchHandlers(mux *http.ServeMux, rec *ghRec) { + if rec.filesPages == nil { + rec.filesPages = map[int]bool{} + } + + // metadata + diff share this path (branch on Accept header). + mux.HandleFunc("/repos/o/r/pulls/7", func(w http.ResponseWriter, r *http.Request) { + rec.mu.Lock() + rec.auth = r.Header.Get("Authorization") + if r.Header.Get("Accept") == "application/vnd.github.v3.diff" { + rec.sawDiff = true + rec.diffAccept = r.Header.Get("Accept") + rec.mu.Unlock() + _, _ = io.WriteString(w, diffText) + return + } + rec.metaCalls++ + call := rec.metaCalls + rec.metaAccept = r.Header.Get("Accept") + statusFn := rec.metaStatus + rec.mu.Unlock() + + status := http.StatusOK + if statusFn != nil { + status = statusFn(call) + } + if status != http.StatusOK { + w.WriteHeader(status) + _, _ = io.WriteString(w, `{"message":"boom"}`) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, metaJSON) + }) + + mux.HandleFunc("/repos/o/r/pulls/7/files", func(w http.ResponseWriter, r *http.Request) { + page := atoiDefault(r.URL.Query().Get("page"), 1) + rec.mu.Lock() + rec.filesPages[page] = true + rec.perPageFile = r.URL.Query().Get("per_page") + rec.filesAccept = r.Header.Get("Accept") + rec.mu.Unlock() + _ = json.NewEncoder(w).Encode(filesPage(page)) + }) + + mux.HandleFunc("/repos/o/r/pulls/7/commits", func(w http.ResponseWriter, r *http.Request) { + page := atoiDefault(r.URL.Query().Get("page"), 1) + _ = json.NewEncoder(w).Encode(commitsPage(page)) + }) +} + +func atoiDefault(s string, def int) int { + if s == "" { + return def + } + n := 0 + for _, r := range s { + if r < '0' || r > '9' { + return def + } + n = n*10 + int(r-'0') + } + return n +} + +func TestFetchPR_Success(t *testing.T) { + rec := &ghRec{} + srv := newFetchServer(t, rec) + c := testClient(srv.URL, "test-token") + + data, err := c.FetchPR(context.Background(), "o", "r", 7) + if err != nil { + t.Fatalf("FetchPR: %v", err) + } + + if data.Owner != "o" || data.Repo != "r" || data.Number != 7 { + t.Errorf("owner/repo/number = %q/%q/%d", data.Owner, data.Repo, data.Number) + } + if data.Title != "Add feature" { + t.Errorf("title = %q", data.Title) + } + if data.Description != "Body text" { + t.Errorf("description = %q", data.Description) + } + if want := []string{"bug", "enhancement"}; !equalStrings(data.Labels, want) { + t.Errorf("labels = %v, want %v (empty-name label must be dropped)", data.Labels, want) + } + if data.Author != "octocat" { + t.Errorf("author = %q", data.Author) + } + if data.BaseSHA != "basesha111" || data.HeadSHA != "headsha222" { + t.Errorf("base/head sha = %q/%q", data.BaseSHA, data.HeadSHA) + } + if len(data.ChangedFiles) != 103 { + t.Errorf("changed files = %d, want 103 (pagination)", len(data.ChangedFiles)) + } + if len(data.CommitMessages) != 199 { + t.Errorf("commit messages = %d, want 199 (empty dropped + pagination)", len(data.CommitMessages)) + } + if data.Diff != diffText { + t.Errorf("diff = %q, want %q", data.Diff, diffText) + } + + // renamed + missing-status defaults + byPath := map[string]schemas.ChangedFile{} + for _, f := range data.ChangedFiles { + byPath[f.Path] = f + } + if f, ok := byPath["renamed_new.go"]; !ok { + t.Error("missing renamed_new.go") + } else if f.PreviousPath == nil || *f.PreviousPath != "renamed_old.go" || f.Status != "renamed" { + t.Errorf("renamed file = %+v", f) + } + if f, ok := byPath["nostatus.go"]; !ok { + t.Error("missing nostatus.go") + } else if f.Status != "modified" { + t.Errorf("missing-status file Status = %q, want default \"modified\"", f.Status) + } else if f.PreviousPath != nil { + t.Errorf("nostatus.go PreviousPath = %v, want nil", *f.PreviousPath) + } + + // headers + pagination assertions + rec.mu.Lock() + defer rec.mu.Unlock() + if rec.auth != "Bearer test-token" { + t.Errorf("auth header = %q, want %q", rec.auth, "Bearer test-token") + } + if rec.metaAccept != "application/vnd.github+json" { + t.Errorf("metadata Accept = %q", rec.metaAccept) + } + if rec.filesAccept != "application/vnd.github+json" { + t.Errorf("files Accept = %q", rec.filesAccept) + } + if rec.perPageFile != "100" { + t.Errorf("files per_page = %q, want 100", rec.perPageFile) + } + if !rec.sawDiff || rec.diffAccept != "application/vnd.github.v3.diff" { + t.Errorf("diff request: sawDiff=%v accept=%q", rec.sawDiff, rec.diffAccept) + } + if !rec.filesPages[1] || !rec.filesPages[2] { + t.Errorf("files pagination did not reach page 2: %v", rec.filesPages) + } +} + +func TestFetchPR_RetryThenSucceed(t *testing.T) { + for _, status := range []int{500, 429, 403} { + status := status + t.Run(fmt.Sprintf("status_%d", status), func(t *testing.T) { + rec := &ghRec{metaStatus: func(call int) int { + if call == 1 { + return status + } + return 200 + }} + srv := newFetchServer(t, rec) + c := testClient(srv.URL, "test-token") + + data, err := c.FetchPR(context.Background(), "o", "r", 7) + if err != nil { + t.Fatalf("FetchPR after retry: %v", err) + } + if data.Title != "Add feature" { + t.Errorf("title = %q", data.Title) + } + rec.mu.Lock() + calls := rec.metaCalls + rec.mu.Unlock() + if calls != 2 { + t.Errorf("metadata calls = %d, want 2 (one failure + one success)", calls) + } + }) + } +} + +func TestFetchPR_FailFastOn404(t *testing.T) { + rec := &ghRec{metaStatus: func(call int) int { return 404 }} + srv := newFetchServer(t, rec) + c := testClient(srv.URL, "test-token") + + _, err := c.FetchPR(context.Background(), "o", "r", 7) + if err == nil { + t.Fatal("expected error on 404") + } + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("error is not *APIError: %v", err) + } + if apiErr.StatusCode != 404 { + t.Errorf("status = %d, want 404", apiErr.StatusCode) + } + rec.mu.Lock() + calls := rec.metaCalls + rec.mu.Unlock() + if calls != 1 { + t.Errorf("metadata calls = %d, want 1 (fail fast, no retry)", calls) + } +} + +func TestFetchPR_RetriesExhausted(t *testing.T) { + rec := &ghRec{metaStatus: func(call int) int { return 500 }} // always fails + srv := newFetchServer(t, rec) + c := testClient(srv.URL, "test-token") + + _, err := c.FetchPR(context.Background(), "o", "r", 7) + if err == nil { + t.Fatal("expected error after retries exhausted") + } + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.StatusCode != 500 { + t.Errorf("expected *APIError 500, got %v", err) + } + rec.mu.Lock() + calls := rec.metaCalls + rec.mu.Unlock() + if calls != 4 { + t.Errorf("metadata calls = %d, want 4 (all attempts)", calls) + } +} + +// ---- PostReview ------------------------------------------------------------ + +type postRec struct { + mu sync.Mutex + auth string + body []byte + status int + respBody string +} + +func newPostServer(t *testing.T, rec *postRec) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/repos/o/r/pulls/7/reviews", func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + rec.mu.Lock() + rec.auth = r.Header.Get("Authorization") + rec.body = b + status := rec.status + respBody := rec.respBody + rec.mu.Unlock() + if status >= 400 { + w.WriteHeader(status) + _, _ = io.WriteString(w, respBody) + return + } + _, _ = io.WriteString(w, `{"id":42,"state":"COMMENTED"}`) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func TestPostReview_PayloadShape(t *testing.T) { + rec := &postRec{} + srv := newPostServer(t, rec) + c := testClient(srv.URL, "test-token") + + review := schemas.GitHubReview{ + Body: "Summary body", + Event: "COMMENT", + Comments: []schemas.GitHubComment{ + {Path: "a.go", Line: 5, Side: "RIGHT", Body: "keep me"}, + {Path: "", Line: 9, Side: "RIGHT", Body: "no path -> drop"}, + {Path: "b.go", Line: 0, Side: "LEFT", Body: "line 0 -> drop"}, + }, + } + result, err := c.PostReview(context.Background(), "o", "r", 7, review, "abc123def456") + if err != nil { + t.Fatalf("PostReview: %v", err) + } + if got, _ := result["id"].(float64); got != 42 { + t.Errorf("result id = %v, want 42", result["id"]) + } + + rec.mu.Lock() + body := rec.body + auth := rec.auth + rec.mu.Unlock() + + if auth != "Bearer test-token" { + t.Errorf("auth = %q", auth) + } + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("payload not json: %v (%s)", err, body) + } + if payload["body"] != "Summary body" { + t.Errorf("body = %v", payload["body"]) + } + if payload["event"] != "COMMENT" { + t.Errorf("event = %v", payload["event"]) + } + if payload["commit_id"] != "abc123def456" { + t.Errorf("commit_id = %v", payload["commit_id"]) + } + comments, ok := payload["comments"].([]any) + if !ok { + t.Fatalf("comments not an array: %v", payload["comments"]) + } + if len(comments) != 1 { + t.Fatalf("comments len = %d, want 1 (filtered)", len(comments)) + } + cm := comments[0].(map[string]any) + if cm["path"] != "a.go" || cm["line"].(float64) != 5 || cm["side"] != "RIGHT" || cm["body"] != "keep me" { + t.Errorf("kept comment = %v", cm) + } +} + +func TestPostReview_NoCommitID_NoComments(t *testing.T) { + rec := &postRec{} + srv := newPostServer(t, rec) + c := testClient(srv.URL, "test-token") + + // zero comments -> no "comments" key at all; empty commitID -> no commit_id key + review := schemas.GitHubReview{Body: "b", Event: "APPROVE", Comments: []schemas.GitHubComment{}} + if _, err := c.PostReview(context.Background(), "o", "r", 7, review, ""); err != nil { + t.Fatalf("PostReview: %v", err) + } + rec.mu.Lock() + body := rec.body + rec.mu.Unlock() + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatal(err) + } + if _, ok := payload["commit_id"]; ok { + t.Errorf("commit_id present, want absent: %v", payload) + } + if _, ok := payload["comments"]; ok { + t.Errorf("comments present for empty list, want absent: %v", payload) + } +} + +func TestPostReview_AllCommentsFilteredStillEmitsEmptyList(t *testing.T) { + rec := &postRec{} + srv := newPostServer(t, rec) + c := testClient(srv.URL, "test-token") + + // non-empty original list, but all fail the path/line filter -> "comments": [] + review := schemas.GitHubReview{ + Body: "b", + Event: "COMMENT", + Comments: []schemas.GitHubComment{ + {Path: "", Line: 1, Side: "RIGHT", Body: "x"}, + {Path: "y.go", Line: 0, Side: "RIGHT", Body: "y"}, + }, + } + if _, err := c.PostReview(context.Background(), "o", "r", 7, review, ""); err != nil { + t.Fatalf("PostReview: %v", err) + } + rec.mu.Lock() + body := rec.body + rec.mu.Unlock() + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatal(err) + } + comments, ok := payload["comments"].([]any) + if !ok { + t.Fatalf("comments key absent, want present empty list: %v", payload) + } + if len(comments) != 0 { + t.Errorf("comments len = %d, want 0", len(comments)) + } +} + +func TestPostReview_422TypedError(t *testing.T) { + rec := &postRec{ + status: 422, + respBody: `{"message":"Unprocessable Entity","errors":[{"resource":"PullRequestReview","code":"custom","message":"Review cannot be submitted on your own pull request"}]}`, + } + srv := newPostServer(t, rec) + c := testClient(srv.URL, "test-token") + + review := schemas.GitHubReview{Body: "b", Event: "REQUEST_CHANGES", Comments: []schemas.GitHubComment{}} + _, err := c.PostReview(context.Background(), "o", "r", 7, review, "") + if err == nil { + t.Fatal("expected 422 error") + } + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("error is not *APIError: %v", err) + } + if apiErr.StatusCode != 422 { + t.Errorf("status = %d, want 422", apiErr.StatusCode) + } + if !strings.Contains(apiErr.Body, "own pull request") { + t.Errorf("body does not carry the 422 reason: %q", apiErr.Body) + } +} + +// ---- App-JWT --------------------------------------------------------------- + +func TestGenerateAppJWT_Claims(t *testing.T) { + privPEM, pub := genRSAKeyPEM(t) + const appID = "1234567" + + before := time.Now().Unix() + tokStr, err := generateAppJWT(appID, privPEM) + if err != nil { + t.Fatalf("generateAppJWT: %v", err) + } + after := time.Now().Unix() + + parsed, err := jwt.Parse(tokStr, func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return pub, nil + }, jwt.WithValidMethods([]string{"RS256"})) + if err != nil { + t.Fatalf("verify JWT with public key: %v", err) + } + claims, ok := parsed.Claims.(jwt.MapClaims) + if !ok { + t.Fatalf("claims type = %T", parsed.Claims) + } + + if iss, _ := claims["iss"].(string); iss != appID { + t.Errorf("iss = %q, want %q", iss, appID) + } + iatF, ok := claims["iat"].(float64) + if !ok { + t.Fatalf("iat missing/wrong type: %v", claims["iat"]) + } + expF, ok := claims["exp"].(float64) + if !ok { + t.Fatalf("exp missing/wrong type: %v", claims["exp"]) + } + iat, exp := int64(iatF), int64(expF) + if iat < before-60 || iat > after-60 { + t.Errorf("iat = %d, want in [%d,%d] (now-60)", iat, before-60, after-60) + } + if exp < before+600 || exp > after+600 { + t.Errorf("exp = %d, want in [%d,%d] (now+600)", exp, before+600, after+600) + } + if exp-iat != 660 { + t.Errorf("exp-iat = %d, want 660", exp-iat) + } +} + +func TestGenerateAppJWT_BadKey(t *testing.T) { + if _, err := generateAppJWT("1", "not-a-pem-key"); err == nil { + t.Fatal("expected error for invalid private key") + } +} + +// ---- installation-token caching ------------------------------------------- + +func newTokenServer(t *testing.T, instID int, expiresAt string, instCalls, tokCalls *int) *httptest.Server { + t.Helper() + var mu sync.Mutex + mux := http.NewServeMux() + mux.HandleFunc("/repos/o/r/installation", func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + *instCalls++ + mu.Unlock() + if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { + t.Errorf("installation lookup missing Bearer JWT: %q", r.Header.Get("Authorization")) + } + _ = json.NewEncoder(w).Encode(map[string]any{"id": instID}) + }) + mux.HandleFunc(fmt.Sprintf("/app/installations/%d/access_tokens", instID), func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + *tokCalls++ + mu.Unlock() + _ = json.NewEncoder(w).Encode(map[string]any{"token": "ghs_installtoken", "expires_at": expiresAt}) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func TestInstallationToken_CacheHit(t *testing.T) { + privPEM, _ := genRSAKeyPEM(t) + var instCalls, tokCalls int + future := time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + srv := newTokenServer(t, 999, future, &instCalls, &tokCalls) + c := testAppClient(srv.URL, "42", privPEM) + + for i := 0; i < 2; i++ { + tok, err := c.getInstallationToken(context.Background(), "o", "r") + if err != nil { + t.Fatalf("getInstallationToken #%d: %v", i, err) + } + if tok != "ghs_installtoken" { + t.Errorf("token = %q", tok) + } + } + // installation id looked up both times; token minted once (cache hit 2nd time) + if instCalls != 2 { + t.Errorf("installation lookups = %d, want 2", instCalls) + } + if tokCalls != 1 { + t.Errorf("token mints = %d, want 1 (cache hit)", tokCalls) + } +} + +func TestInstallationToken_NearExpiryBypassesCache(t *testing.T) { + privPEM, _ := genRSAKeyPEM(t) + var instCalls, tokCalls int + // expires within the 5-minute refresh buffer -> cache never used + near := time.Now().Add(2 * time.Minute).UTC().Format(time.RFC3339) + srv := newTokenServer(t, 777, near, &instCalls, &tokCalls) + c := testAppClient(srv.URL, "42", privPEM) + + for i := 0; i < 2; i++ { + if _, err := c.getInstallationToken(context.Background(), "o", "r"); err != nil { + t.Fatalf("getInstallationToken #%d: %v", i, err) + } + } + if tokCalls != 2 { + t.Errorf("token mints = %d, want 2 (near-expiry token must not be cached)", tokCalls) + } +} + +func TestFetchPR_UsesAppInstallationToken(t *testing.T) { + privPEM, _ := genRSAKeyPEM(t) + rec := &ghRec{} + + // One combined server: PR endpoints + App installation-token endpoints, so + // the installation token is used for the subsequent PR requests with no + // cross-host redirect (which would strip the Authorization header). + future := time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + var instCalls, tokCalls int + mux := http.NewServeMux() + registerFetchHandlers(mux, rec) + mux.HandleFunc("/repos/o/r/installation", func(w http.ResponseWriter, r *http.Request) { + instCalls++ + _ = json.NewEncoder(w).Encode(map[string]any{"id": 555}) + }) + mux.HandleFunc("/app/installations/555/access_tokens", func(w http.ResponseWriter, r *http.Request) { + tokCalls++ + _ = json.NewEncoder(w).Encode(map[string]any{"token": "ghs_appfetch", "expires_at": future}) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + c := testAppClient(srv.URL, "42", privPEM) + data, err := c.FetchPR(context.Background(), "o", "r", 7) + if err != nil { + t.Fatalf("FetchPR (app auth): %v", err) + } + if data.Title != "Add feature" { + t.Errorf("title = %q", data.Title) + } + rec.mu.Lock() + auth := rec.auth + rec.mu.Unlock() + if auth != "Bearer ghs_appfetch" { + t.Errorf("fetch used auth %q, want installation token Bearer ghs_appfetch", auth) + } + if tokCalls == 0 { + t.Error("installation token was never minted") + } +} + +// ---- NewClient env cascade ------------------------------------------------- + +func TestNewClient_TokenCascade(t *testing.T) { + t.Run("explicit_token_wins", func(t *testing.T) { + t.Setenv("GH_TOKEN", "from-env") + t.Setenv("GITHUB_APP_ID", "") + t.Setenv("GITHUB_APP_PRIVATE_KEY", "") + c := NewClient("explicit") + if c.token != "explicit" { + t.Errorf("token = %q, want explicit", c.token) + } + if c.useAppAuth { + t.Error("useAppAuth true, want false") + } + if c.baseURL != defaultBaseURL { + t.Errorf("baseURL = %q", c.baseURL) + } + }) + t.Run("falls_back_to_GH_TOKEN", func(t *testing.T) { + t.Setenv("GH_TOKEN", "env-token") + c := NewClient("") + if c.token != "env-token" { + t.Errorf("token = %q, want env-token", c.token) + } + }) + t.Run("empty_when_unset", func(t *testing.T) { + t.Setenv("GH_TOKEN", "") + c := NewClient("") + if c.token != "" { + t.Errorf("token = %q, want empty", c.token) + } + }) + t.Run("app_auth_when_both_set", func(t *testing.T) { + t.Setenv("GITHUB_APP_ID", "123") + t.Setenv("GITHUB_APP_PRIVATE_KEY", "some-pem") + c := NewClient("") + if !c.useAppAuth { + t.Error("useAppAuth false, want true") + } + }) + t.Run("no_app_auth_when_one_missing", func(t *testing.T) { + t.Setenv("GITHUB_APP_ID", "123") + t.Setenv("GITHUB_APP_PRIVATE_KEY", "") + c := NewClient("") + if c.useAppAuth { + t.Error("useAppAuth true with missing private key, want false") + } + }) +} + +// ---- small helpers --------------------------------------------------------- + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/go/internal/github/jwt.go b/go/internal/github/jwt.go new file mode 100644 index 0000000..0e00d7d --- /dev/null +++ b/go/internal/github/jwt.go @@ -0,0 +1,33 @@ +package github + +import ( + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// generateAppJWT builds the short-lived RS256 JWT used to authenticate as a +// GitHub App. It is a byte-exact port of _generate_app_jwt in +// src/pr_af/github/client.py: +// +// iat = now - 60 (60s clock-skew buffer) +// exp = now + 600 (10 minutes, the maximum GitHub allows) +// iss = app id (the raw GITHUB_APP_ID string) +// +// The private key PEM (PKCS#1 or PKCS#8) is parsed on each call; a parse +// failure surfaces as an error at token-generation time, mirroring Python's +// jwt.encode raising on a bad key. +func generateAppJWT(appID, privateKeyPEM string) (string, error) { + now := time.Now().Unix() + claims := jwt.MapClaims{ + "iat": now - 60, + "exp": now + (10 * 60), + "iss": appID, + } + key, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(privateKeyPEM)) + if err != nil { + return "", err + } + tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + return tok.SignedString(key) +} diff --git a/go/internal/harnessx/harnessx_test.go b/go/internal/harnessx/harnessx_test.go new file mode 100644 index 0000000..3f57a07 --- /dev/null +++ b/go/internal/harnessx/harnessx_test.go @@ -0,0 +1,260 @@ +package harnessx + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/pr-af/go/internal/fatal" +) + +// --- test fixtures ---------------------------------------------------------- + +// mockHarness is the HarnessCaller seam the Python tests get by patching +// router.harness. It records what Run passed and returns a scripted result. +type mockHarness struct { + fn func(ctx context.Context, prompt string, schema map[string]any, dest any, opts harness.Options) (*harness.Result, error) + gotOpts harness.Options + gotSchema map[string]any + gotPrompt string +} + +func (m *mockHarness) Harness(ctx context.Context, prompt string, schema map[string]any, dest any, opts harness.Options) (*harness.Result, error) { + m.gotOpts = opts + m.gotSchema = schema + m.gotPrompt = prompt + return m.fn(ctx, prompt, schema, dest, opts) +} + +// seededResult carries non-zero pydantic-parity defaults via UnmarshalJSON, +// exactly like the real schemas structs (schemas §C.1). Used to prove the +// Parsed==nil path returns seeded defaults, not the Go zero value. +type seededResult struct { + Complete bool `json:"complete"` + Scope string `json:"estimated_scope"` +} + +func (s *seededResult) UnmarshalJSON(b []byte) error { + *s = seededResult{Complete: true, Scope: "medium"} + type alias seededResult + return json.Unmarshal(b, (*alias)(s)) +} + +// childItem / parentSchema exercise nested $defs, array items, and enum output. +type childItem struct { + Name string `json:"name"` +} + +type parentSchema struct { + Outcome string `json:"outcome" jsonschema:"enum=completed,enum=failed"` + Children []childItem `json:"children"` +} + +// --- schema generation ------------------------------------------------------ + +// Contract: schema for a nested struct emits $defs / items / enum for the SDK's +// consumption. +func TestSchemaForNestedStructEmitsDefsItemsEnum(t *testing.T) { + m := schemaFor[parentSchema]() + + props, ok := m["properties"].(map[string]any) + if !ok { + t.Fatalf("expected top-level properties (ExpandedStruct), got: %v", m) + } + + // enum on the outcome field. + outcome, ok := props["outcome"].(map[string]any) + if !ok { + t.Fatalf("expected properties.outcome, got: %v", props) + } + enum, ok := outcome["enum"].([]any) + if !ok { + t.Fatalf("expected properties.outcome.enum, got: %v", outcome) + } + if !containsStr(enum, "completed") || !containsStr(enum, "failed") { + t.Fatalf("enum missing expected values: %v", enum) + } + + // array items on the children field. + children, ok := props["children"].(map[string]any) + if !ok { + t.Fatalf("expected properties.children, got: %v", props) + } + if children["type"] != "array" { + t.Fatalf("expected children.type=array, got: %v", children["type"]) + } + if _, ok := children["items"]; !ok { + t.Fatalf("expected children.items, got: %v", children) + } + + // $defs for the nested struct type. + defs, ok := m["$defs"].(map[string]any) + if !ok || len(defs) == 0 { + t.Fatalf("expected non-empty $defs for nested type, got: %v", m["$defs"]) + } +} + +func TestSchemaForIsCached(t *testing.T) { + a := schemaFor[parentSchema]() + b := schemaFor[parentSchema]() + // Same underlying cached map instance (pointer identity via a mutation probe). + a["__probe__"] = 1 + if _, ok := b["__probe__"]; !ok { + t.Fatalf("expected schemaFor to return the cached map instance on repeat calls") + } + delete(a, "__probe__") +} + +// --- Run: fatal propagation ------------------------------------------------- + +// Contract: a fatal harness error propagates as *FatalHarnessError via errors.As. +func TestRunFatalErrorPropagates(t *testing.T) { + mh := &mockHarness{ + fn: func(_ context.Context, _ string, _ map[string]any, _ any, _ harness.Options) (*harness.Result, error) { + return &harness.Result{IsError: true, ErrorMessage: "Credit balance is too low"}, nil + }, + } + + out, res, err := Run[seededResult](context.Background(), mh, "prompt", harness.Options{}) + if err == nil { + t.Fatal("expected an error for a fatal harness result") + } + var fe *fatal.FatalHarnessError + if !errors.As(err, &fe) { + t.Fatalf("expected *fatal.FatalHarnessError, got %T: %v", err, err) + } + if fe.OriginalMessage != "Credit balance is too low" { + t.Fatalf("expected original message preserved, got %q", fe.OriginalMessage) + } + if out != nil { + t.Fatalf("expected nil value on fatal error, got %v", out) + } + if res == nil { + t.Fatal("expected the *harness.Result to be returned alongside the fatal error") + } +} + +// --- Run: opts pass-through -------------------------------------------------- + +// Contract: PR-AF has no credential store, so Run passes opts.Env through to the +// harness unchanged (no scoped-credential injection, unlike the SWE-AF original). +func TestRunPassesOptsEnvThrough(t *testing.T) { + mh := &mockHarness{ + fn: func(_ context.Context, _ string, _ map[string]any, dest any, _ harness.Options) (*harness.Result, error) { + return &harness.Result{Parsed: dest}, nil + }, + } + + base := harness.Options{Env: map[string]string{"OPENROUTER_API_KEY": "k", "KEEP": "yes"}} + if _, _, err := Run[seededResult](context.Background(), mh, "prompt", base); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got := mh.gotOpts.Env["OPENROUTER_API_KEY"]; got != "k" { + t.Fatalf("expected env passed through unchanged, got OPENROUTER_API_KEY=%q", got) + } + if got := mh.gotOpts.Env["KEEP"]; got != "yes" { + t.Fatalf("expected env passed through unchanged, got KEEP=%q", got) + } +} + +// --- Run: Parsed==nil fallback path ----------------------------------------- + +// Contract: on Result.Parsed == nil (schema parse failure), Run returns the +// default-seeded value plus the Result, NOT an error. +func TestRunParsedNilReturnsSeededDefaults(t *testing.T) { + mh := &mockHarness{ + fn: func(_ context.Context, _ string, _ map[string]any, _ any, _ harness.Options) (*harness.Result, error) { + // Non-fatal error result with no parsed output. + return &harness.Result{IsError: true, ErrorMessage: "schema validation failed after retries", Parsed: nil}, nil + }, + } + + out, res, err := Run[seededResult](context.Background(), mh, "prompt", harness.Options{}) + if err != nil { + t.Fatalf("expected no error on Parsed==nil, got %v", err) + } + if out == nil { + t.Fatal("expected a seeded default value, got nil") + } + if !out.Complete || out.Scope != "medium" { + t.Fatalf("expected seeded defaults (Complete=true, Scope=medium), got %+v", *out) + } + if res == nil || !res.IsError { + t.Fatal("expected the failing Result returned so the caller can inspect IsError") + } +} + +// --- Run: success path ------------------------------------------------------ + +func TestRunSuccessReturnsParsed(t *testing.T) { + mh := &mockHarness{ + fn: func(_ context.Context, _ string, _ map[string]any, dest any, _ harness.Options) (*harness.Result, error) { + d := dest.(*seededResult) + d.Complete = false + d.Scope = "large" + return &harness.Result{Parsed: dest}, nil + }, + } + + out, _, err := Run[seededResult](context.Background(), mh, "prompt", harness.Options{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out == nil || out.Complete != false || out.Scope != "large" { + t.Fatalf("expected parsed value returned, got %+v", out) + } +} + +func TestRunPropagatesTransportError(t *testing.T) { + sentinel := errors.New("boom") + mh := &mockHarness{ + fn: func(_ context.Context, _ string, _ map[string]any, _ any, _ harness.Options) (*harness.Result, error) { + return nil, sentinel + }, + } + _, _, err := Run[seededResult](context.Background(), mh, "prompt", harness.Options{}) + if !errors.Is(err, sentinel) { + t.Fatalf("expected transport error propagated, got %v", err) + } +} + +// --- RoleOptions mapping ----------------------------------------------------- + +func TestRoleOptionsToOptions(t *testing.T) { + ro := RoleOptions{ + Provider: "opencode", + Model: "minimax/minimax-m2.5", + MaxTurns: 12, + Tools: []string{"Read", "Grep", "Bash"}, + PermissionMode: "auto", + SystemPrompt: "sys", + Cwd: "/repo", + Env: map[string]string{"A": "1"}, + } + o := ro.ToOptions() + if o.Provider != "opencode" || o.Model != "minimax/minimax-m2.5" || o.MaxTurns != 12 || + o.PermissionMode != "auto" || o.SystemPrompt != "sys" || o.Cwd != "/repo" { + t.Fatalf("scalar option fields not mapped: %+v", o) + } + if len(o.Tools) != 3 || o.Tools[2] != "Bash" { + t.Fatalf("tools not mapped: %v", o.Tools) + } + if o.Env["A"] != "1" { + t.Fatalf("env not mapped: %v", o.Env) + } +} + +// --- helpers ---------------------------------------------------------------- + +func containsStr(xs []any, want string) bool { + for _, x := range xs { + if s, ok := x.(string); ok && s == want { + return true + } + } + return false +} diff --git a/go/internal/harnessx/run.go b/go/internal/harnessx/run.go new file mode 100644 index 0000000..cf0397b --- /dev/null +++ b/go/internal/harnessx/run.go @@ -0,0 +1,123 @@ +package harnessx + +import ( + "context" + "encoding/json" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/pr-af/go/internal/fatal" +) + +// HarnessCaller is the minimal method set Run needs from *agent.Agent. Declaring +// it as an interface (rather than depending on the concrete *agent.Agent) lets +// tests supply a mock harness without a live subprocess — the same seam the +// Python tests get by patching the router's harness. *agent.Agent satisfies it +// via its Harness method (sdk/go/agent/harness.go). +type HarnessCaller interface { + Harness(ctx context.Context, prompt string, schema map[string]any, dest any, opts harness.Options) (*harness.Result, error) +} + +// Run is the single generic entry point every reasoner uses to invoke the +// harness for structured output of type T. +// +// Sequence (design §C.3): +// 1. Reflect T into the JSON schema the harness consumes (cached per type). +// 2. Call app.Harness with a fresh *T dest. PR-AF has no scout-credential +// store, so — unlike the SWE-AF harness this is adapted from — Run does NOT +// inject run-scoped credentials into opts.Env; opts is passed through as-is. +// 3. Classify fatal (non-retryable) API errors FIRST, before the Parsed==nil +// fallback, so the real billing/auth message surfaces past every retry layer +// as a *fatal.FatalHarnessError (callers must propagate it, not swallow it). +// 4. On Result.Parsed == nil (the harness could not parse valid JSON into T), +// return a default-seeded T plus the Result — NOT an error — so the caller +// inspects Result.IsError and applies its role-specific deterministic +// fallback. The seed comes from unmarshaling "{}" into T, which triggers +// T's UnmarshalJSON default-seeding (schemas §C.1) when present, and yields +// the Go zero value otherwise. +// +// Returns (*T, *harness.Result, error). The Result is returned even alongside a +// non-nil error so callers can inspect diagnostics. +func Run[T any](ctx context.Context, app HarnessCaller, prompt string, opts harness.Options) (*T, *harness.Result, error) { + schema := schemaFor[T]() + + var dest T + result, err := app.Harness(ctx, prompt, schema, &dest, opts) + if err != nil { + return nil, result, err + } + + // Fatal-error classification comes before the Parsed==nil fallback so the + // real non-retryable message is not masked by a generic fallback struct. + if fErr := fatal.CheckFatalHarnessError(result); fErr != nil { + return nil, result, fErr + } + + // Schema parse failure: hand the caller a default-seeded value plus the + // Result so it can apply its own deterministic fallback. Not an error. + if result == nil || result.Parsed == nil { + seeded := seedDefaults[T]() + return &seeded, result, nil + } + + return &dest, result, nil +} + +// seedDefaults returns a T seeded with its pydantic-parity defaults. Unmarshaling +// an empty JSON object invokes T's UnmarshalJSON (which seeds non-zero defaults, +// schemas §C.1) when T implements it; for a plain struct it leaves the Go zero +// value. Any unmarshal error is ignored — the zero value is an acceptable floor. +func seedDefaults[T any]() T { + var v T + _ = json.Unmarshal([]byte("{}"), &v) + return v +} + +// RoleOptions is the role→harness parameter mapping (design §C.3). Each reasoner +// fills it from its resolved config, then ToOptions produces the harness.Options +// passed to Run. Centralizing the mapping here keeps every reasoner consistent — +// the field set mirrors the keyword arguments the Python reasoners pass to +// router.harness (system_prompt, schema, model, provider, tools, cwd, max_turns, +// permission_mode). +type RoleOptions struct { + // Provider is the harness ADAPTER string, e.g. "opencode" (PR-AF's default), + // "claude-code", "codex". + Provider string + + // Model is the resolved role model identifier. + Model string + + // MaxTurns caps agent iterations. + MaxTurns int + + // Tools is the allowed-tool list (e.g. ["Read","Grep","Bash"]). + Tools []string + + // PermissionMode maps to Python's permission_mode; empty means the harness + // default (Python passes `permission_mode or None`, and harness.Options + // treats "" as "use default"). + PermissionMode string + + // SystemPrompt is the role's module-level system prompt. + SystemPrompt string + + // Cwd is the working directory for the subprocess (repo path / worktree). + Cwd string + + // Env is the environment for the subprocess. Run passes it through unchanged. + Env map[string]string +} + +// ToOptions converts a RoleOptions into a harness.Options. +func (r RoleOptions) ToOptions() harness.Options { + return harness.Options{ + Provider: r.Provider, + Model: r.Model, + MaxTurns: r.MaxTurns, + Tools: r.Tools, + PermissionMode: r.PermissionMode, + SystemPrompt: r.SystemPrompt, + Cwd: r.Cwd, + Env: r.Env, + } +} diff --git a/go/internal/harnessx/schema.go b/go/internal/harnessx/schema.go new file mode 100644 index 0000000..856a2ec --- /dev/null +++ b/go/internal/harnessx/schema.go @@ -0,0 +1,160 @@ +// Package harnessx is the single choke-point every PR-AF reasoner uses to call +// the AgentField harness. It replaces the Python monkeypatch of app.harness with +// an explicit generic wrapper: +// +// - schemaFor[T] resolves the JSON-schema map the harness consumes to build +// its OUTPUT REQUIREMENTS prompt suffix AND to validate parsed output +// (design §C.3). For destination types registered by RegisterSchema it +// returns the committed pydantic-generated schema (parity with the Python +// SDK); for unknown types it falls back to invopop reflection. +// - Run[T] calls the harness, classifies fatal API errors, and on a schema +// parse-failure hands the caller a default-seeded value so it can apply its +// deterministic fallback (design §C.3). +// +// Unlike the SWE-AF harness this is adapted from, PR-AF has no scout-credential +// store, so Run does NOT inject run-scoped credentials into the subprocess env — +// opts is passed through unchanged. This is the ONLY way reasoners should reach +// the harness: it guarantees uniform fatal-error handling across every reasoner. +package harnessx + +import ( + "embed" + "encoding/json" + "reflect" + "sync" + + "github.com/invopop/jsonschema" +) + +// embeddedSchemas holds the committed pydantic-generated JSON schemas, one per +// destination type that flows through Run[T]. They are produced by +// go/scripts/gen_schemas.py from the exact pydantic models the Python reasoners +// pass to router.app.harness(schema=...), so the schema the Go SDK validates +// against matches Python's own model_json_schema() rendering (defaulted fields +// optional, X|None nullable, extra keys allowed). See that script for the one +// deliberate deviation (the Severity enum is relaxed to a plain string). +// +// go:embed skips files whose names begin with "_" or "." unless the pattern +// uses the all: prefix, so every fixture basename is plain (no leading +// underscore) even for private Python models like _AdversaryPhaseResult. +// +//go:embed testdata/schemas/*.json +var embeddedSchemas embed.FS + +// schemaCache memoizes the resolved JSON-schema map per concrete type T so the +// embedded-fixture load (or the non-trivial invopop reflection) runs once per +// type. Keyed by reflect.Type; the stored map[string]any is treated as immutable +// by callers (the harness only ever marshals/reads it, never mutates), so +// sharing the cached value across goroutines is safe. +var schemaCache sync.Map // reflect.Type -> map[string]any + +// schemaRegistry maps a destination reflect.Type to the basename (no extension) +// of its committed schema fixture under testdata/schemas/. It is populated by +// RegisterSchema from the reasoners package's init() — that package owns the +// destination types (both the private harness-result structs and the schemas.* +// pipeline types), so registering there keeps harnessx free of an import cycle. +var schemaRegistry sync.Map // reflect.Type -> string (fixture basename) + +// RegisterSchema associates a destination type with its committed pydantic +// schema fixture. Call it once per Run[T] destination type (see +// reasoners/schemas_registry.go). Registration must complete before the first +// Run[T] for that type; init()-time registration guarantees this because any +// reasoner that calls Run[T] imports the package whose init() registers T. +func RegisterSchema(t reflect.Type, fixtureName string) { + schemaRegistry.Store(t, fixtureName) +} + +// RegisteredSchema returns the resolved schema for a registered type, or +// (nil, false) if t has no registered fixture. Exported for the drift test, +// which cross-checks every embedded schema against its Go struct's json tags. +func RegisteredSchema(t reflect.Type) (map[string]any, bool) { + name, ok := schemaRegistry.Load(t) + if !ok { + return nil, false + } + m, err := loadEmbeddedSchema(name.(string)) + if err != nil { + return nil, false + } + return m, true +} + +// loadEmbeddedSchema reads and decodes a committed schema fixture by basename. +func loadEmbeddedSchema(name string) (map[string]any, error) { + b, err := embeddedSchemas.ReadFile("testdata/schemas/" + name + ".json") + if err != nil { + return nil, err + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + return nil, err + } + return m, nil +} + +// schemaFor resolves the JSON-schema map the Go SDK harness consumes for T. +// +// How the SDK consumes this map (verified against the pinned sdk/go +// harness/runner.go + harness/schema.go): the harness validates every parsed +// output against this schema with santhosh-tekuri/jsonschema/v5 +// (runSchemaValidation -> validateAgainstSchema) on each parse success, and its +// schema-retry loop fires when validation fails. A JSON round-trip into the dest +// struct alone accepts missing required fields, bad enums, and extra keys +// silently, so this schema is what actually defines validity. The map is ALSO +// pretty-printed into the BuildPromptSuffix / BuildFollowupPrompt OUTPUT +// REQUIREMENTS instruction and read by DiagnoseOutputFailure (map["properties"]). +// +// Because validation is real and strict, the schema must match Python's — an +// invopop reflection (all fields required, non-nullable pointers, +// additionalProperties:false) would reject Python-valid output. So for +// registered types schemaFor returns the committed pydantic schema; only unknown +// types (e.g. ad-hoc test structs) fall back to invopop reflection. +// +// Invopop reflector configuration (fallback path): +// - ExpandedStruct: inline the root type's own properties at the top level so +// map["properties"] is populated for DiagnoseOutputFailure. +// - DoNotReference=false (default): emit a $defs map for nested struct types. +// - Anonymous: suppress the auto-generated $id derived from the package path. +func schemaFor[T any]() map[string]any { + t := reflect.TypeOf((*T)(nil)).Elem() + if cached, ok := schemaCache.Load(t); ok { + return cached.(map[string]any) + } + + var m map[string]any + if name, ok := schemaRegistry.Load(t); ok { + // Registered destination type: use the committed pydantic schema. A load + // failure "cannot happen" (go:embed guarantees the file is compiled in + // and gen_schemas.py + the determinism test guarantee valid JSON), but + // if it ever did we fall through to invopop rather than panic. + if loaded, err := loadEmbeddedSchema(name.(string)); err == nil { + m = loaded + } + } + if m == nil { + m = reflectSchema(t) + } + + schemaCache.Store(t, m) + return m +} + +// reflectSchema is the invopop fallback for types with no committed fixture. +func reflectSchema(t reflect.Type) map[string]any { + r := &jsonschema.Reflector{ + ExpandedStruct: true, // root properties inline at top level + DoNotReference: false, // emit $defs for nested types + Anonymous: true, // no auto-generated $id from PkgPath + } + schema := r.ReflectFromType(t) + + b, err := json.Marshal(schema) + if err != nil { + return map[string]any{} + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + return map[string]any{} + } + return m +} diff --git a/go/internal/harnessx/schema_parity_test.go b/go/internal/harnessx/schema_parity_test.go new file mode 100644 index 0000000..6ea29c8 --- /dev/null +++ b/go/internal/harnessx/schema_parity_test.go @@ -0,0 +1,243 @@ +package harnessx + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/santhosh-tekuri/jsonschema/v5" +) + +// This file proves the HIGH-severity fix: the committed pydantic schemas the +// harness now validates against accept Python-valid output that the old invopop +// reflection rejected. The Go SDK validates parsed output against this schema +// with santhosh-tekuri/jsonschema/v5 (pinned sdk/go harness/schema.go +// validateAgainstSchema), so these tests use the SAME library the SDK uses. + +// compileSchema mirrors the SDK's validateAgainstSchema: marshal the map, add it +// as an in-memory resource, compile. It is the exact validation surface the +// harness runs on every parse success. +func compileSchema(t *testing.T, schema map[string]any) *jsonschema.Schema { + t.Helper() + b, err := json.Marshal(schema) + if err != nil { + t.Fatalf("marshal schema: %v", err) + } + c := jsonschema.NewCompiler() + if err := c.AddResource("mem://parity/schema.json", bytes.NewReader(b)); err != nil { + t.Fatalf("add resource: %v", err) + } + compiled, err := c.Compile("mem://parity/schema.json") + if err != nil { + t.Fatalf("compile schema: %v", err) + } + return compiled +} + +// validateDoc validates a JSON document string against a compiled schema, +// normalizing through JSON exactly as the SDK does. +func validateDoc(t *testing.T, compiled *jsonschema.Schema, doc string) error { + t.Helper() + var v any + if err := json.Unmarshal([]byte(doc), &v); err != nil { + t.Fatalf("unmarshal doc: %v", err) + } + return compiled.Validate(v) +} + +// pythonValidReviewDoc is shaped like output pydantic's model_validate accepts +// but invopop's reflected schema rejected: a finding that OMITS the defaulted +// fields (tags, confidence, evidence, hunk_context), sets the Optional +// "suggestion" to null, uses a severity SYNONYM ("high", which the Python +// BeforeValidator coerces to "important"), and carries an EXTRA explanatory key +// the model volunteered. sub_reviews is omitted entirely (defaulted). +const pythonValidReviewDoc = `{ + "findings": [ + { + "dimension_id": "d1", + "dimension_name": "Retry semantics", + "file_path": "client.py", + "line_start": 10, + "line_end": 12, + "severity": "high", + "title": "Retry loop can spin forever", + "body": "The loop never decrements the counter.", + "suggestion": null, + "why_this_matters": "extra explanatory key the model volunteered" + } + ], + "sub_reviews": [] +}` + +// --- Axis assertions on the committed pydantic schema ------------------------ + +// Contract: the embedded ReviewFindingsResult schema differs from an invopop +// reflection on exactly the three axes the finding names — defaulted fields are +// optional, X|None is nullable, extra keys are allowed — plus the documented +// Severity relaxation. +func TestEmbeddedSchemaParityAxes(t *testing.T) { + schema, err := loadEmbeddedSchema("ReviewFindingsResult") + if err != nil { + t.Fatalf("load embedded schema: %v", err) + } + + defs, ok := schema["$defs"].(map[string]any) + if !ok { + t.Fatalf("expected $defs, got: %v", schema) + } + finding, ok := defs["ReviewFinding"].(map[string]any) + if !ok { + t.Fatalf("expected $defs.ReviewFinding, got: %v", defs) + } + + // Axis 1 — required excludes every defaulted field. + required := toStringSet(finding["required"]) + for _, f := range []string{"tags", "confidence", "evidence", "hunk_context", "suggestion"} { + if required[f] { + t.Errorf("defaulted field %q must NOT be required (invopop marked it so; pydantic does not)", f) + } + } + // Sanity: fields without a pydantic default remain required. + for _, f := range []string{"dimension_id", "file_path", "severity", "title", "body"} { + if !required[f] { + t.Errorf("no-default field %q should be required", f) + } + } + + props, _ := finding["properties"].(map[string]any) + + // Axis 2 — the Optional "suggestion" is nullable (anyOf includes type:null). + suggestion, _ := props["suggestion"].(map[string]any) + if !anyOfAllowsNull(suggestion) { + t.Errorf("suggestion must be nullable (anyOf with type:null); got %v", suggestion) + } + + // Axis 3 — no additionalProperties:false anywhere (extra keys allowed). + if hasAdditionalPropertiesFalse(schema) { + t.Errorf("schema must not set additionalProperties:false (pydantic ignores extra keys)") + } + + // Documented deviation — Severity is relaxed to a plain string (no strict + // enum), matching the BeforeValidator's accept-and-coerce semantics. + severity, _ := props["severity"].(map[string]any) + if _, hasEnum := severity["enum"]; hasEnum { + t.Errorf("severity must NOT carry a strict enum (relaxed for BeforeValidator parity); got %v", severity) + } + if severity["type"] != "string" { + t.Errorf("severity should remain type:string; got %v", severity["type"]) + } +} + +// --- Behavioral proof: pydantic schema accepts, invopop schema rejects ------- + +// Contract: the committed pydantic schema ACCEPTS Python-valid output that the +// invopop reflection REJECTS. This is the fix, demonstrated on the real +// validation surface (santhosh-tekuri) the SDK uses. +func TestPythonValidOutputAcceptedByEmbeddedSchema(t *testing.T) { + schema, err := loadEmbeddedSchema("ReviewFindingsResult") + if err != nil { + t.Fatalf("load embedded schema: %v", err) + } + compiled := compileSchema(t, schema) + if err := validateDoc(t, compiled, pythonValidReviewDoc); err != nil { + t.Fatalf("pydantic schema must ACCEPT Python-valid output, but rejected it: %v", err) + } +} + +// mirrorFinding / mirrorFindingsResult reproduce the Go destination shape of +// reviewFindingsResult so the invopop fallback (unregistered types) emits the +// exact schema the port shipped BEFORE this fix — all fields required, the +// *string pointer non-nullable, additionalProperties:false. Validating the same +// Python-valid doc against it must FAIL; that failure is the bug the fix closes. +type mirrorFinding struct { + DimensionID string `json:"dimension_id"` + DimensionName string `json:"dimension_name"` + FilePath string `json:"file_path"` + LineStart int `json:"line_start"` + LineEnd int `json:"line_end"` + HunkContext string `json:"hunk_context"` + Severity string `json:"severity"` + Title string `json:"title"` + Body string `json:"body"` + Suggestion *string `json:"suggestion"` + Evidence string `json:"evidence"` + Confidence float64 `json:"confidence"` + Tags []string `json:"tags"` +} + +type mirrorFindingsResult struct { + Findings []mirrorFinding `json:"findings"` + SubReviews []string `json:"sub_reviews"` +} + +// Contract (the guard the fix exists for): the OLD invopop-reflected schema +// rejects the very output pydantic accepts. If this ever starts passing, the +// invopop fallback has silently stopped being strict and the regression the fix +// addresses could resurface unnoticed. +func TestPythonValidOutputRejectedByInvopopSchema(t *testing.T) { + // mirrorFindingsResult is NOT registered, so schemaFor takes the invopop + // fallback — the pre-fix behavior, reproduced exactly. + invopop := schemaFor[mirrorFindingsResult]() + + // Make the premise explicit: invopop over-constrains on the three axes. + if !hasAdditionalPropertiesFalse(invopop) { + t.Fatalf("premise broken: expected invopop schema to set additionalProperties:false") + } + + compiled := compileSchema(t, invopop) + if err := validateDoc(t, compiled, pythonValidReviewDoc); err == nil { + t.Fatalf("invopop schema must REJECT Python-valid output (that rejection is the bug the fix closes)") + } +} + +// --- helpers ---------------------------------------------------------------- + +func toStringSet(v any) map[string]bool { + out := map[string]bool{} + if xs, ok := v.([]any); ok { + for _, x := range xs { + if s, ok := x.(string); ok { + out[s] = true + } + } + } + return out +} + +func anyOfAllowsNull(node map[string]any) bool { + anyOf, ok := node["anyOf"].([]any) + if !ok { + return false + } + for _, o := range anyOf { + if m, ok := o.(map[string]any); ok && m["type"] == "null" { + return true + } + } + return false +} + +// hasAdditionalPropertiesFalse reports whether any object node in the schema +// (root or any $defs entry, recursively) sets additionalProperties:false. +func hasAdditionalPropertiesFalse(node any) bool { + switch n := node.(type) { + case map[string]any: + if ap, ok := n["additionalProperties"]; ok { + if b, ok := ap.(bool); ok && !b { + return true + } + } + for _, v := range n { + if hasAdditionalPropertiesFalse(v) { + return true + } + } + case []any: + for _, v := range n { + if hasAdditionalPropertiesFalse(v) { + return true + } + } + } + return false +} diff --git a/go/internal/harnessx/testdata/schemas/AdversaryPhaseResult.json b/go/internal/harnessx/testdata/schemas/AdversaryPhaseResult.json new file mode 100644 index 0000000..6426f1b --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/AdversaryPhaseResult.json @@ -0,0 +1,56 @@ +{ + "$defs": { + "AdversaryResult": { + "description": "Adversary reviewer's assessment of a finding.", + "properties": { + "finding_title": { + "title": "Finding Title", + "type": "string" + }, + "hidden_trap": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Hidden Trap" + }, + "reason": { + "title": "Reason", + "type": "string" + }, + "severity_adjustment": { + "default": "none", + "title": "Severity Adjustment", + "type": "string" + }, + "verdict": { + "title": "Verdict", + "type": "string" + } + }, + "required": [ + "finding_title", + "verdict", + "reason" + ], + "title": "AdversaryResult", + "type": "object" + } + }, + "properties": { + "results": { + "items": { + "$ref": "#/$defs/AdversaryResult" + }, + "title": "Results", + "type": "array" + } + }, + "title": "_AdversaryPhaseResult", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/AnatomySemanticResult.json b/go/internal/harnessx/testdata/schemas/AnatomySemanticResult.json new file mode 100644 index 0000000..75085bf --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/AnatomySemanticResult.json @@ -0,0 +1,37 @@ +{ + "properties": { + "context_notes": { + "default": "", + "title": "Context Notes", + "type": "string" + }, + "intent_gaps": { + "items": { + "type": "string" + }, + "title": "Intent Gaps", + "type": "array" + }, + "pr_narrative": { + "default": "", + "title": "Pr Narrative", + "type": "string" + }, + "risk_surfaces": { + "items": { + "type": "string" + }, + "title": "Risk Surfaces", + "type": "array" + }, + "unrelated_changes": { + "items": { + "type": "string" + }, + "title": "Unrelated Changes", + "type": "array" + } + }, + "title": "_AnatomySemanticResult", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/CompoundDedupResult.json b/go/internal/harnessx/testdata/schemas/CompoundDedupResult.json new file mode 100644 index 0000000..d1776c5 --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/CompoundDedupResult.json @@ -0,0 +1,18 @@ +{ + "properties": { + "keep_indices": { + "items": { + "type": "integer" + }, + "title": "Keep Indices", + "type": "array" + }, + "reasoning": { + "default": "", + "title": "Reasoning", + "type": "string" + } + }, + "title": "_CompoundDedupResult", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/CompoundResult.json b/go/internal/harnessx/testdata/schemas/CompoundResult.json new file mode 100644 index 0000000..ddd2ddd --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/CompoundResult.json @@ -0,0 +1,87 @@ +{ + "$defs": { + "_CompoundFinding": { + "properties": { + "body": { + "default": "", + "title": "Body", + "type": "string" + }, + "confidence": { + "default": 0.5, + "title": "Confidence", + "type": "number" + }, + "contributing_findings": { + "items": { + "type": "string" + }, + "title": "Contributing Findings", + "type": "array" + }, + "evidence": { + "default": "", + "title": "Evidence", + "type": "string" + }, + "file_path": { + "default": "", + "title": "File Path", + "type": "string" + }, + "line_end": { + "default": 0, + "title": "Line End", + "type": "integer" + }, + "line_start": { + "default": 0, + "title": "Line Start", + "type": "integer" + }, + "severity": { + "default": "suggestion", + "title": "Severity", + "type": "string" + }, + "suggestion": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Suggestion" + }, + "tags": { + "items": { + "type": "string" + }, + "title": "Tags", + "type": "array" + }, + "title": { + "default": "", + "title": "Title", + "type": "string" + } + }, + "title": "_CompoundFinding", + "type": "object" + } + }, + "properties": { + "findings": { + "items": { + "$ref": "#/$defs/_CompoundFinding" + }, + "title": "Findings", + "type": "array" + } + }, + "title": "_CompoundResult", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/DeepenResult.json b/go/internal/harnessx/testdata/schemas/DeepenResult.json new file mode 100644 index 0000000..6631c97 --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/DeepenResult.json @@ -0,0 +1,90 @@ +{ + "$defs": { + "_DeepenFinding": { + "properties": { + "body": { + "default": "", + "title": "Body", + "type": "string" + }, + "confidence": { + "default": 0.7, + "title": "Confidence", + "type": "number" + }, + "dimension_id": { + "default": "literal-verify", + "title": "Dimension Id", + "type": "string" + }, + "dimension_name": { + "default": "Literal-Correctness Verifier", + "title": "Dimension Name", + "type": "string" + }, + "evidence": { + "default": "", + "title": "Evidence", + "type": "string" + }, + "file_path": { + "default": "", + "title": "File Path", + "type": "string" + }, + "line_end": { + "default": 0, + "title": "Line End", + "type": "integer" + }, + "line_start": { + "default": 0, + "title": "Line Start", + "type": "integer" + }, + "severity": { + "default": "important", + "title": "Severity", + "type": "string" + }, + "suggestion": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Suggestion" + }, + "tags": { + "items": { + "type": "string" + }, + "title": "Tags", + "type": "array" + }, + "title": { + "default": "", + "title": "Title", + "type": "string" + } + }, + "title": "_DeepenFinding", + "type": "object" + } + }, + "properties": { + "findings": { + "items": { + "$ref": "#/$defs/_DeepenFinding" + }, + "title": "Findings", + "type": "array" + } + }, + "title": "_DeepenResult", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/IntakeResult.json b/go/internal/harnessx/testdata/schemas/IntakeResult.json new file mode 100644 index 0000000..6ce4d91 --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/IntakeResult.json @@ -0,0 +1,58 @@ +{ + "description": "Phase 1 output. Hybrid \u2014 structured fields for routing, pr_summary string for LLM context.", + "properties": { + "ai_generated": { + "title": "Ai Generated", + "type": "number" + }, + "areas_touched": { + "items": { + "type": "string" + }, + "title": "Areas Touched", + "type": "array" + }, + "complexity": { + "title": "Complexity", + "type": "string" + }, + "languages": { + "items": { + "type": "string" + }, + "title": "Languages", + "type": "array" + }, + "pr_summary": { + "title": "Pr Summary", + "type": "string" + }, + "pr_type": { + "title": "Pr Type", + "type": "string" + }, + "review_depth": { + "title": "Review Depth", + "type": "string" + }, + "risk_signals": { + "items": { + "type": "string" + }, + "title": "Risk Signals", + "type": "array" + } + }, + "required": [ + "pr_type", + "complexity", + "languages", + "areas_touched", + "risk_signals", + "ai_generated", + "review_depth", + "pr_summary" + ], + "title": "IntakeResult", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/MetaDimensionResult.json b/go/internal/harnessx/testdata/schemas/MetaDimensionResult.json new file mode 100644 index 0000000..f22b2aa --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/MetaDimensionResult.json @@ -0,0 +1,108 @@ +{ + "$defs": { + "BudgetAllocation": { + "description": "Budget cap for an agent or phase.", + "properties": { + "max_child_spawns": { + "default": 2, + "title": "Max Child Spawns", + "type": "integer" + }, + "max_cost_usd": { + "default": 0.5, + "title": "Max Cost Usd", + "type": "number" + }, + "max_duration_seconds": { + "default": 60, + "title": "Max Duration Seconds", + "type": "integer" + }, + "max_reference_follows": { + "default": 3, + "title": "Max Reference Follows", + "type": "integer" + } + }, + "title": "BudgetAllocation", + "type": "object" + }, + "ReviewDimension": { + "description": "A single review dimension \u2014 becomes one parallel reviewer instance.\n\nThe review_prompt is THE key innovation: dynamically crafted by the planner\nat runtime, not selected from a fixed catalog.", + "properties": { + "budget": { + "$ref": "#/$defs/BudgetAllocation" + }, + "context_files": { + "items": { + "type": "string" + }, + "title": "Context Files", + "type": "array" + }, + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "priority": { + "default": 1, + "title": "Priority", + "type": "integer" + }, + "review_prompt": { + "title": "Review Prompt", + "type": "string" + }, + "target_files": { + "items": { + "type": "string" + }, + "title": "Target Files", + "type": "array" + } + }, + "required": [ + "id", + "name", + "review_prompt", + "target_files" + ], + "title": "ReviewDimension", + "type": "object" + } + }, + "description": "Output of a meta-dimension selector (Semantic, Mechanical, or Systemic).\n\nEach meta-selector produces a list of ReviewDimension objects plus\na confidence assessment of completeness for its lens.", + "properties": { + "confidence": { + "default": 0.7, + "title": "Confidence", + "type": "number" + }, + "dimensions": { + "items": { + "$ref": "#/$defs/ReviewDimension" + }, + "title": "Dimensions", + "type": "array" + }, + "lens": { + "title": "Lens", + "type": "string" + }, + "rationale": { + "default": "", + "title": "Rationale", + "type": "string" + } + }, + "required": [ + "lens", + "dimensions" + ], + "title": "MetaDimensionResult", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/ObligationVerdict.json b/go/internal/harnessx/testdata/schemas/ObligationVerdict.json new file mode 100644 index 0000000..de35588 --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/ObligationVerdict.json @@ -0,0 +1,63 @@ +{ + "properties": { + "body": { + "default": "", + "title": "Body", + "type": "string" + }, + "confidence": { + "default": 0.7, + "title": "Confidence", + "type": "number" + }, + "evidence": { + "default": "", + "title": "Evidence", + "type": "string" + }, + "file_path": { + "default": "", + "title": "File Path", + "type": "string" + }, + "holds": { + "default": true, + "title": "Holds", + "type": "boolean" + }, + "line_end": { + "default": 0, + "title": "Line End", + "type": "integer" + }, + "line_start": { + "default": 0, + "title": "Line Start", + "type": "integer" + }, + "severity": { + "default": "important", + "title": "Severity", + "type": "string" + }, + "suggestion": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Suggestion" + }, + "title": { + "default": "", + "title": "Title", + "type": "string" + } + }, + "title": "_ObligationVerdict", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/ObligationsResult.json b/go/internal/harnessx/testdata/schemas/ObligationsResult.json new file mode 100644 index 0000000..c5f1567 --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/ObligationsResult.json @@ -0,0 +1,41 @@ +{ + "$defs": { + "_Obligation": { + "properties": { + "id": { + "default": "", + "title": "Id", + "type": "string" + }, + "property": { + "default": "", + "title": "Property", + "type": "string" + }, + "relies_on": { + "default": "", + "title": "Relies On", + "type": "string" + }, + "where": { + "default": "", + "title": "Where", + "type": "string" + } + }, + "title": "_Obligation", + "type": "object" + } + }, + "properties": { + "obligations": { + "items": { + "$ref": "#/$defs/_Obligation" + }, + "title": "Obligations", + "type": "array" + } + }, + "title": "_ObligationsResult", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/PostWorthinessResult.json b/go/internal/harnessx/testdata/schemas/PostWorthinessResult.json new file mode 100644 index 0000000..7b801b3 --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/PostWorthinessResult.json @@ -0,0 +1,18 @@ +{ + "properties": { + "keep_indices": { + "items": { + "type": "integer" + }, + "title": "Keep Indices", + "type": "array" + }, + "reasoning": { + "default": "", + "title": "Reasoning", + "type": "string" + } + }, + "title": "_PostWorthinessResult", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/ReviewFindingsResult.json b/go/internal/harnessx/testdata/schemas/ReviewFindingsResult.json new file mode 100644 index 0000000..a87b2d9 --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/ReviewFindingsResult.json @@ -0,0 +1,140 @@ +{ + "$defs": { + "ReviewFinding": { + "description": "Emitted to findings queue as reviewers work.", + "properties": { + "body": { + "title": "Body", + "type": "string" + }, + "confidence": { + "default": 0.5, + "title": "Confidence", + "type": "number" + }, + "dimension_id": { + "title": "Dimension Id", + "type": "string" + }, + "dimension_name": { + "title": "Dimension Name", + "type": "string" + }, + "evidence": { + "default": "", + "title": "Evidence", + "type": "string" + }, + "file_path": { + "title": "File Path", + "type": "string" + }, + "hunk_context": { + "default": "", + "title": "Hunk Context", + "type": "string" + }, + "line_end": { + "title": "Line End", + "type": "integer" + }, + "line_start": { + "title": "Line Start", + "type": "integer" + }, + "severity": { + "title": "Severity", + "type": "string" + }, + "suggestion": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Suggestion" + }, + "tags": { + "items": { + "type": "string" + }, + "title": "Tags", + "type": "array" + }, + "title": { + "title": "Title", + "type": "string" + } + }, + "required": [ + "dimension_id", + "dimension_name", + "file_path", + "line_start", + "line_end", + "severity", + "title", + "body" + ], + "title": "ReviewFinding", + "type": "object" + }, + "_SubReviewRequest": { + "properties": { + "context_files": { + "items": { + "type": "string" + }, + "title": "Context Files", + "type": "array" + }, + "priority": { + "default": 1, + "title": "Priority", + "type": "integer" + }, + "reason": { + "default": "", + "title": "Reason", + "type": "string" + }, + "review_prompt": { + "default": "", + "title": "Review Prompt", + "type": "string" + }, + "target_files": { + "items": { + "type": "string" + }, + "title": "Target Files", + "type": "array" + } + }, + "title": "_SubReviewRequest", + "type": "object" + } + }, + "properties": { + "findings": { + "items": { + "$ref": "#/$defs/ReviewFinding" + }, + "title": "Findings", + "type": "array" + }, + "sub_reviews": { + "items": { + "$ref": "#/$defs/_SubReviewRequest" + }, + "title": "Sub Reviews", + "type": "array" + } + }, + "title": "_ReviewFindingsResult", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/ReviewPlan.json b/go/internal/harnessx/testdata/schemas/ReviewPlan.json new file mode 100644 index 0000000..b1f76bb --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/ReviewPlan.json @@ -0,0 +1,108 @@ +{ + "$defs": { + "BudgetAllocation": { + "description": "Budget cap for an agent or phase.", + "properties": { + "max_child_spawns": { + "default": 2, + "title": "Max Child Spawns", + "type": "integer" + }, + "max_cost_usd": { + "default": 0.5, + "title": "Max Cost Usd", + "type": "number" + }, + "max_duration_seconds": { + "default": 60, + "title": "Max Duration Seconds", + "type": "integer" + }, + "max_reference_follows": { + "default": 3, + "title": "Max Reference Follows", + "type": "integer" + } + }, + "title": "BudgetAllocation", + "type": "object" + }, + "ReviewDimension": { + "description": "A single review dimension \u2014 becomes one parallel reviewer instance.\n\nThe review_prompt is THE key innovation: dynamically crafted by the planner\nat runtime, not selected from a fixed catalog.", + "properties": { + "budget": { + "$ref": "#/$defs/BudgetAllocation" + }, + "context_files": { + "items": { + "type": "string" + }, + "title": "Context Files", + "type": "array" + }, + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "priority": { + "default": 1, + "title": "Priority", + "type": "integer" + }, + "review_prompt": { + "title": "Review Prompt", + "type": "string" + }, + "target_files": { + "items": { + "type": "string" + }, + "title": "Target Files", + "type": "array" + } + }, + "required": [ + "id", + "name", + "review_prompt", + "target_files" + ], + "title": "ReviewDimension", + "type": "object" + } + }, + "description": "Phase 3 output. The planner's complete review strategy.", + "properties": { + "ai_adjusted": { + "default": false, + "title": "Ai Adjusted", + "type": "boolean" + }, + "cross_ref_hints": { + "items": { + "type": "string" + }, + "title": "Cross Ref Hints", + "type": "array" + }, + "dimensions": { + "items": { + "$ref": "#/$defs/ReviewDimension" + }, + "title": "Dimensions", + "type": "array" + }, + "total_budget": { + "$ref": "#/$defs/BudgetAllocation" + } + }, + "required": [ + "dimensions" + ], + "title": "ReviewPlan", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/VerificationResult.json b/go/internal/harnessx/testdata/schemas/VerificationResult.json new file mode 100644 index 0000000..23e9718 --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/VerificationResult.json @@ -0,0 +1,51 @@ +{ + "$defs": { + "_VerifiedFinding": { + "properties": { + "actual_behavior": { + "default": "", + "title": "Actual Behavior", + "type": "string" + }, + "revised_confidence": { + "default": 0.5, + "title": "Revised Confidence", + "type": "number" + }, + "revised_severity": { + "default": "", + "title": "Revised Severity", + "type": "string" + }, + "title": { + "default": "", + "title": "Title", + "type": "string" + }, + "verification_notes": { + "default": "", + "title": "Verification Notes", + "type": "string" + }, + "verified": { + "default": true, + "title": "Verified", + "type": "boolean" + } + }, + "title": "_VerifiedFinding", + "type": "object" + } + }, + "properties": { + "verified_findings": { + "items": { + "$ref": "#/$defs/_VerifiedFinding" + }, + "title": "Verified Findings", + "type": "array" + } + }, + "title": "_VerificationResult", + "type": "object" +} diff --git a/go/internal/hitl/hax_client.go b/go/internal/hitl/hax_client.go new file mode 100644 index 0000000..b139192 --- /dev/null +++ b/go/internal/hitl/hax_client.go @@ -0,0 +1,254 @@ +// Package hitl provides PR-AF's human-in-the-loop substrate: the hax REST +// client (hax_client.go) and the pause/approval seam (pause.go). +// +// It is the Go port of src/pr_af/hitl/client.py — the hax client builder, the +// control-plane webhook-URL resolver, and the watchdog-safe create-request +// wrapper. The PR-AF review gate that renders the "pr-af-review-v1" template +// and drives the pause loop lives in review_gate.go (a separate task) and +// consumes this substrate. +package hitl + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +// HaxCreateRequestTimeout is the hard cap on a single hax create-request call. +// +// Ports pr_af/hitl/client.py::HAX_CREATE_REQUEST_TIMEOUT_SECONDS (120s). The +// hax REST service is synchronous and has occasionally wedged for tens of +// minutes in production; an explicit client-side timeout keeps a hung hax from +// silently burning the parent reasoner's active-time budget. +const HaxCreateRequestTimeout = 120 * time.Second + +// DefaultHaxBaseURL mirrors pr_af/hitl/client.py::_DEFAULT_HAX_BASE, which +// defaults HAX_SDK_URL to http://localhost:3000 (same default SWE-AF uses). +const DefaultHaxBaseURL = "http://localhost:3000" + +// HaxClient is a thin REST client for the hax human-input service. +// +// The Python port uses the hax-sdk Python client; Go calls the same REST +// surface directly: POST {BaseURL}/api/v1/requests with a Bearer token and a +// camelCase JSON body. The client itself carries no sender identity — the +// sender attribution is resolved from the environment on each CreateRequest, +// matching pr_af/hitl/client.py::create_hax_form_request_with_timeout. +type HaxClient struct { + // BaseURL is the hax service origin WITHOUT the /api/v1 suffix (appended by + // CreateRequest). Default: DefaultHaxBaseURL. Python stores base_url WITH + // the /api/v1 suffix; the resulting wire URL is identical either way. + BaseURL string + // APIKey is the hax API key sent as "Authorization: Bearer ". + APIKey string + // HTTPClient is used for all requests; defaults to http.DefaultClient. + HTTPClient *http.Client + // Timeout overrides HaxCreateRequestTimeout when non-zero (tests use this to + // exercise the watchdog with sub-second durations). + Timeout time.Duration +} + +// BuildHaxClientFromEnv constructs a HaxClient from HAX_API_KEY / HAX_SDK_URL. +// +// Returns nil when HAX_API_KEY is unset or blank — callers MUST treat a nil +// client as "HITL disabled" and post the review directly. This is the on/off +// switch (ports pr_af/hitl/client.py::build_hax_client_from_env returning None). +// +// Note: unlike SWE-AF's Go client, sender identity is NOT read here — Python's +// build_hax_client_from_env reads only HAX_API_KEY / HAX_SDK_URL; HAX_SENDER_* +// is resolved at CreateRequest time. +func BuildHaxClientFromEnv() *HaxClient { + apiKey := strings.TrimSpace(os.Getenv("HAX_API_KEY")) + if apiKey == "" { + return nil + } + // os.environ.get("HAX_SDK_URL", default): the default applies only when the + // var is ABSENT; an explicitly empty value is kept (LookupEnv preserves that + // distinction, unlike SWE-AF's Go which coerces empty -> default). + base, ok := os.LookupEnv("HAX_SDK_URL") + if !ok { + base = DefaultHaxBaseURL + } + return &HaxClient{ + BaseURL: strings.TrimRight(base, "/"), + APIKey: apiKey, + } +} + +// resolveSender reproduces the sender attribution built by +// pr_af/hitl/client.py::create_hax_form_request_with_timeout: +// +// _sender_name = os.getenv("HAX_SENDER_NAME") or os.getenv("NODE_ID", "pr-af") +// key = os.getenv("HAX_SENDER_KEY", _sender_name) +// +// The Python `or` treats an empty HAX_SENDER_NAME as falsy (falls through to +// NODE_ID), whereas os.getenv("NODE_ID", "pr-af") / os.getenv("HAX_SENDER_KEY", +// default) only substitute the default when the variable is ABSENT (an empty +// value is kept). LookupEnv preserves that absent-vs-empty distinction. +func resolveSender() (key, displayName string) { + displayName = os.Getenv("HAX_SENDER_NAME") + if displayName == "" { + if v, ok := os.LookupEnv("NODE_ID"); ok { + displayName = v + } else { + displayName = "pr-af" + } + } + if v, ok := os.LookupEnv("HAX_SENDER_KEY"); ok { + key = v + } else { + key = displayName + } + return key, displayName +} + +// CreateRequestParams are the inputs to CreateRequest. Each maps to a camelCase +// body key exactly as the hax-sdk Python client builds it (hax/client.py:: +// create_request). The sender is resolved from the environment inside +// CreateRequest and is not part of these params. +type CreateRequestParams struct { + Type string // -> "type" (e.g. "pr-af-review-v1") + Payload map[string]any // -> "payload" + Title string // -> "title" (omitted when empty) + Description *string // -> "description" (omitted when nil) + WebhookURL string // -> "webhookUrl" (omitted when empty) + ExpiresInSeconds int // -> "expiresInSeconds" (omitted when <= 0) + UserID string // -> "userId" (omitted when empty) + Metadata map[string]any // -> "metadata" (omitted when nil) +} + +// CreatedRequest is the subset of the hax create-request response we consume. +type CreatedRequest struct { + ID string `json:"id"` + URL string `json:"url"` +} + +// CreateRequest POSTs {BaseURL}/api/v1/requests with a Bearer token and a +// camelCase JSON body, bounded by a hard timeout (HaxCreateRequestTimeout, or +// HaxClient.Timeout when set). A timeout surfaces as an error rather than a +// silent multi-hour stall. +// +// The body always carries a "sender" object ({key, displayName}) resolved from +// the environment (resolveSender) — matching Python's create wrapper, and +// diverging from SWE-AF's Go client, which never sends sender attribution. +// publicKey is intentionally omitted so hax returns plaintext response values +// end-to-end (the Python client is built without a public key). +func (c *HaxClient) CreateRequest(ctx context.Context, p CreateRequestParams) (*CreatedRequest, error) { + body := map[string]any{ + "type": p.Type, + "payload": p.Payload, + } + if p.Title != "" { + body["title"] = p.Title + } + if p.Description != nil { + body["description"] = *p.Description + } + if p.WebhookURL != "" { + body["webhookUrl"] = p.WebhookURL + } + if p.ExpiresInSeconds > 0 { + body["expiresInSeconds"] = p.ExpiresInSeconds + } + if p.UserID != "" { + body["userId"] = p.UserID + } + if p.Metadata != nil { + body["metadata"] = p.Metadata + } + senderKey, senderName := resolveSender() + body["sender"] = map[string]any{ + "key": senderKey, + "displayName": senderName, + } + // publicKey is intentionally omitted (Python HaxClient is built without one, + // so end-to-end response encryption is disabled and values come back plain). + + raw, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("hax create_request: marshal body: %w", err) + } + + timeout := c.Timeout + if timeout <= 0 { + timeout = HaxCreateRequestTimeout + } + reqCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + url := c.BaseURL + "/api/v1/requests" + req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, url, bytes.NewReader(raw)) + if err != nil { + return nil, fmt.Errorf("hax create_request: build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.APIKey) + + httpClient := c.HTTPClient + if httpClient == nil { + httpClient = http.DefaultClient + } + + resp, err := httpClient.Do(req) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) || errors.Is(reqCtx.Err(), context.DeadlineExceeded) { + return nil, fmt.Errorf( + "hax create_request (%s) timed out after %s; hax-sdk is likely wedged: %w", + p.Type, timeout, err, + ) + } + return nil, fmt.Errorf("hax create_request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("hax create_request: read response: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("hax create_request: status %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody))) + } + + var created CreatedRequest + if err := json.Unmarshal(respBody, &created); err != nil { + return nil, fmt.Errorf("hax create_request: decode response: %w", err) + } + if created.ID == "" { + return nil, fmt.Errorf("hax create_request: response missing id: %s", strings.TrimSpace(string(respBody))) + } + return &created, nil +} + +// ApprovalWebhookURL resolves the control-plane webhook URL the CP calls back +// when a human responds to a paused review. Ports +// pr_af/hitl/client.py::approval_webhook_url with its three-tier precedence: +// +// AGENTFIELD_PUBLIC_URL (env) > agentFieldServer (the agent's server) > AGENTFIELD_SERVER (env) +// +// AGENTFIELD_PUBLIC_URL wins because the callback is invoked by hax-sdk, which +// lives in a separate Railway project and must reach a publicly routable URL — +// not the internal AGENTFIELD_SERVER address the agent uses to call the CP. +// The caller passes the agent's own server URL (Python's app.agentfield_server) +// as agentFieldServer. Returns nil when no base can be resolved (Python returns +// None), matching the design's *string signature (SWE-AF's Go returned ""). +func ApprovalWebhookURL(agentFieldServer string) *string { + base := os.Getenv("AGENTFIELD_PUBLIC_URL") + if base == "" { + base = agentFieldServer + } + if base == "" { + base = os.Getenv("AGENTFIELD_SERVER") + } + base = strings.TrimRight(strings.TrimSpace(base), "/") + if base == "" { + return nil + } + url := base + "/api/v1/webhooks/approval-response" + return &url +} diff --git a/go/internal/hitl/hax_client_test.go b/go/internal/hitl/hax_client_test.go new file mode 100644 index 0000000..31dd505 --- /dev/null +++ b/go/internal/hitl/hax_client_test.go @@ -0,0 +1,366 @@ +package hitl + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "reflect" + "sort" + "strings" + "testing" + "time" +) + +// haxEnvKeys are every environment variable the hax substrate reads. Tests +// clear all of them up front so a stray value in the runner's environment +// cannot leak into a case that expects the variable absent. +var haxEnvKeys = []string{ + "HAX_API_KEY", "HAX_SDK_URL", + "HAX_SENDER_NAME", "HAX_SENDER_KEY", "NODE_ID", + "AGENTFIELD_PUBLIC_URL", "AGENTFIELD_SERVER", +} + +// clearEnv unsets keys for the duration of the test, restoring prior values on +// cleanup. Used to establish a known-absent baseline before selectively setting +// variables with setEnv (t.Setenv can set but not unset). +func clearEnv(t *testing.T, keys ...string) { + t.Helper() + for _, k := range keys { + k := k + prev, had := os.LookupEnv(k) + if err := os.Unsetenv(k); err != nil { + t.Fatalf("unsetenv %s: %v", k, err) + } + t.Cleanup(func() { + if had { + _ = os.Setenv(k, prev) + } else { + _ = os.Unsetenv(k) + } + }) + } +} + +// setEnv sets one variable for the test, restoring the prior state on cleanup. +func setEnv(t *testing.T, key, val string) { + t.Helper() + prev, had := os.LookupEnv(key) + if err := os.Setenv(key, val); err != nil { + t.Fatalf("setenv %s: %v", key, err) + } + t.Cleanup(func() { + if had { + _ = os.Setenv(key, prev) + } else { + _ = os.Unsetenv(key) + } + }) +} + +func sortedKeys(m map[string]json.RawMessage) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// ptr is a tiny helper for the optional *string CreateRequestParams field. +func ptr(s string) *string { return &s } + +// TestCreateRequestWireBody drives a full CreateRequest against an httptest hax +// server and asserts the on-the-wire contract: POST /api/v1/requests, Bearer +// auth, exact camelCase top-level key set, publicKey absent, sender shape. +func TestCreateRequestWireBody(t *testing.T) { + clearEnv(t, haxEnvKeys...) + // Deterministic sender: display name explicit, key falls back to it. + setEnv(t, "HAX_SENDER_NAME", "pr-af-go") + + var ( + gotMethod string + gotPath string + gotAuth string + gotCT string + gotBody map[string]json.RawMessage + ) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + gotCT = r.Header.Get("Content-Type") + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Errorf("decode request body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"req_123","url":"https://hax.example/r/req_123"}`)) + })) + defer srv.Close() + + c := &HaxClient{BaseURL: srv.URL, APIKey: "test-key-123", HTTPClient: srv.Client()} + created, err := c.CreateRequest(context.Background(), CreateRequestParams{ + Type: "pr-af-review-v1", + Payload: map[string]any{"title": "T"}, + Title: "PR-AF Review Approval", + Description: ptr("desc"), + WebhookURL: "https://cp.example/api/v1/webhooks/approval-response", + ExpiresInSeconds: 3600, + UserID: "u_1", + Metadata: map[string]any{"reviewId": "rev_abc"}, + }) + if err != nil { + t.Fatalf("CreateRequest: %v", err) + } + + if created.ID != "req_123" || created.URL != "https://hax.example/r/req_123" { + t.Fatalf("unexpected CreatedRequest: %+v", created) + } + if gotMethod != http.MethodPost { + t.Errorf("method = %q, want POST", gotMethod) + } + if gotPath != "/api/v1/requests" { + t.Errorf("path = %q, want /api/v1/requests", gotPath) + } + if gotAuth != "Bearer test-key-123" { + t.Errorf("Authorization = %q, want %q", gotAuth, "Bearer test-key-123") + } + if gotCT != "application/json" { + t.Errorf("Content-Type = %q, want application/json", gotCT) + } + + // Exact camelCase top-level key set — no snake_case leakage, publicKey absent. + wantKeys := []string{ + "description", "expiresInSeconds", "metadata", "payload", + "sender", "title", "type", "userId", "webhookUrl", + } + if got := sortedKeys(gotBody); !reflect.DeepEqual(got, wantKeys) { + t.Errorf("body keys = %v, want %v", got, wantKeys) + } + if _, present := gotBody["publicKey"]; present { + t.Error("publicKey MUST be omitted from the hax body") + } + for _, snake := range []string{"public_key", "webhook_url", "expires_in_seconds", "user_id", "display_name"} { + if _, present := gotBody[snake]; present { + t.Errorf("snake_case key %q leaked into hax body", snake) + } + } + + var sender map[string]any + if err := json.Unmarshal(gotBody["sender"], &sender); err != nil { + t.Fatalf("decode sender: %v", err) + } + wantSender := map[string]any{"key": "pr-af-go", "displayName": "pr-af-go"} + if !reflect.DeepEqual(sender, wantSender) { + t.Errorf("sender = %v, want %v", sender, wantSender) + } +} + +// TestCreateRequestMinimalBody confirms every optional field is omitted when +// unset — only type, payload and the always-present sender remain. +func TestCreateRequestMinimalBody(t *testing.T) { + clearEnv(t, haxEnvKeys...) + setEnv(t, "NODE_ID", "pr-af-go") // sender falls back to NODE_ID + + var gotBody map[string]json.RawMessage + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&gotBody) + _, _ = w.Write([]byte(`{"id":"req_1","url":"u"}`)) + })) + defer srv.Close() + + c := &HaxClient{BaseURL: srv.URL, APIKey: "k", HTTPClient: srv.Client()} + if _, err := c.CreateRequest(context.Background(), CreateRequestParams{ + Type: "pr-af-review-v1", + Payload: map[string]any{}, + }); err != nil { + t.Fatalf("CreateRequest: %v", err) + } + + wantKeys := []string{"payload", "sender", "type"} + if got := sortedKeys(gotBody); !reflect.DeepEqual(got, wantKeys) { + t.Errorf("minimal body keys = %v, want %v", got, wantKeys) + } +} + +// TestCreateRequestTimeout exercises the watchdog: a slow handler against a +// short client timeout must surface a "wedged" error rather than hang. Uses +// sub-second durations (the 120s constant is overridden via HaxClient.Timeout). +func TestCreateRequestTimeout(t *testing.T) { + clearEnv(t, haxEnvKeys...) + + release := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-release // block until the test releases it, well past the client timeout + })) + defer srv.Close() + defer close(release) + + c := &HaxClient{BaseURL: srv.URL, APIKey: "k", HTTPClient: srv.Client(), Timeout: 20 * time.Millisecond} + _, err := c.CreateRequest(context.Background(), CreateRequestParams{ + Type: "pr-af-review-v1", + Payload: map[string]any{}, + }) + if err == nil { + t.Fatal("expected a timeout error, got nil") + } + if !strings.Contains(err.Error(), "timed out") || !strings.Contains(err.Error(), "wedged") { + t.Errorf("timeout error = %q, want it to mention 'timed out' and 'wedged'", err.Error()) + } +} + +// TestCreateRequestErrorPaths covers a non-2xx status and a 200 response that +// omits the id — both must be reported as errors, not swallowed. +func TestCreateRequestErrorPaths(t *testing.T) { + clearEnv(t, haxEnvKeys...) + + t.Run("non-2xx status", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"error":"bad severity enum"}`)) + })) + defer srv.Close() + c := &HaxClient{BaseURL: srv.URL, APIKey: "k", HTTPClient: srv.Client()} + _, err := c.CreateRequest(context.Background(), CreateRequestParams{Type: "t", Payload: map[string]any{}}) + if err == nil || !strings.Contains(err.Error(), "status 422") { + t.Fatalf("err = %v, want it to mention status 422", err) + } + }) + + t.Run("missing id", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"url":"u"}`)) + })) + defer srv.Close() + c := &HaxClient{BaseURL: srv.URL, APIKey: "k", HTTPClient: srv.Client()} + _, err := c.CreateRequest(context.Background(), CreateRequestParams{Type: "t", Payload: map[string]any{}}) + if err == nil || !strings.Contains(err.Error(), "missing id") { + t.Fatalf("err = %v, want it to mention missing id", err) + } + }) +} + +// TestBuildHaxClientFromEnv is the HITL on/off switch: nil without a key, +// configured client with one. Table-driven over HAX_API_KEY / HAX_SDK_URL. +func TestBuildHaxClientFromEnv(t *testing.T) { + tests := []struct { + name string + apiKey *string // nil => leave unset + sdkURL *string // nil => leave unset + wantNil bool + wantBaseURL string + }{ + {name: "no key -> nil (HITL disabled)", apiKey: nil, wantNil: true}, + {name: "blank key -> nil", apiKey: ptr(" "), wantNil: true}, + {name: "empty key -> nil", apiKey: ptr(""), wantNil: true}, + {name: "key, default base", apiKey: ptr("secret"), sdkURL: nil, wantBaseURL: "http://localhost:3000"}, + {name: "key + custom base, trailing slash trimmed", apiKey: ptr("secret"), sdkURL: ptr("https://hax.internal:9000/"), wantBaseURL: "https://hax.internal:9000"}, + {name: "key, key is trimmed", apiKey: ptr(" secret "), sdkURL: ptr("http://h:1"), wantBaseURL: "http://h:1"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + clearEnv(t, haxEnvKeys...) + if tc.apiKey != nil { + setEnv(t, "HAX_API_KEY", *tc.apiKey) + } + if tc.sdkURL != nil { + setEnv(t, "HAX_SDK_URL", *tc.sdkURL) + } + + c := BuildHaxClientFromEnv() + if tc.wantNil { + if c != nil { + t.Fatalf("expected nil client (HITL disabled), got %+v", c) + } + return + } + if c == nil { + t.Fatal("expected a client, got nil") + } + if c.BaseURL != tc.wantBaseURL { + t.Errorf("BaseURL = %q, want %q", c.BaseURL, tc.wantBaseURL) + } + if strings.TrimSpace(c.APIKey) == "" { + t.Error("APIKey must be populated and trimmed") + } + }) + } +} + +// TestResolveSender covers the sender-attribution cascade +// (HAX_SENDER_NAME || NODE_ID || "pr-af") and the key fallback +// (HAX_SENDER_KEY, defaulting to the resolved display name). +func TestResolveSender(t *testing.T) { + tests := []struct { + name string + senderName, nodeID *string + senderKey *string + wantKey, wantName string + }{ + {name: "all unset -> pr-af/pr-af", wantKey: "pr-af", wantName: "pr-af"}, + {name: "NODE_ID drives both (design case)", nodeID: ptr("pr-af-go"), wantKey: "pr-af-go", wantName: "pr-af-go"}, + {name: "HAX_SENDER_NAME wins over NODE_ID", senderName: ptr("custom"), nodeID: ptr("pr-af-go"), wantKey: "custom", wantName: "custom"}, + {name: "explicit key overrides name-derived key", senderName: ptr("disp"), senderKey: ptr("k-explicit"), wantKey: "k-explicit", wantName: "disp"}, + {name: "empty HAX_SENDER_NAME falls through to NODE_ID", senderName: ptr(""), nodeID: ptr("pr-af-go"), wantKey: "pr-af-go", wantName: "pr-af-go"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + clearEnv(t, haxEnvKeys...) + if tc.senderName != nil { + setEnv(t, "HAX_SENDER_NAME", *tc.senderName) + } + if tc.nodeID != nil { + setEnv(t, "NODE_ID", *tc.nodeID) + } + if tc.senderKey != nil { + setEnv(t, "HAX_SENDER_KEY", *tc.senderKey) + } + key, name := resolveSender() + if key != tc.wantKey || name != tc.wantName { + t.Errorf("resolveSender() = (%q, %q), want (%q, %q)", key, name, tc.wantKey, tc.wantName) + } + }) + } +} + +// TestApprovalWebhookURL covers the three-tier precedence +// (AGENTFIELD_PUBLIC_URL > agentFieldServer arg > AGENTFIELD_SERVER) plus +// trailing-slash trimming and the nil result when nothing resolves. +func TestApprovalWebhookURL(t *testing.T) { + const suffix = "/api/v1/webhooks/approval-response" + tests := []struct { + name string + publicURL *string + serverArg string + serverEnv *string + want *string + }{ + {name: "public url wins over arg and env", publicURL: ptr("https://public.example"), serverArg: "http://internal:8080", serverEnv: ptr("http://also:8080"), want: ptr("https://public.example" + suffix)}, + {name: "arg used when public url unset", serverArg: "http://internal:8080", serverEnv: ptr("http://also:8080"), want: ptr("http://internal:8080" + suffix)}, + {name: "server env used when public url + arg empty", serverEnv: ptr("http://cp:8080"), want: ptr("http://cp:8080" + suffix)}, + {name: "trailing slash trimmed", publicURL: ptr("https://public.example/"), want: ptr("https://public.example" + suffix)}, + {name: "nothing resolves -> nil", want: nil}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + clearEnv(t, "AGENTFIELD_PUBLIC_URL", "AGENTFIELD_SERVER") + if tc.publicURL != nil { + setEnv(t, "AGENTFIELD_PUBLIC_URL", *tc.publicURL) + } + if tc.serverEnv != nil { + setEnv(t, "AGENTFIELD_SERVER", *tc.serverEnv) + } + got := ApprovalWebhookURL(tc.serverArg) + switch { + case tc.want == nil && got != nil: + t.Fatalf("got %q, want nil", *got) + case tc.want != nil && got == nil: + t.Fatalf("got nil, want %q", *tc.want) + case tc.want != nil && *got != *tc.want: + t.Errorf("got %q, want %q", *got, *tc.want) + } + }) + } +} diff --git a/go/internal/hitl/pause.go b/go/internal/hitl/pause.go new file mode 100644 index 0000000..104762b --- /dev/null +++ b/go/internal/hitl/pause.go @@ -0,0 +1,64 @@ +package hitl + +import ( + "context" + + "github.com/Agent-Field/agentfield/sdk/go/agent" +) + +// Pauser is the pause surface the PR-AF review gate drives. The AgentField Go +// SDK *agent.Agent satisfies it via its Pause method; tests supply a fake. +// +// Pause transitions the execution to "waiting" on the control plane and blocks +// until the approval webhook callback resolves it (or it expires) — the direct +// port of Python's app.pause() used by review_gate.py::request_review_approval. +// There is no polling: the SDK registers the pending pause and the agent's +// /webhooks/approval route resolves it when the human responds. +type Pauser interface { + Pause(ctx context.Context, opts agent.PauseOptions) (*agent.ApprovalResult, error) +} + +// Compile-time proof that the real SDK agent is a Pauser, so the review gate +// can accept the seam and be handed the live *agent.Agent unchanged. +var _ Pauser = (*agent.Agent)(nil) + +// App is the minimal slice of *agent.Agent the HITL primitives need: the +// fire-and-forget note channel. Kept as an interface so tests can supply a +// silent stub (mirrors the Python tests that mock app.note). +type App interface { + Note(ctx context.Context, message string, tags ...string) +} + +// noteSafe fires a note when app is non-nil; a nil app is a no-op so the +// primitives stay usable in tests / contexts without an agent. +func noteSafe(ctx context.Context, app App, message string, tags ...string) { + if app != nil { + app.Note(ctx, message, tags...) + } +} + +// extractValuesFromRaw finds the submitted form/template values inside an +// approval response payload. Ports pr_af/hitl/client.py::extract_values_from_raw: +// prefer raw["values"], then raw["response"]["values"]; anything else -> {}. +func extractValuesFromRaw(raw map[string]any) map[string]any { + if raw == nil { + return map[string]any{} + } + if direct, ok := raw["values"].(map[string]any); ok { + return copyAnyMap(direct) + } + if respObj, ok := raw["response"].(map[string]any); ok { + if inner, ok := respObj["values"].(map[string]any); ok { + return copyAnyMap(inner) + } + } + return map[string]any{} +} + +func copyAnyMap(in map[string]any) map[string]any { + out := make(map[string]any, len(in)) + for k, v := range in { + out[k] = v + } + return out +} diff --git a/go/internal/hitl/pause_test.go b/go/internal/hitl/pause_test.go new file mode 100644 index 0000000..2a9fd64 --- /dev/null +++ b/go/internal/hitl/pause_test.go @@ -0,0 +1,82 @@ +package hitl + +import ( + "context" + "reflect" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/agent" +) + +// fakePauser is a test double for the Pauser seam. Its existence + the +// assignment below prove a non-agent type can satisfy the interface, which is +// what lets the review gate be unit-tested without a live control plane. +type fakePauser struct { + gotOpts agent.PauseOptions + result *agent.ApprovalResult + err error +} + +func (f *fakePauser) Pause(_ context.Context, opts agent.PauseOptions) (*agent.ApprovalResult, error) { + f.gotOpts = opts + return f.result, f.err +} + +var _ Pauser = (*fakePauser)(nil) + +// TestPauserSeam confirms a fake satisfies the Pauser contract and round-trips +// options and result — the seam the review gate (T3.3) drives. +func TestPauserSeam(t *testing.T) { + want := &agent.ApprovalResult{Decision: "approved", Feedback: "lgtm"} + f := &fakePauser{result: want} + var p Pauser = f + + got, err := p.Pause(context.Background(), agent.PauseOptions{ApprovalRequestID: "req_1", ExpiresInHours: 72}) + if err != nil { + t.Fatalf("Pause: %v", err) + } + if got != want { + t.Errorf("result = %+v, want %+v", got, want) + } + if f.gotOpts.ApprovalRequestID != "req_1" || f.gotOpts.ExpiresInHours != 72 { + t.Errorf("opts not forwarded: %+v", f.gotOpts) + } +} + +func TestExtractValuesFromRaw(t *testing.T) { + tests := []struct { + name string + raw map[string]any + want map[string]any + }{ + {name: "nil -> empty", raw: nil, want: map[string]any{}}, + { + name: "direct values", + raw: map[string]any{"values": map[string]any{"action": "post_selected"}}, + want: map[string]any{"action": "post_selected"}, + }, + { + name: "nested response.values", + raw: map[string]any{"response": map[string]any{"values": map[string]any{"action": "rerun"}}}, + want: map[string]any{"action": "rerun"}, + }, + { + name: "direct wins over nested", + raw: map[string]any{ + "values": map[string]any{"action": "reject"}, + "response": map[string]any{"values": map[string]any{"action": "rerun"}}, + }, + want: map[string]any{"action": "reject"}, + }, + {name: "values wrong type -> empty", raw: map[string]any{"values": "not-a-map"}, want: map[string]any{}}, + {name: "no values anywhere -> empty", raw: map[string]any{"other": 1}, want: map[string]any{}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := extractValuesFromRaw(tc.raw) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("extractValuesFromRaw(%v) = %v, want %v", tc.raw, got, tc.want) + } + }) + } +} diff --git a/go/internal/hitl/review_gate.go b/go/internal/hitl/review_gate.go new file mode 100644 index 0000000..4505b4c --- /dev/null +++ b/go/internal/hitl/review_gate.go @@ -0,0 +1,434 @@ +package hitl + +// review_gate.go is the PR-AF human-in-the-loop review gate — the Go port of +// src/pr_af/hitl/review_gate.py. It builds the "pr-af-review-v1" hax form +// (intent blurb + one entry per finding), creates the request via the hax +// substrate (hax_client.go), pauses the execution via the Pauser seam +// (pause.go), and maps the reviewer's response to a ReviewDecision: +// +// - post_selected — post the checked subset of findings, or +// - rerun — re-run the review with free-text instructions, or +// - reject — post nothing. +// +// Every failure path (payload build, create-request, pause) is surfaced as a +// reject so the pipeline never posts an unreviewed review when the gate is on. + +import ( + "context" + "fmt" + "regexp" + "strings" + "unicode" + + "github.com/Agent-Field/agentfield/sdk/go/agent" + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// HaxReviewTemplate is the hax template this gate renders into. Registered in +// hax-sdk under src/templates/pr-af-review (id "pr-af-review-v1"). Ports +// review_gate.py::HAX_REVIEW_TEMPLATE. +const HaxReviewTemplate = "pr-af-review-v1" + +// The action the reviewer chose in the template's buttons. These string values +// are the contract with the hax template's response values.action. Port +// review_gate.py::{ACTION_POST,ACTION_RERUN,ACTION_REJECT}. +const ( + ActionPost = "post_selected" + ActionRerun = "rerun" + ActionReject = "reject" +) + +// validActions is the _VALID_ACTIONS set from review_gate.py. +var validActions = map[string]struct{}{ + ActionPost: {}, + ActionRerun: {}, + ActionReject: {}, +} + +// MaxIntentChars is the longest PR-intent blurb shown in the form; the raw PR +// body is stripped of HTML and truncated to this. Ports +// review_gate.py::_MAX_INTENT_CHARS (700). +const MaxIntentChars = 700 + +// Regexes for CleanIntent, ported verbatim from review_gate.py. +var ( + htmlTagRE = regexp.MustCompile(`<[^>]+>`) // _HTML_TAG_RE + wsRE = regexp.MustCompile(`[ \t]+`) // _WS_RE + blankLineRE = regexp.MustCompile(`\n{3,}`) // _BLANKLINES_RE +) + +// ReviewDecision is the parsed outcome of one HITL round. Ports +// review_gate.py::ReviewDecision (Python's set[str] -> map[string]struct{}). +type ReviewDecision struct { + // Action is one of ActionPost | ActionRerun | ActionReject. + Action string + // SelectedFindingIDs is the set of finding ids to post (post_selected only). + SelectedFindingIDs map[string]struct{} + // Instructions is the reviewer's free-text (rerun) or the fallback feedback. + Instructions string + // DecisionRaw is the underlying agentfield decision string ("approved", + // "expired", ...) kept for logs. + DecisionRaw string +} + +// IsPost reports whether the reviewer chose to post the selected findings. +func (d ReviewDecision) IsPost() bool { return d.Action == ActionPost } + +// IsRerun reports whether the reviewer asked to re-run with instructions. +func (d ReviewDecision) IsRerun() bool { return d.Action == ActionRerun } + +// IsReject reports whether the outcome is to post nothing. +func (d ReviewDecision) IsReject() bool { return d.Action == ActionReject } + +// CleanIntent turns a raw PR body into a short, legible intent blurb: strip HTML +// tags, collapse whitespace, collapse blank-line runs, per-line trim, then +// truncate to maxChars codepoints with a trailing "…". Ports +// review_gate.py::clean_intent. +// +// Truncation is codepoint-aware (Python slices a unicode str; a byte slice would +// split multibyte runes), and the trailing trim uses unicode.IsSpace to mirror +// Python's str.rstrip(). +func CleanIntent(text string, maxChars int) string { + if text == "" { + return "" + } + stripped := htmlTagRE.ReplaceAllString(text, " ") + stripped = wsRE.ReplaceAllString(stripped, " ") + stripped = blankLineRE.ReplaceAllString(stripped, "\n\n") + lines := strings.Split(stripped, "\n") + for i, line := range lines { + lines[i] = strings.TrimSpace(line) + } + stripped = strings.Join(lines, "\n") + stripped = strings.TrimSpace(stripped) + runes := []rune(stripped) + if len(runes) > maxChars { + truncated := strings.TrimRightFunc(string(runes[:maxChars]), unicode.IsSpace) + stripped = truncated + "…" + } + return stripped +} + +// findingPayload builds one finding entry for the hax template. Keys are +// camelCase (per the template's zod schema) — this is one of the few camelCase +// surfaces in an otherwise snake_case system (design §B.5). Ports +// review_gate.py::_finding_payload. +// +// Severity is re-normalized here (defense-in-depth against the zod-enum 422): a +// finding built off the validated path could still carry a stray label like +// "high"; NormalizeSeverity coerces it to "important" before the create. +func findingPayload(f schemas.ScoredFinding) map[string]any { + entry := map[string]any{ + "id": f.ID, + "severity": string(schemas.NormalizeSeverity(f.Severity, schemas.DefaultSeverity)), + "title": f.Title, + "defaultSelected": true, + } + if f.FilePath != "" { + entry["filePath"] = f.FilePath + } + if f.LineStart > 0 { + entry["lineStart"] = f.LineStart + } + if f.LineEnd > 0 { + entry["lineEnd"] = f.LineEnd + } + if f.Body != "" { + entry["body"] = f.Body + } + if f.Suggestion != nil && *f.Suggestion != "" { + entry["suggestion"] = *f.Suggestion + } + if f.DimensionName != "" { + entry["dimension"] = f.DimensionName + } + // Python: `if finding.confidence is not None`. Go ScoredFinding.Confidence is + // a non-optional float64 (never nil), so it is always emitted. + entry["confidence"] = f.Confidence + return entry +} + +// BuildReviewPayload builds the "pr-af-review-v1" request payload (camelCase, +// validated server-side against prAfReviewPayloadSchema). Ports +// review_gate.py::build_review_payload. The returned map is the hax payload; the +// error return exists so RequestReviewApproval can surface a build failure as a +// reject (the Python code wraps this call in try/except). +func BuildReviewPayload( + prIntent string, + findings []schemas.ScoredFinding, + title string, + prMeta map[string]any, + revisionIter int, + revisionHistory []string, +) (map[string]any, error) { + // Severity counts, preserving first-seen order to match Python dict + // insertion order (counts[f.severity] uses the finding's severity as-is). + var order []string + counts := map[string]int{} + for _, f := range findings { + sev := string(f.Severity) + if _, seen := counts[sev]; !seen { + order = append(order, sev) + } + counts[sev]++ + } + parts := make([]string, 0, len(order)) + for _, sev := range order { + parts = append(parts, fmt.Sprintf("%d %s", counts[sev], sev)) + } + countStr := strings.Join(parts, ", ") + if countStr == "" { + countStr = "no findings" + } + + findingEntries := make([]map[string]any, 0, len(findings)) + for _, f := range findings { + findingEntries = append(findingEntries, findingPayload(f)) + } + + payload := map[string]any{ + "title": title, + "intent": CleanIntent(prIntent, MaxIntentChars), + "reviewSummary": fmt.Sprintf("PR-AF found %d finding(s) (%s).", len(findings), countStr), + "findings": findingEntries, + "postLabel": "Post selected", + "rerunLabel": "Re-review with instructions", + "rejectLabel": "Reject", + "instructionsPlaceholder": "e.g. too aggressive, tone it down and drop the nitpicks", + } + if len(prMeta) > 0 { + // Drop empties so optional zod fields stay absent rather than "". + cleaned := map[string]any{} + for k, v := range prMeta { + if v != nil && v != "" { + cleaned[k] = v + } + } + if len(cleaned) > 0 { + payload["pr"] = cleaned + } + } + if revisionIter > 0 || len(revisionHistory) > 0 { + prior := []string{} + for _, ins := range revisionHistory { + if strings.TrimSpace(ins) != "" { + prior = append(prior, ins) + } + } + payload["revision"] = map[string]any{ + "iteration": revisionIter, + "priorInstructions": prior, + } + } + return payload, nil +} + +// coerceStr ports review_gate.py::_coerce_str: a string is trimmed; a non-empty +// list yields its trimmed first string element (or a stringified first element); +// anything else yields "". +func coerceStr(value any) string { + switch t := value.(type) { + case string: + return strings.TrimSpace(t) + case []any: + if len(t) > 0 { + if s, ok := t[0].(string); ok { + return strings.TrimSpace(s) + } + return fmt.Sprintf("%v", t[0]) + } + } + return "" +} + +// coerceIDList ports review_gate.py::_coerce_id_list: a list yields its string +// elements; a non-empty string yields a single-element list; else empty. +func coerceIDList(value any) []string { + switch t := value.(type) { + case []any: + out := []string{} + for _, v := range t { + if s, ok := v.(string); ok { + out = append(out, s) + } + } + return out + case string: + if t != "" { + return []string{t} + } + } + return []string{} +} + +// idSet builds a set from a list of finding ids. +func idSet(ids []string) map[string]struct{} { + s := make(map[string]struct{}, len(ids)) + for _, id := range ids { + s[id] = struct{}{} + } + return s +} + +// ParseReviewDecision converts an agentfield ApprovalResult into a +// ReviewDecision. Ports review_gate.py::parse_review_decision. +// +// Terminal control-plane outcomes (expired/error, or a hax-level reject with no +// form values) map to a reject. Otherwise the reviewer's action radio drives the +// outcome; an absent findings_to_post field defaults to posting everything (the +// form pre-checks all findings). res may be nil (defensive; Python's getattr +// tolerates a missing attribute), in which case it is treated as an empty +// approval. +func ParseReviewDecision(res *agent.ApprovalResult, allFindingIDs []string) ReviewDecision { + var decision, feedback string + var raw map[string]any + if res != nil { + decision = strings.TrimSpace(res.Decision) + feedback = strings.TrimSpace(res.Feedback) + raw = res.RawResponse + } + values := extractValuesFromRaw(raw) + + // Literal strings matching Python's hardcoded {"expired","error"} / + // "rejected" set (equal to agent.ApprovalExpired/Error/Rejected). + if decision == "expired" || decision == "error" { + return ReviewDecision{Action: ActionReject, Instructions: feedback, DecisionRaw: decision} + } + if decision == "rejected" && len(values) == 0 { + return ReviewDecision{Action: ActionReject, Instructions: feedback, DecisionRaw: decision} + } + + action := coerceStr(values["action"]) + if _, ok := validActions[action]; !ok { + if decision == "rejected" { + action = ActionReject + } else { + action = ActionPost + } + } + instructions := coerceStr(values["instructions"]) + if instructions == "" { + instructions = feedback + } + + if action == ActionRerun { + return ReviewDecision{Action: ActionRerun, Instructions: instructions, DecisionRaw: decision} + } + if action == ActionReject { + return ReviewDecision{Action: ActionReject, Instructions: instructions, DecisionRaw: decision} + } + + // post_selected: honor the checked subset; default to all when field absent. + var selected map[string]struct{} + if _, present := values["findings_to_post"]; present { + selected = idSet(coerceIDList(values["findings_to_post"])) + } else { + selected = idSet(allFindingIDs) + } + return ReviewDecision{ + Action: ActionPost, + SelectedFindingIDs: selected, + Instructions: instructions, + DecisionRaw: decision, + } +} + +// RequestReviewApprovalArgs are the inputs to RequestReviewApproval. App and +// Pauser are the two agent seams (Note and Pause); in production both are the +// same *agent.Agent, but they are separate fields so tests can drive a fake +// Pauser with a silent (nil) note surface. +type RequestReviewApprovalArgs struct { + App App + Pauser Pauser + HaxClient *HaxClient + PRIntent string + Findings []schemas.ScoredFinding + PRLabel string + WebhookURL *string + UserID *string + ExpiresInHours int + PRMeta map[string]any + RevisionIter int + RevisionHistory []string + Metadata map[string]any +} + +// buildPayloadFn is the payload builder RequestReviewApproval calls, indirected +// through a package var so tests can force the "payload build failed" reject +// path (BuildReviewPayload itself never errors in normal operation). +var buildPayloadFn = BuildReviewPayload + +// RequestReviewApproval builds the payload, creates the hax request, pauses, and +// returns the decision. Ports review_gate.py::request_review_approval. +// +// It NEVER returns an error: any failure to build the payload, create the +// request, or pause is surfaced as a reject decision (with the exact +// " failed: " instruction string) so the pipeline never posts an +// unreviewed review when the gate is enabled. The 120s create-request timeout +// comes from the hax substrate (HaxClient.CreateRequest). +func RequestReviewApproval(ctx context.Context, args RequestReviewApprovalArgs) ReviewDecision { + title := "PR-AF Review Approval" + if args.RevisionIter > 0 { + title = fmt.Sprintf("%s (revision %d)", title, args.RevisionIter) + } + if args.PRLabel != "" { + title = fmt.Sprintf("%s — %s", title, args.PRLabel) + } + + payload, err := buildPayloadFn(args.PRIntent, args.Findings, title, args.PRMeta, args.RevisionIter, args.RevisionHistory) + if err != nil { + noteSafe(ctx, args.App, fmt.Sprintf("hitl: failed to build review payload: %s", err), "hitl", "payload", "error") + return ReviewDecision{Action: ActionReject, Instructions: fmt.Sprintf("payload build failed: %s", err)} + } + + noteSafe(ctx, args.App, + fmt.Sprintf("hitl: submitting hax request (%s: %q)", HaxReviewTemplate, title), + "hitl", "hax", "create_request") + + var webhookURL, userID string + if args.WebhookURL != nil { + webhookURL = *args.WebhookURL + } + if args.UserID != nil { + userID = *args.UserID + } + + created, err := args.HaxClient.CreateRequest(ctx, CreateRequestParams{ + Type: HaxReviewTemplate, + Payload: payload, + Title: title, + Description: nil, + WebhookURL: webhookURL, + ExpiresInSeconds: args.ExpiresInHours * 3600, + UserID: userID, + Metadata: args.Metadata, + }) + if err != nil { + noteSafe(ctx, args.App, fmt.Sprintf("hitl: create_request failed, treating as reject: %s", err), "hitl", "hax", "error") + return ReviewDecision{Action: ActionReject, Instructions: fmt.Sprintf("create_request failed: %s", err)} + } + noteSafe(ctx, args.App, + fmt.Sprintf("hitl: hax form request created (request_id=%s)", created.ID), + "hitl", "hax", "submitted") + + approval, err := args.Pauser.Pause(ctx, agent.PauseOptions{ + ApprovalRequestID: created.ID, + ApprovalRequestURL: created.URL, + ExpiresInHours: args.ExpiresInHours, + }) + if err != nil { + noteSafe(ctx, args.App, fmt.Sprintf("hitl: pause failed, treating as reject: %s", err), "hitl", "pause", "error") + return ReviewDecision{Action: ActionReject, Instructions: fmt.Sprintf("pause failed: %s", err)} + } + + findingIDs := make([]string, 0, len(args.Findings)) + for _, f := range args.Findings { + findingIDs = append(findingIDs, f.ID) + } + decision := ParseReviewDecision(approval, findingIDs) + noteSafe(ctx, args.App, + fmt.Sprintf("hitl: review decision=%s (raw=%s, selected=%d)", + decision.Action, decision.DecisionRaw, len(decision.SelectedFindingIDs)), + "hitl", "decision", decision.Action) + return decision +} diff --git a/go/internal/hitl/review_gate_test.go b/go/internal/hitl/review_gate_test.go new file mode 100644 index 0000000..8b968da --- /dev/null +++ b/go/internal/hitl/review_gate_test.go @@ -0,0 +1,513 @@ +package hitl + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "reflect" + "sort" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/agent" + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// sortedAnyKeys returns the sorted key set of a map[string]any for exact +// key-set assertions on the hand-built (camelCase) hax payload. +func sortedAnyKeys(m map[string]any) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// selectedIDs collapses the decision's selected-id set to a sorted slice for +// comparison. +func selectedIDs(d ReviewDecision) []string { + out := make([]string, 0, len(d.SelectedFindingIDs)) + for id := range d.SelectedFindingIDs { + out = append(out, id) + } + sort.Strings(out) + return out +} + +// --- V5: decision state machine ------------------------------------------ + +func TestParseReviewDecision(t *testing.T) { + allIDs := []string{"f_001", "f_002", "f_003"} + + tests := []struct { + name string + res *agent.ApprovalResult + wantAction string + wantInstr string + wantRaw string + wantSelected []string // nil => don't assert + }{ + { + name: "expired -> reject", + res: &agent.ApprovalResult{Decision: "expired", Feedback: "timed out"}, + wantAction: ActionReject, wantInstr: "timed out", wantRaw: "expired", + }, + { + name: "error -> reject", + res: &agent.ApprovalResult{Decision: "error", Feedback: "boom"}, + wantAction: ActionReject, wantInstr: "boom", wantRaw: "error", + }, + { + name: "rejected with no values -> reject", + res: &agent.ApprovalResult{Decision: "rejected", Feedback: "no thanks"}, + wantAction: ActionReject, wantInstr: "no thanks", wantRaw: "rejected", + }, + { + name: "rejected with empty values map -> reject", + res: &agent.ApprovalResult{Decision: "rejected", Feedback: "fb", RawResponse: map[string]any{"values": map[string]any{}}}, + wantAction: ActionReject, wantInstr: "fb", wantRaw: "rejected", + }, + { + name: "post honors findings_to_post subset", + res: &agent.ApprovalResult{Decision: "approved", RawResponse: map[string]any{ + "values": map[string]any{"action": "post_selected", "findings_to_post": []any{"f_001", "f_003"}}, + }}, + wantAction: ActionPost, wantRaw: "approved", wantSelected: []string{"f_001", "f_003"}, + }, + { + name: "post with absent findings_to_post -> all ids", + res: &agent.ApprovalResult{Decision: "approved", RawResponse: map[string]any{ + "values": map[string]any{"action": "post_selected"}, + }}, + wantAction: ActionPost, wantRaw: "approved", wantSelected: []string{"f_001", "f_002", "f_003"}, + }, + { + name: "rerun carries instructions", + res: &agent.ApprovalResult{Decision: "approved", RawResponse: map[string]any{ + "values": map[string]any{"action": "rerun", "instructions": "tone it down"}, + }}, + wantAction: ActionRerun, wantInstr: "tone it down", wantRaw: "approved", + }, + { + name: "rerun instructions fall back to feedback", + res: &agent.ApprovalResult{Decision: "approved", Feedback: "less aggressive please", RawResponse: map[string]any{ + "values": map[string]any{"action": "rerun"}, + }}, + wantAction: ActionRerun, wantInstr: "less aggressive please", wantRaw: "approved", + }, + { + name: "values nested under response.values", + res: &agent.ApprovalResult{Decision: "approved", RawResponse: map[string]any{ + "response": map[string]any{"values": map[string]any{"action": "rerun", "instructions": "nested"}}, + }}, + wantAction: ActionRerun, wantInstr: "nested", wantRaw: "approved", + }, + { + name: "rejected but values present with post action -> post subset", + res: &agent.ApprovalResult{Decision: "rejected", RawResponse: map[string]any{ + "values": map[string]any{"action": "post_selected", "findings_to_post": []any{"f_002"}}, + }}, + wantAction: ActionPost, wantRaw: "rejected", wantSelected: []string{"f_002"}, + }, + { + name: "invalid action + approved -> post all", + res: &agent.ApprovalResult{Decision: "approved", RawResponse: map[string]any{ + "values": map[string]any{"action": "bogus"}, + }}, + wantAction: ActionPost, wantRaw: "approved", wantSelected: []string{"f_001", "f_002", "f_003"}, + }, + { + name: "invalid action + rejected (values present) -> reject", + res: &agent.ApprovalResult{Decision: "rejected", RawResponse: map[string]any{ + "values": map[string]any{"action": "bogus", "instructions": "x"}, + }}, + wantAction: ActionReject, wantInstr: "x", wantRaw: "rejected", + }, + { + name: "nil approval -> post all (empty approval, not terminal)", + res: nil, + wantAction: ActionPost, wantRaw: "", wantSelected: []string{"f_001", "f_002", "f_003"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := ParseReviewDecision(tc.res, allIDs) + if got.Action != tc.wantAction { + t.Errorf("Action = %q, want %q", got.Action, tc.wantAction) + } + if got.DecisionRaw != tc.wantRaw { + t.Errorf("DecisionRaw = %q, want %q", got.DecisionRaw, tc.wantRaw) + } + if tc.wantInstr != "" && got.Instructions != tc.wantInstr { + t.Errorf("Instructions = %q, want %q", got.Instructions, tc.wantInstr) + } + if tc.wantSelected != nil { + if diff := selectedIDs(got); !reflect.DeepEqual(diff, tc.wantSelected) { + t.Errorf("SelectedFindingIDs = %v, want %v", diff, tc.wantSelected) + } + } + }) + } +} + +// TestReviewDecisionPredicates confirms the Is* predicates track Action. +func TestReviewDecisionPredicates(t *testing.T) { + if !(ReviewDecision{Action: ActionPost}).IsPost() { + t.Error("IsPost") + } + if !(ReviewDecision{Action: ActionRerun}).IsRerun() { + t.Error("IsRerun") + } + if !(ReviewDecision{Action: ActionReject}).IsReject() { + t.Error("IsReject") + } + if (ReviewDecision{Action: ActionPost}).IsReject() { + t.Error("post must not report IsReject") + } +} + +// --- V5: payload shape + severity re-normalization ----------------------- + +func TestBuildReviewPayloadShape(t *testing.T) { + sug := "use parameterized queries" + findings := []schemas.ScoredFinding{ + { + ID: "f_001", Severity: "important", Title: "SQL injection", + FilePath: "db/query.go", LineStart: 10, LineEnd: 12, + Body: "concatenated user input", Suggestion: &sug, + DimensionName: "Security", Confidence: 0.9, + }, + } + payload, err := BuildReviewPayload("PR body intent", findings, "PR-AF Review Approval", nil, 0, nil) + if err != nil { + t.Fatalf("BuildReviewPayload: %v", err) + } + + wantTop := []string{ + "findings", "instructionsPlaceholder", "intent", + "postLabel", "rejectLabel", "rerunLabel", "reviewSummary", "title", + } + if got := sortedAnyKeys(payload); !reflect.DeepEqual(got, wantTop) { + t.Errorf("top-level keys = %v, want %v", got, wantTop) + } + + if payload["title"] != "PR-AF Review Approval" { + t.Errorf("title = %v", payload["title"]) + } + if payload["postLabel"] != "Post selected" || + payload["rerunLabel"] != "Re-review with instructions" || + payload["rejectLabel"] != "Reject" { + t.Errorf("labels wrong: %v / %v / %v", payload["postLabel"], payload["rerunLabel"], payload["rejectLabel"]) + } + if payload["instructionsPlaceholder"] != "e.g. too aggressive, tone it down and drop the nitpicks" { + t.Errorf("instructionsPlaceholder = %v", payload["instructionsPlaceholder"]) + } + if payload["reviewSummary"] != "PR-AF found 1 finding(s) (1 important)." { + t.Errorf("reviewSummary = %v", payload["reviewSummary"]) + } + + entries, ok := payload["findings"].([]map[string]any) + if !ok || len(entries) != 1 { + t.Fatalf("findings = %#v", payload["findings"]) + } + fp := entries[0] + wantFP := []string{ + "body", "confidence", "defaultSelected", "dimension", + "filePath", "id", "lineEnd", "lineStart", "severity", "suggestion", "title", + } + if got := sortedAnyKeys(fp); !reflect.DeepEqual(got, wantFP) { + t.Errorf("finding entry keys = %v, want %v", got, wantFP) + } + if fp["defaultSelected"] != true { + t.Errorf("defaultSelected = %v, want true", fp["defaultSelected"]) + } + if fp["id"] != "f_001" || fp["title"] != "SQL injection" || fp["severity"] != "important" { + t.Errorf("finding scalar fields wrong: %#v", fp) + } + if fp["filePath"] != "db/query.go" || fp["lineStart"] != 10 || fp["lineEnd"] != 12 { + t.Errorf("finding location fields wrong: %#v", fp) + } + if fp["suggestion"] != "use parameterized queries" || fp["dimension"] != "Security" { + t.Errorf("finding suggestion/dimension wrong: %#v", fp) + } + if fp["confidence"] != 0.9 { + t.Errorf("confidence = %v, want 0.9", fp["confidence"]) + } +} + +// TestBuildReviewPayloadMinimalFinding asserts optional keys are omitted when +// their source field is empty/zero — only the four always-present keys plus the +// always-emitted confidence remain. +func TestBuildReviewPayloadMinimalFinding(t *testing.T) { + findings := []schemas.ScoredFinding{ + {ID: "f_001", Severity: "nitpick", Title: "style", Confidence: 0.5}, + } + payload, err := BuildReviewPayload("", findings, "T", nil, 0, nil) + if err != nil { + t.Fatalf("BuildReviewPayload: %v", err) + } + fp := payload["findings"].([]map[string]any)[0] + want := []string{"confidence", "defaultSelected", "id", "severity", "title"} + if got := sortedAnyKeys(fp); !reflect.DeepEqual(got, want) { + t.Errorf("minimal finding keys = %v, want %v", got, want) + } + // Empty intent stays "". + if payload["intent"] != "" { + t.Errorf("intent = %q, want empty", payload["intent"]) + } +} + +// TestFindingPayloadSeverityRenormalization is the zod-enum-422 defense: a +// finding carrying a stray "high" (constructed by struct literal, so it never +// passed through the unmarshal coercion) must be re-normalized to "important" +// before it reaches the pr-af-review-v1 template. +func TestFindingPayloadSeverityRenormalization(t *testing.T) { + findings := []schemas.ScoredFinding{ + {ID: "f_001", Severity: "high", Title: "x", Confidence: 0.5}, + } + payload, err := BuildReviewPayload("", findings, "T", nil, 0, nil) + if err != nil { + t.Fatalf("BuildReviewPayload: %v", err) + } + fp := payload["findings"].([]map[string]any)[0] + if fp["severity"] != "important" { + t.Errorf("severity = %v, want important (high re-normalized)", fp["severity"]) + } +} + +// TestBuildReviewPayloadNoFindings covers the "no findings" review summary and +// the empty (non-nil) findings slice. +func TestBuildReviewPayloadNoFindings(t *testing.T) { + payload, err := BuildReviewPayload("intent", nil, "T", nil, 0, nil) + if err != nil { + t.Fatalf("BuildReviewPayload: %v", err) + } + if payload["reviewSummary"] != "PR-AF found 0 finding(s) (no findings)." { + t.Errorf("reviewSummary = %v", payload["reviewSummary"]) + } + entries, ok := payload["findings"].([]map[string]any) + if !ok || entries == nil || len(entries) != 0 { + t.Errorf("findings = %#v, want empty non-nil slice", payload["findings"]) + } +} + +// TestBuildReviewPayloadOptionalSections covers the pr{} and revision{} blocks: +// present when populated, absent otherwise, empty pr-meta values dropped, and +// blank prior instructions filtered. +func TestBuildReviewPayloadOptionalSections(t *testing.T) { + t.Run("absent by default", func(t *testing.T) { + payload, _ := BuildReviewPayload("i", nil, "T", nil, 0, nil) + if _, ok := payload["pr"]; ok { + t.Error("pr must be absent") + } + if _, ok := payload["revision"]; ok { + t.Error("revision must be absent") + } + }) + + t.Run("pr meta drops empties", func(t *testing.T) { + payload, _ := BuildReviewPayload("i", nil, "T", map[string]any{ + "title": "My PR", "number": 42, "author": "", "url": nil, + }, 0, nil) + pr, ok := payload["pr"].(map[string]any) + if !ok { + t.Fatalf("pr = %#v", payload["pr"]) + } + want := []string{"number", "title"} + if got := sortedAnyKeys(pr); !reflect.DeepEqual(got, want) { + t.Errorf("pr keys = %v, want %v", got, want) + } + }) + + t.Run("pr meta all-empty -> absent", func(t *testing.T) { + payload, _ := BuildReviewPayload("i", nil, "T", map[string]any{"a": "", "b": nil}, 0, nil) + if _, ok := payload["pr"]; ok { + t.Error("pr must be absent when all values empty") + } + }) + + t.Run("revision present with filtered instructions", func(t *testing.T) { + payload, _ := BuildReviewPayload("i", nil, "T", nil, 2, []string{"first", " ", "", "second"}) + rev, ok := payload["revision"].(map[string]any) + if !ok { + t.Fatalf("revision = %#v", payload["revision"]) + } + if rev["iteration"] != 2 { + t.Errorf("iteration = %v, want 2", rev["iteration"]) + } + prior, ok := rev["priorInstructions"].([]string) + if !ok || !reflect.DeepEqual(prior, []string{"first", "second"}) { + t.Errorf("priorInstructions = %#v, want [first second]", rev["priorInstructions"]) + } + }) + + t.Run("revision from iteration alone yields empty prior list", func(t *testing.T) { + payload, _ := BuildReviewPayload("i", nil, "T", nil, 1, nil) + rev := payload["revision"].(map[string]any) + prior, ok := rev["priorInstructions"].([]string) + if !ok || prior == nil || len(prior) != 0 { + t.Errorf("priorInstructions = %#v, want empty non-nil slice", rev["priorInstructions"]) + } + }) +} + +// --- V5: three failure paths -> reject with exact instruction strings ---- + +func TestRequestReviewApprovalPayloadBuildFailure(t *testing.T) { + orig := buildPayloadFn + buildPayloadFn = func(string, []schemas.ScoredFinding, string, map[string]any, int, []string) (map[string]any, error) { + return nil, errors.New("boom") + } + t.Cleanup(func() { buildPayloadFn = orig }) + + dec := RequestReviewApproval(context.Background(), RequestReviewApprovalArgs{ + // HaxClient/Pauser intentionally nil: the build failure returns before + // either is touched. + Findings: []schemas.ScoredFinding{{ID: "f_001"}}, + ExpiresInHours: 72, + }) + if !dec.IsReject() { + t.Fatalf("action = %q, want reject", dec.Action) + } + if dec.Instructions != "payload build failed: boom" { + t.Errorf("instructions = %q, want %q", dec.Instructions, "payload build failed: boom") + } +} + +func TestRequestReviewApprovalCreateRequestFailure(t *testing.T) { + clearEnv(t, haxEnvKeys...) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"hax down"}`)) + })) + defer srv.Close() + + dec := RequestReviewApproval(context.Background(), RequestReviewApprovalArgs{ + HaxClient: &HaxClient{BaseURL: srv.URL, APIKey: "k", HTTPClient: srv.Client()}, + Pauser: &fakePauser{}, // must never be reached + Findings: []schemas.ScoredFinding{{ID: "f_001"}}, + ExpiresInHours: 72, + }) + if !dec.IsReject() { + t.Fatalf("action = %q, want reject", dec.Action) + } + if !strings.HasPrefix(dec.Instructions, "create_request failed:") { + t.Errorf("instructions = %q, want prefix %q", dec.Instructions, "create_request failed:") + } + if !strings.Contains(dec.Instructions, "status 500") { + t.Errorf("instructions = %q, want it to mention status 500", dec.Instructions) + } +} + +func TestRequestReviewApprovalPauseFailure(t *testing.T) { + clearEnv(t, haxEnvKeys...) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"id":"req_1","url":"https://hax.example/r/req_1"}`)) + })) + defer srv.Close() + + fp := &fakePauser{err: errors.New("pause boom")} + dec := RequestReviewApproval(context.Background(), RequestReviewApprovalArgs{ + HaxClient: &HaxClient{BaseURL: srv.URL, APIKey: "k", HTTPClient: srv.Client()}, + Pauser: fp, + Findings: []schemas.ScoredFinding{{ID: "f_001"}}, + ExpiresInHours: 72, + }) + if !dec.IsReject() { + t.Fatalf("action = %q, want reject", dec.Action) + } + if dec.Instructions != "pause failed: pause boom" { + t.Errorf("instructions = %q, want %q", dec.Instructions, "pause failed: pause boom") + } + // The create-request must have succeeded and the resolved request id/url must + // have been forwarded to Pause. + if fp.gotOpts.ApprovalRequestID != "req_1" || fp.gotOpts.ExpiresInHours != 72 { + t.Errorf("pause opts = %+v, want id=req_1 hours=72", fp.gotOpts) + } +} + +// TestRequestReviewApprovalHappyPath drives a full round: create succeeds, the +// human approves a subset, and the decision reflects it. +func TestRequestReviewApprovalHappyPath(t *testing.T) { + clearEnv(t, haxEnvKeys...) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"id":"req_9","url":"u"}`)) + })) + defer srv.Close() + + fp := &fakePauser{result: &agent.ApprovalResult{ + Decision: "approved", + RawResponse: map[string]any{ + "values": map[string]any{"action": "post_selected", "findings_to_post": []any{"f_002"}}, + }, + }} + dec := RequestReviewApproval(context.Background(), RequestReviewApprovalArgs{ + HaxClient: &HaxClient{BaseURL: srv.URL, APIKey: "k", HTTPClient: srv.Client()}, + Pauser: fp, + PRLabel: "owner/repo#1", + Findings: []schemas.ScoredFinding{{ID: "f_001"}, {ID: "f_002"}}, + ExpiresInHours: 48, + }) + if !dec.IsPost() { + t.Fatalf("action = %q, want post_selected", dec.Action) + } + if got := selectedIDs(dec); !reflect.DeepEqual(got, []string{"f_002"}) { + t.Errorf("selected = %v, want [f_002]", got) + } + if fp.gotOpts.ApprovalRequestID != "req_9" || fp.gotOpts.ExpiresInHours != 48 { + t.Errorf("pause opts = %+v", fp.gotOpts) + } +} + +// --- V5: clean_intent table ---------------------------------------------- + +func TestCleanIntent(t *testing.T) { + tests := []struct { + name string + in string + maxChars int + want string + }{ + {name: "empty", in: "", maxChars: MaxIntentChars, want: ""}, + { + name: "html stripped and whitespace collapsed", + in: "

Hello

world", + maxChars: MaxIntentChars, + want: "Hello world", + }, + { + name: "tabs collapsed, blank-line runs squeezed, per-line trimmed", + in: "line1\t\t \nline2\n\n\n\nline3", + maxChars: MaxIntentChars, + want: "line1\nline2\n\nline3", + }, + { + name: "truncated at maxChars with ellipsis", + in: strings.Repeat("a", 800), + maxChars: MaxIntentChars, + want: strings.Repeat("a", MaxIntentChars) + "…", + }, + { + name: "truncation rstrips a trailing space before the ellipsis", + in: strings.Repeat("a", 699) + " bbbb", // ws collapses -> 699 a's, space, bbbb + maxChars: MaxIntentChars, + want: strings.Repeat("a", 699) + "…", + }, + { + name: "exactly maxChars is not truncated", + in: strings.Repeat("a", MaxIntentChars), + maxChars: MaxIntentChars, + want: strings.Repeat("a", MaxIntentChars), + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := CleanIntent(tc.in, tc.maxChars); got != tc.want { + t.Errorf("CleanIntent(%q, %d) = %q, want %q", tc.in, tc.maxChars, got, tc.want) + } + }) + } +} diff --git a/go/internal/node/node.go b/go/internal/node/node.go new file mode 100644 index 0000000..7043489 --- /dev/null +++ b/go/internal/node/node.go @@ -0,0 +1,248 @@ +// Package node is the PR-AF wiring wave (T4.2): it constructs the shared +// *agent.Agent from the environment and registers the exact Python reasoner +// surface (design §B.1) so the Go node is a drop-in opt-in sibling of the Python +// pr-af node. +// +// node.go owns agent construction (env -> agent.Config, mirroring +// src/pr_af/app.py:26-50) plus the custom HTTP server that wraps the SDK handler +// with the /webhook/github route (app.py:365-367). register.go owns the +// per-reasoner registration (the 17 names of §B.1); webhook.go owns the GitHub +// @mention webhook handler. +package node + +import ( + "context" + "errors" + "fmt" + "log" + "net" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/agent" + "github.com/Agent-Field/agentfield/sdk/go/ai" + + "github.com/Agent-Field/pr-af/go/internal/config" + "github.com/Agent-Field/pr-af/go/internal/github" + "github.com/Agent-Field/pr-af/go/internal/orch" + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// Node bundles the constructed agent with the resolved environment config and +// the collaborators the review handler and webhook thread through. +type Node struct { + // App is the SDK agent. It satisfies orch.App (Harness/AI/Pause/Note) and + // the reasoner Deps interfaces directly, so register.go points every Deps + // field at it and Serve mounts App.Handler() as the fallback route. + App *agent.Agent + + // NodeID is the resolved node id (NODE_ID env, or the pr-af-go default). + NodeID string + + // AgentFieldServer is the control-plane base URL (AGENTFIELD_SERVER). The + // webhook fires the async review at "{AgentFieldServer}/api/v1/execute/async/ + // {NodeID}.review" and the HITL gate derives the approval webhook URL from it. + AgentFieldServer string + + // ListenAddress is the ":port" the custom server binds (":"+PORT). + ListenAddress string + + // reviewApp is the agent-capability seam the review handler feeds into + // orch.Deps.App AND emits the pipeline-failure note through. It defaults to + // App; the error-mapping tests override it with a fake that records notes. + reviewApp orch.App + + // gh is the GitHub client injected into orch.Deps.GH. Tests that stub + // runReview leave it unused (nil). + gh github.Client + + // runReview is the orchestrator-construct-and-run seam. Production builds and + // runs the real orchestrator; the error-mapping tests inject ErrBadInput / + // other failures without a live harness (design §F "seam for the orchestrator + // constructor"). + runReview func(ctx context.Context, deps orch.Deps, in schemas.ReviewInput, cfg config.ReviewConfig) (schemas.ReviewResult, error) + + // registered records every reasoner name passed through the single + // registration path, in order, so the parity test (V1) can assert the exact + // surface. tags records the tags registered per name (review -> nil; the 16 + // internal reasoners -> ["review","pr"]). + registered []string + tags map[string][]string +} + +// RegisteredNames returns a copy of the reasoner names registered on this node, +// in registration order — the functional/unit parity source of truth (V1). +func (n *Node) RegisteredNames() []string { + return append([]string(nil), n.registered...) +} + +// TagsFor returns a copy of the tags registered for name (nil when none). +func (n *Node) TagsFor(name string) []string { + return append([]string(nil), n.tags[name]...) +} + +// defaultRunReview constructs the real orchestrator and runs it. Kept as a +// package function so BuildAgent can point Node.runReview at it and tests can +// swap in a stub. +func defaultRunReview(ctx context.Context, deps orch.Deps, in schemas.ReviewInput, cfg config.ReviewConfig) (schemas.ReviewResult, error) { + return orch.New(deps, in, cfg).Run(ctx) +} + +// BuildAgent constructs the PR-AF agent from the environment exactly as the +// Python entry point does (app.py:26-50): +// +// - NODE_ID default "pr-af-go" (opt-in sibling; distinct from the +// Python "pr-af" node so both run against one control plane). +// - AGENTFIELD_SERVER default "http://localhost:8080". +// - AGENTFIELD_API_KEY -> Config.Token (control-plane bearer). +// - PORT default "8007" -> ListenAddress ":8007". +// - AGENT_CALLBACK_URL -> Config.PublicURL — the base URL the CP uses to reach +// this node; unset falls back to the SDK's http://localhost:. +// - HarnessConfig / AIConfig — the harness (opencode) + LLM credentials the +// reasoners rely on. Every reasoner calls the harness with only Cwd set, so +// the agent's default HarnessConfig Provider/Model must be present, and the +// two .ai() gates (intake/coverage) need AIConfig. Mirrors app.py's +// harness_config=/ai_config=. +// +// Divergence from Python (documented, deliberate): the Go SDK's ai.Config +// rejects an empty API key at construction, whereas Python's AIConfig accepts +// os.getenv("OPENROUTER_API_KEY","") == "". So AIConfig is attached ONLY when +// OPENROUTER_API_KEY is set — construction succeeds without a key (matching +// Python), and the AI call fails at call time either way when the key is absent. +func BuildAgent(defaultNodeID, defaultPort, description string) (*Node, error) { + nodeID := envOr("NODE_ID", defaultNodeID) + server := envOr("AGENTFIELD_SERVER", "http://localhost:8080") + token := os.Getenv("AGENTFIELD_API_KEY") + port := envOr("PORT", defaultPort) + + aiConf, err := config.AIConfigFromEnv() + if err != nil { + // Python constructs AIIntegrationConfig at module import, so a malformed + // numeric env var (e.g. PR_AF_MAX_TURNS=abc) crashes the node at boot. + return nil, err + } + + cfg := agent.Config{ + NodeID: nodeID, + Version: "0.1.0", + AgentFieldURL: server, + Token: token, + ListenAddress: ":" + port, + PublicURL: os.Getenv("AGENT_CALLBACK_URL"), + CLIConfig: &agent.CLIConfig{AppDescription: description}, + HarnessConfig: &agent.HarnessConfig{ + Provider: aiConf.Provider, + Model: aiConf.HarnessModel, + MaxTurns: aiConf.MaxTurns, + PermissionMode: "auto", + Env: aiConf.ProviderEnv(), + BinPath: aiConf.OpencodeBin, + }, + } + if apiKey := os.Getenv("OPENROUTER_API_KEY"); apiKey != "" { + // Python's .ai() path runs through LiteLLM, which CONSUMES a leading + // "openrouter/" as its routing prefix before calling the OpenRouter API. + // The Go SDK's ai client posts the model string verbatim to BaseURL, and + // OpenRouter rejects "openrouter/moonshotai/..." as an invalid model ID — + // so strip the routing prefix here to reach the same model Python does. + // The HARNESS model keeps the prefix (opencode's config wants it; the + // entrypoint derives its model key by stripping it there too). + cfg.AIConfig = &ai.Config{ + Model: aiModelForAPI(aiConf.AIModel), + APIKey: apiKey, + BaseURL: "https://openrouter.ai/api/v1", + } + } + + app, err := agent.New(cfg) + if err != nil { + return nil, fmt.Errorf("create agent %q: %w", nodeID, err) + } + + n := &Node{ + App: app, + NodeID: nodeID, + AgentFieldServer: server, + ListenAddress: ":" + port, + reviewApp: app, + gh: github.NewClient(""), // reads GH_TOKEN internally (app.py GitHubClient()) + runReview: defaultRunReview, + tags: map[string][]string{}, + } + return n, nil +} + +// Serve runs the custom HTTP server and registers with the control plane. +// +// It mounts /webhook/github on the PR-AF handler and delegates every other path +// (/health, /reasoners/, /execute, /discover, …) to the SDK's App.Handler(), +// mirroring Python's app.add_api_route("/webhook/github", …) grafted onto the +// SDK's own routes. +// +// Ordering (design §G): bind the listener BEFORE App.Initialize so the control +// plane's post-registration health check reaches a live server — the same +// startServer→Initialize order agent.Serve uses, reproduced here because the +// webhook route forces a bespoke mux instead of App.Serve. +func (n *Node) Serve(ctx context.Context) error { + mux := http.NewServeMux() + mux.HandleFunc("/webhook/github", n.webhookGitHub) + mux.Handle("/", n.App.Handler()) + + ln, err := net.Listen("tcp", n.ListenAddress) + if err != nil { + return fmt.Errorf("listen %s: %w", n.ListenAddress, err) + } + srv := &http.Server{Handler: mux} + + serveErr := make(chan error, 1) + go func() { + if serr := srv.Serve(ln); serr != nil && !errors.Is(serr, http.ErrServerClosed) { + serveErr <- serr + } + }() + + if err := n.App.Initialize(ctx); err != nil { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + return fmt.Errorf("initialize node %q: %w", n.NodeID, err) + } + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + select { + case <-ctx.Done(): + case sig := <-sigCh: + log.Printf("pr-af: received signal %s, shutting down", sig) + case err := <-serveErr: + return fmt.Errorf("webhook server: %w", err) + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return srv.Shutdown(shutdownCtx) +} + +// envOr returns the value of key, or def when the env var is unset or empty. +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +// aiModelForAPI converts the configured AI model into the model ID the +// OpenRouter API expects. Python's .ai() path runs through LiteLLM, which +// CONSUMES a leading "openrouter/" as its routing prefix before calling the +// OpenRouter API; the Go SDK's ai client posts the model string verbatim to +// BaseURL, where "openrouter/moonshotai/..." is an invalid model ID. Stripping +// the routing prefix reaches the same model Python does. The HARNESS model is +// untouched (opencode's config expects the prefixed form). +func aiModelForAPI(model string) string { + return strings.TrimPrefix(model, "openrouter/") +} diff --git a/go/internal/node/node_test.go b/go/internal/node/node_test.go new file mode 100644 index 0000000..3e65405 --- /dev/null +++ b/go/internal/node/node_test.go @@ -0,0 +1,97 @@ +package node + +import ( + "testing" +) + +// TestBuildAgentFromEnv is the main.go smoke: BuildAgent resolves node identity +// from the environment (with the pr-af-go / 8007 defaults), constructs the agent +// without a control plane or LLM key, and RegisterAll wires the full surface. +func TestBuildAgentFromEnv(t *testing.T) { + cases := []struct { + name string + env map[string]string + wantNodeID string + wantServer string + wantListen string + }{ + { + name: "defaults when env unset", + env: map[string]string{"NODE_ID": "", "PORT": "", "AGENTFIELD_SERVER": "", "OPENROUTER_API_KEY": ""}, + wantNodeID: "pr-af-go", + wantServer: "http://localhost:8080", + wantListen: ":8007", + }, + { + name: "env overrides", + env: map[string]string{ + "NODE_ID": "pr-af-go-canary", + "PORT": "9107", + "AGENTFIELD_SERVER": "http://cp.internal:8080", + "OPENROUTER_API_KEY": "", // keep AIConfig off so New needs no key + }, + wantNodeID: "pr-af-go-canary", + wantServer: "http://cp.internal:8080", + wantListen: ":9107", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + for k, v := range tc.env { + t.Setenv(k, v) + } + n, err := BuildAgent("pr-af-go", "8007", "AI-Native Pull Request Review Agent") + if err != nil { + t.Fatalf("BuildAgent: %v", err) + } + if n.App == nil { + t.Fatal("BuildAgent returned a nil App") + } + if n.NodeID != tc.wantNodeID { + t.Errorf("NodeID = %q, want %q", n.NodeID, tc.wantNodeID) + } + if n.AgentFieldServer != tc.wantServer { + t.Errorf("AgentFieldServer = %q, want %q", n.AgentFieldServer, tc.wantServer) + } + if n.ListenAddress != tc.wantListen { + t.Errorf("ListenAddress = %q, want %q", n.ListenAddress, tc.wantListen) + } + + n.RegisterAll() + if got := len(n.RegisteredNames()); got != 17 { + t.Errorf("registered %d reasoners, want 17", got) + } + }) + } +} + +// TestBuildAgentWithLLMKey proves AIConfig attaches (and agent.New still +// succeeds) when OPENROUTER_API_KEY is present — the production path. +func TestBuildAgentWithLLMKey(t *testing.T) { + t.Setenv("NODE_ID", "") + t.Setenv("PORT", "") + t.Setenv("AGENTFIELD_SERVER", "") + t.Setenv("OPENROUTER_API_KEY", "sk-or-test") + + n, err := BuildAgent("pr-af-go", "8007", "desc") + if err != nil { + t.Fatalf("BuildAgent with LLM key: %v", err) + } + if n.App == nil { + t.Fatal("nil App") + } +} + +// The .ai() path must receive the OpenRouter API model ID, with LiteLLM's +// "openrouter/" routing prefix stripped — Python consumes that prefix in +// LiteLLM, so a prefixed PR_AF_MODEL (the deploy default) must not reach the +// OpenRouter API verbatim. Unprefixed models pass through untouched. +func TestAIModelForAPIStripsOpenRouterRoutingPrefix(t *testing.T) { + if got := aiModelForAPI("openrouter/moonshotai/kimi-k2.5"); got != "moonshotai/kimi-k2.5" { + t.Errorf("prefixed: got %q, want moonshotai/kimi-k2.5", got) + } + if got := aiModelForAPI("minimax/minimax-m2.5"); got != "minimax/minimax-m2.5" { + t.Errorf("unprefixed: got %q, want unchanged", got) + } +} diff --git a/go/internal/node/register.go b/go/internal/node/register.go new file mode 100644 index 0000000..a07731a --- /dev/null +++ b/go/internal/node/register.go @@ -0,0 +1,181 @@ +package node + +// register.go wires the exact 17-reasoner surface (design §B.1) onto the agent. +// The single externally-driven reasoner is `review` (no tags, carries the §B.1 +// input schema); the other 16 are the router reasoners the orchestrator invokes +// in-process but that Python still CP-registers, each tagged ["review","pr"]. +// +// Those 16 tags are SEMANTIC domain tags, not node-identity tags — node identity +// is carried by node_id=pr-af-go, so callers reach pr-af-go.review. They are NOT +// renamed to the node id (unlike SWE-AF's -go role tags). + +import ( + "context" + "encoding/json" + "errors" + "net/http" + + "github.com/Agent-Field/agentfield/sdk/go/agent" + + "github.com/Agent-Field/pr-af/go/internal/afx" + "github.com/Agent-Field/pr-af/go/internal/config" + "github.com/Agent-Field/pr-af/go/internal/orch" + "github.com/Agent-Field/pr-af/go/internal/reasoners" + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// reviewTags is the shared domain tag set for the 16 internal reasoners. +var reviewTags = []string{"review", "pr"} + +// RegisterAll registers the full PR-AF surface: `review` (externally driven) + +// the 16 in-process router reasoners. Registration order matches §B.1 so the +// recorded slice reads as the design's canonical list. +func (n *Node) RegisterAll() { + // review — the only externally-driven reasoner. No tags; carries the §B.1 + // input schema so the CP UI card shows the real parameters. Error mapping is + // applied inside reviewHandler (ErrBadInput -> 400; else note + 500 prefix). + n.record("review", nil) + n.App.RegisterReasoner("review", n.reviewHandler, agent.WithInputSchema(reviewInputSchema)) + + // The 16 router reasoners, tagged ["review","pr"], in §B.1 order. Each is + // bound (afx.Bind) into its typed input and backed by a reasoners.Deps built + // from the agent (Harness=App, AI=App). + regReasoner(n, "intake_phase", reasoners.IntakePhase) + regReasoner(n, "anatomy_phase", reasoners.AnatomyPhase) + regReasoner(n, "planning_phase", reasoners.PlanningPhase) + regReasoner(n, "meta_semantic", reasoners.MetaSemantic) + regReasoner(n, "meta_mechanical", reasoners.MetaMechanical) + regReasoner(n, "meta_systemic", reasoners.MetaSystemic) + regReasoner(n, "review_dimension", reasoners.ReviewDimension) + regReasoner(n, "compound_finder_phase", reasoners.CompoundFinderPhase) + regReasoner(n, "post_worthiness_gate", reasoners.PostWorthinessGate) + regReasoner(n, "compound_dedup_phase", reasoners.CompoundDedupPhase) + regReasoner(n, "evidence_verifier", reasoners.EvidenceVerifier) + regReasoner(n, "adversary_phase", reasoners.AdversaryPhase) + regReasoner(n, "deepen_findings", reasoners.DeepenFindings) + regReasoner(n, "extract_obligations", reasoners.ExtractObligations) + regReasoner(n, "verify_obligation", reasoners.VerifyObligation) + regReasoner(n, "coverage_gate", reasoners.CoverageGate) +} + +// record appends name (and its tags) to the node's registration bookkeeping — +// the source of truth for the parity test. tags==nil records an empty slice so +// TagsFor("review") returns no tags. +func (n *Node) record(name string, tags []string) { + n.registered = append(n.registered, name) + n.tags[name] = append([]string(nil), tags...) +} + +// regReasoner registers one internal router reasoner under name with the +// ["review","pr"] tags. It adapts a typed reasoner func +// (func(ctx, reasoners.Deps, T) (map[string]any, error)) to the SDK HandlerFunc +// by afx.Bind-ing the request map into T. T is inferred from fn. The recorded +// tags and the registered tags come from the same reviewTags slice, so the +// parity bookkeeping cannot drift from what the CP receives. +func regReasoner[T any]( + n *Node, + name string, + fn func(context.Context, reasoners.Deps, T) (map[string]any, error), +) { + n.record(name, reviewTags) + deps := reasoners.Deps{Harness: n.App, AI: n.App} + n.App.RegisterReasoner(name, func(ctx context.Context, input map[string]any) (any, error) { + in, err := afx.Bind[T](input) + if err != nil { + return nil, err + } + return fn(ctx, deps, in) + }, agent.WithReasonerTags(reviewTags...)) +} + +// reviewHandler ports app.py review(): bind the request into a ReviewInput +// (afx.Bind runs the struct's default-seeding UnmarshalJSON), clamp +// max_review_depth to 3 at the bind layer, resolve the repo path, build the +// per-call config, then run the orchestrator through the runReview seam with the +// §B.4 error mapping. +func (n *Node) reviewHandler(ctx context.Context, input map[string]any) (any, error) { + in, err := afx.Bind[schemas.ReviewInput](input) + if err != nil { + // A malformed body is a client error (Python: pydantic validation -> 422; + // mapped here to 400 as the closest node-level bad-input signal). + return nil, &agent.ExecuteError{StatusCode: http.StatusBadRequest, Message: err.Error()} + } + + // Bind-layer clamp — min(max_review_depth, 3). Python clamps here AND again in + // ReviewConfig.FromInput (config.go); both are reproduced. + in.MaxReviewDepth = min(in.MaxReviewDepth, 3) + + // Resolve the repo path (app.py:231, called OUTSIDE the mapped try). Empty + // strings stand in for Python's None. + resolved, err := orch.ResolveRepo(ctx, strp(in.RepoPath), strp(in.PrURL)) + if err != nil { + // Python leaves a _resolve_repo ValueError uncaught, so FastAPI returns a + // generic 500 (message hidden). Go surfaces the clone/checkout message at + // 500 — structurally the same status, more debuggable. No pipeline note + // and no "review execution failed:" prefix (that path is the orchestrator's). + return nil, &agent.ExecuteError{StatusCode: http.StatusInternalServerError, Message: err.Error()} + } + if strp(in.RepoPath) == "" { + in.RepoPath = &resolved + } + + cfg, err := config.ReviewConfig{}.FromInput(in) + if err != nil { + // A malformed PR_AF_MAX_COST_USD / PR_AF_MAX_DURATION_SECONDS raises + // ValueError inside Python's review() -> HTTP 400 with the raw message. + return nil, &agent.ExecuteError{StatusCode: http.StatusBadRequest, Message: err.Error()} + } + + deps := orch.Deps{ + App: n.reviewApp, + GH: n.gh, + NodeID: n.NodeID, + AgentFieldServer: n.AgentFieldServer, + } + + result, err := n.runReview(ctx, deps, in, cfg) + if err != nil { + if errors.Is(err, orch.ErrBadInput) { + // ValueError-class -> 400 with the RAW message (badInputError.Error() + // reports only the message, so the body is byte-identical to Python's + // str(ValueError)). + return nil, &agent.ExecuteError{StatusCode: http.StatusBadRequest, Message: err.Error()} + } + // Any other failure: emit the pipeline-failure note (tags ["review","error"]) + // then 500 with the "review execution failed: " prefix — exactly app.py:243-245. + deps.App.Note(ctx, "Review pipeline failed: "+err.Error(), "review", "error") + return nil, &agent.ExecuteError{ + StatusCode: http.StatusInternalServerError, + Message: "review execution failed: " + err.Error(), + } + } + + // Python returns result.model_dump(); the ReviewResult struct marshals to the + // identical snake_case key set (no omitempty), so returning it directly yields + // the same JSON the async status callback / sync response carries. + return result, nil +} + +// strp dereferences a *string (nil -> ""). +func strp(p *string) string { + if p == nil { + return "" + } + return *p +} + +// reviewInputSchema is the §B.1 input schema for `review`, transcribed from the +// app.py review() signature (exact param names/types/defaults). additionalProperties +// stays true so the async API body remains byte-compatible with Python (extra +// keys accepted). Nullable params are typed by their non-null base type — the +// schema is a UI-display aid, and the permissive additionalProperties keeps +// binding lossless. +var reviewInputSchema = json.RawMessage(`{"type":"object","additionalProperties":true,"properties":{` + + `"pr_url":{"type":"string"},"diff_text":{"type":"string"},"repo_path":{"type":"string"},` + + `"base_ref":{"type":"string"},"head_ref":{"type":"string"},"depth":{"type":"string","default":"auto"},` + + `"max_cost_usd":{"type":"number"},"max_duration_seconds":{"type":"integer"},"focus":{"type":"string","default":"auto"},` + + `"ignore_paths":{"type":"array","items":{"type":"string"}},"hints":{"type":"array","items":{"type":"string"}},` + + `"models":{"type":"object"},"max_concurrent_reviewers":{"type":"integer"},"max_coverage_iterations":{"type":"integer"},` + + `"max_review_depth":{"type":"integer","default":2},"output_format":{"type":"string","default":"github"},` + + `"dry_run":{"type":"boolean","default":false},"post_pr_number":{"type":"integer"},` + + `"suggestion_mode":{"type":"string","default":"comment"}}}`) diff --git a/go/internal/node/register_test.go b/go/internal/node/register_test.go new file mode 100644 index 0000000..d629462 --- /dev/null +++ b/go/internal/node/register_test.go @@ -0,0 +1,273 @@ +package node + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/agent" + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/pr-af/go/internal/config" + "github.com/Agent-Field/pr-af/go/internal/orch" + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// pythonSurface is the independent parity checklist: the exact 17 reasoner names +// the Python pr-af node registers, in the design §B.1 canonical order. Written +// from the Python inventory (app.py `review` + reasoners/router), NOT derived +// from RegisterAll, so the test catches drift in either direction. +var pythonSurface = []string{ + "review", + "intake_phase", + "anatomy_phase", + "planning_phase", + "meta_semantic", + "meta_mechanical", + "meta_systemic", + "review_dimension", + "compound_finder_phase", + "post_worthiness_gate", + "compound_dedup_phase", + "evidence_verifier", + "adversary_phase", + "deepen_findings", + "extract_obligations", + "verify_obligation", + "coverage_gate", +} + +// TestRegisterAllExactSurface asserts V1: RegisterAll registers exactly the 17 +// §B.1 names, in order, with `review` untagged and the other 16 tagged +// ["review","pr"]. +func TestRegisterAllExactSurface(t *testing.T) { + n := newTestNode(t) + n.RegisterAll() + + got := n.RegisteredNames() + + // Exact ordered equality — V1 asserts the registration order too. + if !reflect.DeepEqual(got, pythonSurface) { + t.Fatalf("registered surface mismatch:\n got = %v\n want = %v", got, pythonSurface) + } + + // Duplicate guard: RegisterReasoner dedupes by name, so a duplicate in the + // recorded slice means two registrations collided on one name. + seen := map[string]int{} + for _, name := range got { + seen[name]++ + } + for name, c := range seen { + if c > 1 { + t.Errorf("reasoner %q registered %d times (collision)", name, c) + } + } + if len(got) != 17 { + t.Errorf("surface size = %d, want 17", len(got)) + } + + // Tags: review has none; every other reasoner carries exactly ["review","pr"]. + if tags := n.TagsFor("review"); len(tags) != 0 { + t.Errorf("review tags = %v, want none", tags) + } + for _, name := range pythonSurface { + if name == "review" { + continue + } + if tags := n.TagsFor(name); !reflect.DeepEqual(tags, []string{"review", "pr"}) { + t.Errorf("%s tags = %v, want [review pr]", name, tags) + } + } +} + +// TestReviewHandlerErrorMapping asserts the §B.4 error contract at the node +// layer: ErrBadInput -> 400 with the raw message and NO note; any other error -> +// note("Review pipeline failed: ", ["review","error"]) then 500 with the +// "review execution failed: " prefix; success returns the result verbatim. +func TestReviewHandlerErrorMapping(t *testing.T) { + repo := t.TempDir() // existing dir -> ResolveRepo returns it, no clone/network. + + t.Run("bad-input maps to 400 with raw message, no note", func(t *testing.T) { + fa := &fakeApp{} + n := &Node{ + NodeID: "pr-af-go", + reviewApp: fa, + runReview: func(context.Context, orch.Deps, schemas.ReviewInput, config.ReviewConfig) (schemas.ReviewResult, error) { + return schemas.ReviewResult{}, wrapBadInput("One of pr_url, diff_text, or repo_path is required") + }, + } + + _, err := n.reviewHandler(context.Background(), map[string]any{"repo_path": repo}) + exec := asExecuteError(t, err) + if exec.StatusCode != 400 { + t.Errorf("status = %d, want 400", exec.StatusCode) + } + if exec.Message != "One of pr_url, diff_text, or repo_path is required" { + t.Errorf("message = %q, want the raw ValueError message", exec.Message) + } + if len(fa.notes) != 0 { + t.Errorf("bad-input path must not emit a note, got %v", fa.notes) + } + }) + + t.Run("other error maps to 500 with prefix + pipeline note", func(t *testing.T) { + fa := &fakeApp{} + n := &Node{ + NodeID: "pr-af-go", + reviewApp: fa, + runReview: func(context.Context, orch.Deps, schemas.ReviewInput, config.ReviewConfig) (schemas.ReviewResult, error) { + return schemas.ReviewResult{}, errors.New("kaboom") + }, + } + + _, err := n.reviewHandler(context.Background(), map[string]any{"repo_path": repo}) + exec := asExecuteError(t, err) + if exec.StatusCode != 500 { + t.Errorf("status = %d, want 500", exec.StatusCode) + } + if exec.Message != "review execution failed: kaboom" { + t.Errorf("message = %q, want the prefixed message", exec.Message) + } + if len(fa.notes) != 1 { + t.Fatalf("expected exactly one pipeline-failure note, got %v", fa.notes) + } + note := fa.notes[0] + if note.msg != "Review pipeline failed: kaboom" { + t.Errorf("note message = %q, want %q", note.msg, "Review pipeline failed: kaboom") + } + if !reflect.DeepEqual(note.tags, []string{"review", "error"}) { + t.Errorf("note tags = %v, want [review error]", note.tags) + } + }) + + t.Run("success returns the result verbatim", func(t *testing.T) { + fa := &fakeApp{} + want := schemas.ReviewResult{ReviewID: "rev_abc123", PrURL: "https://example/pr/1"} + n := &Node{ + NodeID: "pr-af-go", + reviewApp: fa, + runReview: func(context.Context, orch.Deps, schemas.ReviewInput, config.ReviewConfig) (schemas.ReviewResult, error) { + return want, nil + }, + } + + out, err := n.reviewHandler(context.Background(), map[string]any{"repo_path": repo}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + got, ok := out.(schemas.ReviewResult) + if !ok { + t.Fatalf("result type = %T, want schemas.ReviewResult", out) + } + if got.ReviewID != want.ReviewID || got.PrURL != want.PrURL { + t.Errorf("result = %+v, want %+v", got, want) + } + if len(fa.notes) != 0 { + t.Errorf("success path must not emit a note, got %v", fa.notes) + } + }) +} + +// TestReviewHandlerClampsDepthAndResolvesRepo proves the bind-layer coercions +// reach the orchestrator: max_review_depth is clamped to 3 and the empty +// repo_path is filled by ResolveRepo before the pipeline runs. +func TestReviewHandlerClampsDepthAndResolvesRepo(t *testing.T) { + t.Setenv("PR_AF_REPO_PATH", t.TempDir()) // ResolveRepo fallback when repo_path is empty. + + var seenInput schemas.ReviewInput + fa := &fakeApp{} + n := &Node{ + NodeID: "pr-af-go", + reviewApp: fa, + runReview: func(_ context.Context, _ orch.Deps, in schemas.ReviewInput, _ config.ReviewConfig) (schemas.ReviewResult, error) { + seenInput = in + return schemas.ReviewResult{}, nil + }, + } + + if _, err := n.reviewHandler(context.Background(), map[string]any{ + "diff_text": "diff --git a b", + "max_review_depth": 9, + }); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if seenInput.MaxReviewDepth != 3 { + t.Errorf("max_review_depth = %d, want clamped to 3", seenInput.MaxReviewDepth) + } + if seenInput.RepoPath == nil || *seenInput.RepoPath == "" { + t.Errorf("repo_path was not resolved: %v", seenInput.RepoPath) + } +} + +// --- helpers ----------------------------------------------------------------- + +// newTestNode builds a node from a cleared env so BuildAgent is deterministic +// (no ambient OPENROUTER_API_KEY forcing AIConfig, node id/port at defaults). +func newTestNode(t *testing.T) *Node { + t.Helper() + t.Setenv("NODE_ID", "") + t.Setenv("PORT", "") + t.Setenv("AGENTFIELD_SERVER", "") + t.Setenv("AGENT_CALLBACK_URL", "") + t.Setenv("OPENROUTER_API_KEY", "") + n, err := BuildAgent("pr-af-go", "8007", "AI-Native Pull Request Review Agent") + if err != nil { + t.Fatalf("BuildAgent: %v", err) + } + return n +} + +func asExecuteError(t *testing.T, err error) *agent.ExecuteError { + t.Helper() + if err == nil { + t.Fatal("expected an error, got nil") + } + var exec *agent.ExecuteError + if !errors.As(err, &exec) { + t.Fatalf("error is not *agent.ExecuteError: %T (%v)", err, err) + } + return exec +} + +// wrapBadInput builds an error that errors.Is(err, orch.ErrBadInput) while +// reporting only the raw message — the shape orch's badInputError produces, so +// the node's 400 body is byte-identical to Python's str(ValueError). +type badInputWrap struct{ msg string } + +func (e badInputWrap) Error() string { return e.msg } +func (e badInputWrap) Unwrap() error { return orch.ErrBadInput } + +func wrapBadInput(msg string) error { return badInputWrap{msg: msg} } + +// fakeApp is a minimal orch.App used by the error-mapping tests. Only Note is +// exercised (runReview is stubbed); Harness/AI/Pause satisfy the interface. +type fakeApp struct { + notes []recordedNote +} + +type recordedNote struct { + msg string + tags []string +} + +func (f *fakeApp) Harness(context.Context, string, map[string]any, any, harness.Options) (*harness.Result, error) { + return nil, nil +} + +func (f *fakeApp) AI(context.Context, string, ...ai.Option) (*ai.Response, error) { + return nil, nil +} + +func (f *fakeApp) Pause(context.Context, agent.PauseOptions) (*agent.ApprovalResult, error) { + return nil, nil +} + +func (f *fakeApp) Note(_ context.Context, message string, tags ...string) { + f.notes = append(f.notes, recordedNote{msg: message, tags: append([]string(nil), tags...)}) +} + +// compile-time proof the fake satisfies the orchestrator surface. +var _ orch.App = (*fakeApp)(nil) diff --git a/go/internal/node/webhook.go b/go/internal/node/webhook.go new file mode 100644 index 0000000..8de1080 --- /dev/null +++ b/go/internal/node/webhook.go @@ -0,0 +1,218 @@ +package node + +// webhook.go ports the GitHub @mention webhook (app.py:250-367): an +// issue_comment listener that fires an async PR review at the control plane when +// someone comments "@pr-af …" on a PR. +// +// Env reads happen at REQUEST time (matching internal/config's call-time +// convention) so the httptest table can drive GITHUB_WEBHOOK_SECRET / +// PR_AF_BOT_MENTION with t.Setenv. Python reads these at import; the observable +// behavior is identical for a fixed environment. + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "os" + "strings" + "time" +) + +const defaultBotMention = "@pr-af" + +// webhookGitHub handles POST /webhook/github. It mirrors app.py::webhook_github: +// verify the HMAC signature, answer ping with pong, ignore anything that is not +// a created issue_comment carrying the bot mention on a PR, then fire the async +// review and echo the execution id. +func (n *Node) webhookGitHub(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "cannot read body"}) + return + } + defer r.Body.Close() + + secret := os.Getenv("GITHUB_WEBHOOK_SECRET") + sig := r.Header.Get("X-Hub-Signature-256") + if secret != "" && !verifySignature(body, sig, secret) { + writeJSON(w, http.StatusUnauthorized, map[string]any{"detail": "Invalid signature"}) + return + } + + event := r.Header.Get("X-GitHub-Event") + if event == "ping" { + writeJSON(w, http.StatusOK, map[string]any{"status": "pong"}) + return + } + if event != "issue_comment" { + writeJSON(w, http.StatusOK, map[string]any{"status": "ignored", "reason": "event=" + event}) + return + } + + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "invalid JSON"}) + return + } + + action := getStr(payload, "action") + if action != "created" { + writeJSON(w, http.StatusOK, map[string]any{"status": "ignored", "reason": "action=" + action}) + return + } + + botMention := envOr("PR_AF_BOT_MENTION", defaultBotMention) + commentBody := nestedStr(payload, "comment", "body") + if !strings.Contains(strings.ToLower(commentBody), strings.ToLower(botMention)) { + writeJSON(w, http.StatusOK, map[string]any{"status": "ignored", "reason": "no bot mention"}) + return + } + + // Only respond to comments on PRs (issue_comment fires for plain issues too). + prURL := getPRURLFromIssue(payload) + if prURL == "" { + writeJSON(w, http.StatusOK, map[string]any{"status": "ignored", "reason": "not a PR comment"}) + return + } + + hints := extractHintsFromComment(commentBody, botMention) + execID := n.fireReview(r.Context(), prURL, hints) + + // Python returns execution_id=None (JSON null) when the fire fails and the id + // string on success; execID is nil / string here to preserve that. + writeJSON(w, http.StatusOK, map[string]any{ + "status": "review_dispatched", + "pr_url": prURL, + "execution_id": execID, + }) +} + +// verifySignature ports app.py::_verify_signature. An empty secret means "no +// secret configured — skip verification" (the caller already guards that). +func verifySignature(payload []byte, signature, secret string) bool { + if secret == "" { + return true + } + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(payload) + expected := "sha256=" + hex.EncodeToString(mac.Sum(nil)) + return hmac.Equal([]byte(expected), []byte(signature)) +} + +// fireReview ports app.py::_fire_review: POST {CP}/api/v1/execute/async/ +// {node_id}.review with body {"input": {pr_url, depth:"standard", dry_run:false +// [, hints]}}. Returns the execution id string on success, or nil (JSON null) on +// any failure — a fire-and-forget dispatch, so failures never block the webhook. +func (n *Node) fireReview(ctx context.Context, prURL string, hints []string) any { + inputPayload := map[string]any{ + "pr_url": prURL, + "depth": "standard", + "dry_run": false, + } + if len(hints) > 0 { + inputPayload["hints"] = hints + } + body, err := json.Marshal(map[string]any{"input": inputPayload}) + if err != nil { + return nil + } + + target := strings.TrimSuffix(n.AgentFieldServer, "/") + "/api/v1/execute/async/" + n.NodeID + ".review" + reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, target, strings.NewReader(string(body))) + if err != nil { + return nil + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil + } + defer resp.Body.Close() + data, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { // Python: resp.raise_for_status() + return nil + } + + var parsed map[string]any + if err := json.Unmarshal(data, &parsed); err != nil { + return nil + } + if id, ok := parsed["execution_id"].(string); ok { + return id + } + return nil +} + +// extractHintsFromComment ports app.py::_extract_hints_from_comment: the text +// after the (case-insensitive) bot mention, trimmed; [] when there is none. +func extractHintsFromComment(commentBody, botMention string) []string { + mention := strings.ToLower(botMention) + lower := strings.ToLower(commentBody) + idx := strings.Index(lower, mention) + if idx < 0 { + return nil + } + after := strings.TrimSpace(commentBody[idx+len(botMention):]) + if after != "" { + return []string{after} + } + return nil +} + +// getPRURLFromIssue ports app.py::_get_pr_url_from_issue: payload.issue. +// pull_request.html_url, or "" when absent (issue_comment on a non-PR issue). +func getPRURLFromIssue(payload map[string]any) string { + return nestedStr(payload, "issue", "pull_request", "html_url") +} + +// getStr returns payload[key] as a string, or "" when absent / non-string. +func getStr(m map[string]any, key string) string { + if v, ok := m[key].(string); ok { + return v + } + return "" +} + +// nestedStr walks a chain of object keys and returns the terminal string value, +// or "" if any hop is missing or not an object/string (Python's chained +// dict.get({}).get(...) with a string leaf). +func nestedStr(m map[string]any, keys ...string) string { + cur := m + for i, k := range keys { + v, ok := cur[k] + if !ok { + return "" + } + if i == len(keys)-1 { + if s, ok := v.(string); ok { + return s + } + return "" + } + next, ok := v.(map[string]any) + if !ok { + return "" + } + cur = next + } + return "" +} + +// writeJSON writes a JSON response with the given status (the SDK's writeJSON is +// unexported, so the webhook has its own). +func writeJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if payload == nil { + return + } + _ = json.NewEncoder(w).Encode(payload) +} diff --git a/go/internal/node/webhook_test.go b/go/internal/node/webhook_test.go new file mode 100644 index 0000000..c661803 --- /dev/null +++ b/go/internal/node/webhook_test.go @@ -0,0 +1,224 @@ +package node + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// fakeCP records the async-execute request the webhook fires and returns a +// canned execution id, standing in for the control plane. +type fakeCP struct { + server *httptest.Server + gotPath string + gotBody map[string]any + hitCount int +} + +func newFakeCP(t *testing.T) *fakeCP { + t.Helper() + cp := &fakeCP{} + cp.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cp.hitCount++ + cp.gotPath = r.URL.Path + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &cp.gotBody) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"execution_id":"exec_abc123"}`)) + })) + t.Cleanup(cp.server.Close) + return cp +} + +func sign(secret string, body []byte) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + return "sha256=" + hex.EncodeToString(mac.Sum(nil)) +} + +// prComment builds an issue_comment webhook payload for a PR with the given +// comment body. htmlURL "" produces a non-PR issue (no pull_request block). +func prComment(action, commentBody, htmlURL string) []byte { + issue := map[string]any{"number": 42} + if htmlURL != "" { + issue["pull_request"] = map[string]any{"html_url": htmlURL} + } + payload := map[string]any{ + "action": action, + "comment": map[string]any{"body": commentBody}, + "issue": issue, + "repository": map[string]any{"full_name": "octo/repo"}, + } + b, _ := json.Marshal(payload) + return b +} + +// doWebhook drives n.webhookGitHub with the given event/signature and returns +// the recorded response plus the decoded JSON body. +func doWebhook(t *testing.T, n *Node, event, signature string, body []byte) (*httptest.ResponseRecorder, map[string]any) { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/webhook/github", strings.NewReader(string(body))) + if event != "" { + req.Header.Set("X-GitHub-Event", event) + } + if signature != "" { + req.Header.Set("X-Hub-Signature-256", signature) + } + rec := httptest.NewRecorder() + n.webhookGitHub(rec, req) + + var decoded map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &decoded) + return rec, decoded +} + +func TestWebhookPing(t *testing.T) { + t.Setenv("GITHUB_WEBHOOK_SECRET", "") + n := &Node{NodeID: "pr-af-go"} + rec, body := doWebhook(t, n, "ping", "", []byte(`{}`)) + if rec.Code != 200 { + t.Fatalf("status = %d, want 200", rec.Code) + } + if body["status"] != "pong" { + t.Errorf("body = %v, want status=pong", body) + } +} + +func TestWebhookSignature(t *testing.T) { + const secret = "s3cr3t" + t.Setenv("GITHUB_WEBHOOK_SECRET", secret) + n := &Node{NodeID: "pr-af-go"} + body := prComment("created", "@pr-af review", "https://github.com/octo/repo/pull/42") + + t.Run("valid signature passes verification (ping through)", func(t *testing.T) { + // A valid signature on a ping still returns pong — verification succeeded. + rec, resp := doWebhook(t, n, "ping", sign(secret, []byte(`{}`)), []byte(`{}`)) + if rec.Code != 200 || resp["status"] != "pong" { + t.Errorf("valid signature rejected: code=%d body=%v", rec.Code, resp) + } + }) + + t.Run("invalid signature -> 401", func(t *testing.T) { + rec, _ := doWebhook(t, n, "issue_comment", "sha256=deadbeef", body) + if rec.Code != 401 { + t.Errorf("status = %d, want 401", rec.Code) + } + }) + + t.Run("missing signature -> 401", func(t *testing.T) { + rec, _ := doWebhook(t, n, "issue_comment", "", body) + if rec.Code != 401 { + t.Errorf("status = %d, want 401", rec.Code) + } + }) +} + +func TestWebhookIgnoreGates(t *testing.T) { + t.Setenv("GITHUB_WEBHOOK_SECRET", "") + n := &Node{NodeID: "pr-af-go"} + prURL := "https://github.com/octo/repo/pull/42" + + cases := []struct { + name string + event string + body []byte + wantReason string + }{ + {"non-issue_comment event", "push", []byte(`{}`), "event=push"}, + {"action not created", "issue_comment", prComment("edited", "@pr-af", prURL), "action=edited"}, + {"no bot mention", "issue_comment", prComment("created", "looks good to me", prURL), "no bot mention"}, + {"comment on a non-PR issue", "issue_comment", prComment("created", "@pr-af", ""), "not a PR comment"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec, resp := doWebhook(t, n, tc.event, "", tc.body) + if rec.Code != 200 { + t.Fatalf("status = %d, want 200", rec.Code) + } + if resp["status"] != "ignored" { + t.Errorf("status = %v, want ignored", resp["status"]) + } + if resp["reason"] != tc.wantReason { + t.Errorf("reason = %q, want %q", resp["reason"], tc.wantReason) + } + }) + } +} + +func TestWebhookFiresAsyncReview(t *testing.T) { + t.Setenv("GITHUB_WEBHOOK_SECRET", "") + cp := newFakeCP(t) + n := &Node{NodeID: "pr-af-go", AgentFieldServer: cp.server.URL} + prURL := "https://github.com/octo/repo/pull/42" + + rec, resp := doWebhook(t, n, "issue_comment", "", + prComment("created", "@pr-af please focus on error handling", prURL)) + + if rec.Code != 200 { + t.Fatalf("status = %d, want 200", rec.Code) + } + if resp["status"] != "review_dispatched" { + t.Errorf("status = %v, want review_dispatched", resp["status"]) + } + if resp["pr_url"] != prURL { + t.Errorf("pr_url = %v, want %q", resp["pr_url"], prURL) + } + if resp["execution_id"] != "exec_abc123" { + t.Errorf("execution_id = %v, want exec_abc123", resp["execution_id"]) + } + + // The CP received exactly one POST to the node-qualified async endpoint. + if cp.hitCount != 1 { + t.Fatalf("CP hit %d times, want 1", cp.hitCount) + } + if cp.gotPath != "/api/v1/execute/async/pr-af-go.review" { + t.Errorf("fire path = %q, want /api/v1/execute/async/pr-af-go.review", cp.gotPath) + } + input, ok := cp.gotBody["input"].(map[string]any) + if !ok { + t.Fatalf("fire body missing input: %v", cp.gotBody) + } + if input["pr_url"] != prURL { + t.Errorf("fired pr_url = %v, want %q", input["pr_url"], prURL) + } + if input["depth"] != "standard" { + t.Errorf("fired depth = %v, want standard", input["depth"]) + } + if input["dry_run"] != false { + t.Errorf("fired dry_run = %v, want false", input["dry_run"]) + } + hints, ok := input["hints"].([]any) + if !ok || len(hints) != 1 || hints[0] != "please focus on error handling" { + t.Errorf("fired hints = %v, want [please focus on error handling]", input["hints"]) + } +} + +func TestWebhookBotMentionOverride(t *testing.T) { + t.Setenv("GITHUB_WEBHOOK_SECRET", "") + t.Setenv("PR_AF_BOT_MENTION", "@reviewbot") + cp := newFakeCP(t) + n := &Node{NodeID: "pr-af-go", AgentFieldServer: cp.server.URL} + prURL := "https://github.com/octo/repo/pull/7" + + // The default "@pr-af" no longer triggers; the configured "@reviewbot" does. + rec, resp := doWebhook(t, n, "issue_comment", "", + prComment("created", "@pr-af nope", prURL)) + if resp["status"] != "ignored" || resp["reason"] != "no bot mention" { + t.Errorf("@pr-af should be ignored under override: code=%d body=%v", rec.Code, resp) + } + + rec, resp = doWebhook(t, n, "issue_comment", "", + prComment("created", "@reviewbot go", prURL)) + if resp["status"] != "review_dispatched" { + t.Errorf("@reviewbot should dispatch: code=%d body=%v", rec.Code, resp) + } + if cp.hitCount != 1 { + t.Errorf("CP hit %d times, want 1 (only @reviewbot fires)", cp.hitCount) + } +} diff --git a/go/internal/orch/budget_test.go b/go/internal/orch/budget_test.go new file mode 100644 index 0000000..d2e73cb --- /dev/null +++ b/go/internal/orch/budget_test.go @@ -0,0 +1,81 @@ +package orch + +// V7 (orch half) + error taxonomy (item 6): the cost counter stays inert (reasoner +// returns never carry cost_usd), only the wall-clock gate trips, and the +// no-source guard surfaces ErrBadInput with the verbatim message the node maps to +// HTTP 400. + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/Agent-Field/pr-af/go/internal/config" + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +func TestCostStaysInert(t *testing.T) { + o := New(Deps{App: &fakeApp{}}, schemas.ReviewInput{}, config.DefaultReviewConfig()) + // A realistic reasoner return: no cost_usd key anywhere. + o.registerCost("intake", map[string]any{"pr_type": "feature", "findings": []any{}}) + o.registerCost("review", nil) + if got := o.totalCost(); got != 0.0 { + t.Errorf("total cost = %v, want 0.0 (inert)", got) + } + // The mechanism still works when a cost IS present (defensive coverage). + o.registerCost("intake", map[string]any{"cost_usd": 0.25}) + if got := o.totalCost(); got != 0.25 { + t.Errorf("total cost after explicit cost_usd = %v, want 0.25", got) + } +} + +func TestWallClockBudgetTrips(t *testing.T) { + // Production builds config via FromInput, which resolves the effective + // duration cap to 300s (ResolveBudgetCaps default) — not the 1800s raw + // BudgetConfig default that DefaultReviewConfig() carries. + cfg, cfgErr := config.ReviewConfig{}.FromInput(schemas.ReviewInput{}) + if cfgErr != nil { + t.Fatalf("FromInput: %v", cfgErr) + } + o := New(Deps{App: &fakeApp{}}, schemas.ReviewInput{}, cfg) + o.clock = func() time.Duration { return 10 * time.Second } + if o.budgetOrTimeoutExhausted("review") { + t.Error("should not be exhausted at 10s") + } + if o.isBudgetExhausted() { + t.Error("budgetExhausted flag set prematurely") + } + o.clock = func() time.Duration { return 400 * time.Second } + if !o.budgetOrTimeoutExhausted("intake") { + t.Error("should be exhausted past the 300s wall-clock cap") + } + if !o.isBudgetExhausted() { + t.Error("budgetExhausted flag not set after timeout") + } +} + +func TestPhaseCapNeverTripsWithZeroCost(t *testing.T) { + o := New(Deps{App: &fakeApp{}}, schemas.ReviewInput{}, config.DefaultReviewConfig()) + o.clock = func() time.Duration { return 0 } + // Every positive-cap phase: spent (0) < cap → false. + for _, phase := range []string{"intake", "anatomy", "meta_selectors", "review", "adversary", "cross_ref", "coverage"} { + if o.budgetOrTimeoutExhausted(phase) { + t.Errorf("phase %q tripped with zero cost", phase) + } + } +} + +func TestRunNoSourceIsBadInput(t *testing.T) { + o := New(Deps{App: &fakeApp{}}, schemas.ReviewInput{Depth: "auto"}, config.DefaultReviewConfig()) + _, err := o.Run(context.Background()) + if err == nil { + t.Fatal("expected an error for a review with no source") + } + if !errors.Is(err, ErrBadInput) { + t.Errorf("error should wrap ErrBadInput (→ HTTP 400), got %v", err) + } + if err.Error() != "One of pr_url, diff_text, or repo_path is required" { + t.Errorf("error message = %q, want the verbatim ValueError string", err.Error()) + } +} diff --git a/go/internal/orch/compound.go b/go/internal/orch/compound.go new file mode 100644 index 0000000..a7c148a --- /dev/null +++ b/go/internal/orch/compound.go @@ -0,0 +1,419 @@ +package orch + +// compound.go ports the Phase 5.5 compound (cross-reference) analysis from +// orchestrator.py: _run_compound_analysis, _dedup_compound_findings, +// _select_compound_clusters, _extract_compound_findings. +// +// Determinism note: Python's _select_compound_clusters derives some candidate +// groups from Python `set` iteration (import tokens, caller-snippet keys), whose +// order is non-deterministic per run — so the `order` counter (and thus the +// tie-break among equal-priority import/caller candidates) is already +// non-deterministic in Python. The Go port iterates those set-derived groups in +// sorted order for a STABLE result; file/tag/dir groups preserve finding order +// exactly as Python does. + +import ( + "context" + "regexp" + "sort" + "strings" + "sync" + + "github.com/Agent-Field/pr-af/go/internal/evidence" + "github.com/Agent-Field/pr-af/go/internal/reasoners" + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// runCompoundAnalysis ports _run_compound_analysis. Uses return_exceptions=True +// semantics (WaitGroup, per-goroutine error captured and skipped, siblings not +// cancelled) with order-preserving pre-indexed results. +func (o *Orchestrator) runCompoundAnalysis( + ctx context.Context, + confirmedFindings []schemas.ReviewFinding, + evidenceMap map[string]evidence.EvidencePackage, +) ([]schemas.ReviewFinding, error) { + clusters := selectCompoundClusters(confirmedFindings, evidenceMap, o.config.Budget.MaxCrossRefDeepDives) + if len(clusters) == 0 || o.budgetOrTimeoutExhausted("cross_ref") { + return nil, nil + } + + type compoundOut struct { + raw map[string]any + err bool + } + results := make([]compoundOut, len(clusters)) + var wg sync.WaitGroup + for i := range clusters { + i := i + wg.Add(1) + go func() { + defer wg.Done() + cluster := clusters[i] + clusterEvidence := map[string]map[string]any{} + if len(evidenceMap) > 0 { + for _, f := range cluster { + if pkg, ok := evidenceMap[f.Title]; ok { + clusterEvidence[f.Title] = evidencePackToMap(pkg) + } + } + } + var evArg map[string]map[string]any + if len(clusterEvidence) > 0 { + evArg = clusterEvidence + } + raw, err := o.rfns.compoundFinder(ctx, o.reasonerDeps(), reasoners.CompoundFinderInput{ + ClusterFindings: cluster, + RepoPath: strp(o.input.RepoPath), + EvidenceMap: evArg, + }) + if err != nil { + results[i] = compoundOut{err: true} + return + } + results[i] = compoundOut{raw: raw} + }() + } + wg.Wait() + + var compoundFindings []schemas.ReviewFinding + for _, r := range results { + if r.err { + continue + } + o.incInvocations(1) + o.registerCost("cross_ref", r.raw) + compoundFindings = append(compoundFindings, extractCompoundFindings(r.raw)...) + } + + if len(compoundFindings) > 1 { + deduped, err := o.dedupCompoundFindings(ctx, compoundFindings, confirmedFindings) + if err != nil { + return nil, err + } + compoundFindings = deduped + } + + o.mu.Lock() + o.crossRefCount += len(compoundFindings) + o.mu.Unlock() + return compoundFindings, nil +} + +// dedupCompoundFindings ports _dedup_compound_findings. +func (o *Orchestrator) dedupCompoundFindings( + ctx context.Context, + compoundFindings, individualFindings []schemas.ReviewFinding, +) ([]schemas.ReviewFinding, error) { + limit := len(individualFindings) + if limit > 20 { + limit = 20 + } + summaryLines := make([]string, 0, limit) + for _, f := range individualFindings[:limit] { + summaryLines = append(summaryLines, "- ["+string(f.Severity)+"] "+f.Title+" ("+f.FilePath+")") + } + individualSummary := strings.Join(summaryLines, "\n") + + raw, err := o.rfns.compoundDedup(ctx, o.reasonerDeps(), reasoners.CompoundDedupInput{ + CompoundFindings: compoundFindings, + IndividualFindingsSummary: individualSummary, + }) + if err != nil { + return nil, err + } + o.incInvocations(1) + o.registerCost("cross_ref", raw) + + payload := unwrap(raw) + var keepIndices []int + if payload != nil { + keepIndices = getIntSlice(payload["keep_indices"]) + } + if len(keepIndices) == 0 { + return compoundFindings, nil + } + var deduped []schemas.ReviewFinding + for _, i := range keepIndices { + if i >= 0 && i < len(compoundFindings) { + deduped = append(deduped, compoundFindings[i]) + } + } + if len(deduped) == 0 { + return compoundFindings, nil + } + return deduped, nil +} + +// extractCompoundFindings ports _extract_compound_findings. +func extractCompoundFindings(resultRaw map[string]any) []schemas.ReviewFinding { + payload := unwrap(resultRaw) + var raw []map[string]any + if payload != nil { + if lst := asObjListStrict(payload, "findings"); lst != nil { + raw = lst + } else if lst := asObjListStrict(payload, "results"); lst != nil { + raw = lst + } + } + out := make([]schemas.ReviewFinding, 0, len(raw)) + for _, item := range raw { + out = append(out, mapToReviewFinding(item, map[string]any{ + "dimension_id": "compound", + "dimension_name": "Compound Analysis", + "title": "Untitled compound finding", + "severity": "important", + "line_end_fallback": "line_start", + })) + } + return out +} + +var importTokenRe = regexp.MustCompile(`[a-z0-9_./]+`) + +// selectCompoundClusters ports _select_compound_clusters. +func selectCompoundClusters( + findings []schemas.ReviewFinding, + evidenceMap map[string]evidence.EvidencePackage, + maxClusters int, +) [][]schemas.ReviewFinding { + if len(findings) < 2 { + return nil + } + + byTitle := map[string]schemas.ReviewFinding{} + var titleOrder []string + for _, f := range findings { + if _, ok := byTitle[f.Title]; !ok { + titleOrder = append(titleOrder, f.Title) + } + byTitle[f.Title] = f + } + + type candidate struct { + priority int + order int + cluster []schemas.ReviewFinding + } + var candidates []candidate + seenSignatures := map[string]struct{}{} + order := 0 + + addCandidate := func(priority int, cluster []schemas.ReviewFinding) { + // Dedup by title (last value wins, first position kept). + uniq := map[string]schemas.ReviewFinding{} + var uniqOrder []string + for _, f := range cluster { + if _, ok := uniq[f.Title]; !ok { + uniqOrder = append(uniqOrder, f.Title) + } + uniq[f.Title] = f + } + normalized := make([]schemas.ReviewFinding, 0, len(uniqOrder)) + for _, t := range uniqOrder { + normalized = append(normalized, uniq[t]) + } + if len(normalized) < 2 { + return + } + sort.SliceStable(normalized, func(i, j int) bool { return normalized[i].Title < normalized[j].Title }) + if len(normalized) > 4 { + normalized = normalized[:4] + } + sig := make([]string, len(normalized)) + for i, f := range normalized { + sig[i] = f.Title + } + sort.Strings(sig) + sigStr := strings.Join(sig, "\x00") + if _, ok := seenSignatures[sigStr]; ok { + return + } + seenSignatures[sigStr] = struct{}{} + candidates = append(candidates, candidate{priority: priority, order: order, cluster: normalized}) + order++ + } + + // file_groups (priority 0) — grouped by file_path, first-seen order. + fileGroups := newOrderedGroups() + for _, f := range findings { + if f.FilePath != "" { + fileGroups.add(f.FilePath, f) + } + } + for _, key := range fileGroups.keys { + if g := fileGroups.m[key]; len(g) >= 2 { + addCandidate(0, g) + } + } + + // import_groups (priority 1) — token → titles; sorted-token order (stable). + importTokens := func(title string) []string { + pkg, ok := evidenceMap[title] + if !ok { + return nil + } + text := strings.ToLower(pkg.ImportContext) + set := map[string]struct{}{} + for _, tok := range importTokenRe.FindAllString(text, -1) { + if len(tok) > 2 { + set[tok] = struct{}{} + } + } + out := make([]string, 0, len(set)) + for t := range set { + out = append(out, t) + } + sort.Strings(out) + return out + } + importGroups := map[string]map[string]struct{}{} + for _, title := range titleOrder { + for _, token := range importTokens(title) { + if importGroups[token] == nil { + importGroups[token] = map[string]struct{}{} + } + importGroups[token][title] = struct{}{} + } + } + for _, token := range sortedKeys(importGroups) { + titles := importGroups[token] + if len(titles) >= 2 { + addCandidate(1, findingsForTitles(sortedSet(titles), byTitle)) + } + } + + // caller_groups (priority 3) — snippet key → titles; sorted-key order. + callerGroups := map[string]map[string]struct{}{} + for _, title := range titleOrder { + pkg, ok := evidenceMap[title] + if !ok { + continue + } + f := byTitle[title] + limit := len(pkg.CallerSnippets) + if limit > 8 { + limit = 8 + } + for _, snippet := range pkg.CallerSnippets[:limit] { + key := strings.ToLower(strings.TrimSpace(snippet)) + key = truncateRunes(key, 180) + if key == "" { + continue + } + if callerGroups[key] == nil { + callerGroups[key] = map[string]struct{}{} + } + callerGroups[key][f.Title] = struct{}{} + } + } + for _, key := range sortedKeys(callerGroups) { + titles := callerGroups[key] + if len(titles) >= 2 { + addCandidate(3, findingsForTitles(sortedSet(titles), byTitle)) + } + } + + // tag_groups (priority 2) — key tags, first-seen finding order. + keyTags := map[string]struct{}{"security": {}, "auth": {}, "validation": {}, "error-handling": {}} + tagGroups := newOrderedGroups() + for _, f := range findings { + for _, tag := range f.Tags { + lowered := strings.ToLower(tag) + if _, ok := keyTags[lowered]; ok { + tagGroups.add(lowered, f) + } + } + } + for _, key := range tagGroups.keys { + if g := tagGroups.m[key]; len(g) >= 2 { + addCandidate(2, g) + } + } + + // dir_groups (priority 4) — dirname, first-seen order. + dirGroups := newOrderedGroups() + for _, f := range findings { + if f.FilePath != "" { + if dir := pyDirname(f.FilePath); dir != "" { + dirGroups.add(dir, f) + } + } + } + for _, key := range dirGroups.keys { + if g := dirGroups.m[key]; len(g) >= 2 { + addCandidate(4, g) + } + } + + sort.SliceStable(candidates, func(i, j int) bool { + if candidates[i].priority != candidates[j].priority { + return candidates[i].priority < candidates[j].priority + } + return candidates[i].order < candidates[j].order + }) + + out := make([][]schemas.ReviewFinding, 0, maxClusters) + for i, c := range candidates { + if i >= maxClusters { + break + } + out = append(out, c.cluster) + } + return out +} + +// orderedGroups is an insertion-ordered multimap (Python dict.setdefault(...) +// .append(...) over .values() in insertion order). +type orderedGroups struct { + keys []string + m map[string][]schemas.ReviewFinding +} + +func newOrderedGroups() *orderedGroups { + return &orderedGroups{m: map[string][]schemas.ReviewFinding{}} +} + +func (g *orderedGroups) add(key string, f schemas.ReviewFinding) { + if _, ok := g.m[key]; !ok { + g.keys = append(g.keys, key) + } + g.m[key] = append(g.m[key], f) +} + +func findingsForTitles(titles []string, byTitle map[string]schemas.ReviewFinding) []schemas.ReviewFinding { + out := make([]schemas.ReviewFinding, 0, len(titles)) + for _, t := range titles { + if f, ok := byTitle[t]; ok { + out = append(out, f) + } + } + return out +} + +func sortedKeys(m map[string]map[string]struct{}) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func sortedSet(s map[string]struct{}) []string { + out := make([]string, 0, len(s)) + for k := range s { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// pyDirname ports posixpath.dirname. +func pyDirname(p string) string { + i := strings.LastIndex(p, "/") + 1 + head := p[:i] + if head != "" && strings.Trim(head, "/") != "" { + head = strings.TrimRight(head, "/") + } + return head +} diff --git a/go/internal/orch/helpers.go b/go/internal/orch/helpers.go new file mode 100644 index 0000000..da9b2e3 --- /dev/null +++ b/go/internal/orch/helpers.go @@ -0,0 +1,398 @@ +package orch + +import ( + "encoding/json" + "strconv" + "strings" + + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// badInputError wraps ErrBadInput (for errors.Is classification → HTTP 400) but +// reports ONLY the raw message via Error(), so the node's `{"error": }` +// body is byte-identical to Python's str(ValueError) — not "bad input: ". +type badInputError struct{ msg string } + +func (e *badInputError) Error() string { return e.msg } +func (e *badInputError) Unwrap() error { return ErrBadInput } + +func badInput(msg string) error { return &badInputError{msg: msg} } + +// budgetError wraps errBudgetExhausted (a non-ValueError → HTTP 500) while +// reporting the raw message, matching str(BudgetExhaustedError). +type budgetError struct{ msg string } + +func (e *budgetError) Error() string { return e.msg } +func (e *budgetError) Unwrap() error { return errBudgetExhausted } + +func budgetExhaustedErr(msg string) error { return &budgetError{msg: msg} } + +// itoa is strconv.Itoa, aliased for terse positional-id formatting. +func itoa(i int) string { return strconv.Itoa(i) } + +// structToMap JSON-round-trips a struct into a decoded map — the Go analogue of +// model_dump() when a map (not a struct) is required (metadata dicts, evidence +// packages passed to reasoners). +func structToMap(v any) (map[string]any, error) { + b, err := json.Marshal(v) + if err != nil { + return nil, err + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + return nil, err + } + return m, nil +} + +// asObjListStrict returns the objects at m[key] when that value IS a JSON array +// (mirroring Python's isinstance(payload.get(key), list) branch selection): a +// non-array yields nil, an array yields a non-nil slice with only its object +// elements (non-dicts dropped, exactly as the Python loops `continue` on them). +func asObjListStrict(m map[string]any, key string) []map[string]any { + v, ok := m[key] + if !ok { + return nil + } + arr, ok := v.([]any) + if !ok { + return nil + } + out := make([]map[string]any, 0, len(arr)) + for _, e := range arr { + if mp, ok := e.(map[string]any); ok { + out = append(out, mp) + } + } + return out +} + +// truthy mirrors Python bool() over decoded-JSON types. +func truthy(v any) bool { + switch t := v.(type) { + case nil: + return false + case bool: + return t + case float64: + return t != 0 + case string: + return t != "" + case []any: + return len(t) > 0 + case map[string]any: + return len(t) > 0 + default: + return false + } +} + +// getBoolDefault mirrors bool(m.get(key, def)): absent → def, present → truthy. +func getBoolDefault(m map[string]any, key string, def bool) bool { + v, ok := m[key] + if !ok { + return def + } + return truthy(v) +} + +// mapGet mirrors m.get(key, def): the raw value if present, else def. +func mapGet(m map[string]any, key string, def any) any { + if v, ok := m[key]; ok { + return v + } + return def +} + +// severityOr mirrors v.get("severity", def), returning the raw value so the +// downstream Severity.UnmarshalJSON normalizes it. +func severityOr(m map[string]any, def string) any { + if v, ok := m["severity"]; ok { + return v + } + return def +} + +// strp dereferences a *string (nil → ""). +func strp(p *string) string { + if p == nil { + return "" + } + return *p +} + +// countSplitlines counts lines the way Python str.splitlines() does: a trailing +// newline does NOT yield a trailing empty element. Used by _resolve_depth's +// diff.splitlines() line count. +func countSplitlines(s string) int { + if s == "" { + return 0 + } + // splitlines splits on \n, \r, \r\n and drops a single trailing separator. + normalized := strings.ReplaceAll(s, "\r\n", "\n") + normalized = strings.ReplaceAll(normalized, "\r", "\n") + n := strings.Count(normalized, "\n") + if !strings.HasSuffix(normalized, "\n") { + n++ + } + return n +} + +// unwrap ports orchestrator._unwrap: prefer result["output"], then +// result["result"], each only when it is itself a JSON object; else the map +// itself. Ported reasoners never wrap, so this is identity in practice. +func unwrap(result map[string]any) map[string]any { + if result == nil { + return nil + } + if v, ok := result["output"]; ok { + if m, ok := v.(map[string]any); ok { + return m + } + } + if v, ok := result["result"]; ok { + if m, ok := v.(map[string]any); ok { + return m + } + } + return result +} + +// asFloat coerces a JSON scalar to float64 (int/float only, mirroring Python's +// isinstance(cost, (int, float))). Booleans are excluded (Python's isinstance +// against (int,float) matches bool, but cost_usd is never a bool and JSON +// decodes numbers to float64 anyway). +func asFloat(v any) (float64, bool) { + switch t := v.(type) { + case float64: + return t, true + case int: + return float64(t), true + case json.Number: + f, err := t.Float64() + return f, err == nil + default: + return 0, false + } +} + +// getStr mirrors Python dict.get(key, default) restricted to strings. +func getStr(m map[string]any, key, def string) string { + if v, ok := m[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + return def +} + +// getIntOrZero mirrors `int(m.get(key, 0) or 0)`: a falsy value (missing, nil, 0, +// "", false) yields 0; otherwise the value is coerced to int. +func getIntOrZero(m map[string]any, key string) int { + v, ok := m[key] + if !ok { + return 0 + } + switch t := v.(type) { + case nil: + return 0 + case float64: + return int(t) + case int: + return t + case string: + if t == "" { + return 0 + } + if n, err := strconv.Atoi(t); err == nil { + return n + } + if f, err := strconv.ParseFloat(t, 64); err == nil { + return int(f) + } + return 0 + case bool: + if t { + return 1 + } + return 0 + default: + return 0 + } +} + +// getFloatOr mirrors `float(m.get(key, def) or def)`: a falsy value yields def. +func getFloatOr(m map[string]any, key string, def float64) float64 { + v, ok := m[key] + if !ok { + return def + } + switch t := v.(type) { + case nil: + return def + case float64: + if t == 0 { + return def + } + return t + case int: + if t == 0 { + return def + } + return float64(t) + case string: + if t == "" { + return def + } + if f, err := strconv.ParseFloat(t, 64); err == nil { + return f + } + return def + default: + return def + } +} + +// getStrSlice extracts a []string from m[key], dropping non-string elements +// (mirrors item.get("tags", []) feeding a list[str] pydantic field). +func getStrSlice(m map[string]any, key string) []string { + v, ok := m[key] + if !ok { + return []string{} + } + arr, ok := v.([]any) + if !ok { + return []string{} + } + out := make([]string, 0, len(arr)) + for _, e := range arr { + if s, ok := e.(string); ok { + out = append(out, s) + } + } + return out +} + +// asObjList extracts the []map[string]any at m[key] (skipping non-objects), the +// Go analogue of a Python `payload.get(key)` that isinstance-checks list-of-dict. +func asObjList(v any) []map[string]any { + arr, ok := v.([]any) + if !ok { + return nil + } + out := make([]map[string]any, 0, len(arr)) + for _, e := range arr { + if m, ok := e.(map[string]any); ok { + out = append(out, m) + } + } + return out +} + +// getIntSlice extracts a []int from a JSON array of numbers (keep_indices). +func getIntSlice(v any) []int { + arr, ok := v.([]any) + if !ok { + return nil + } + out := make([]int, 0, len(arr)) + for _, e := range arr { + switch t := e.(type) { + case float64: + out = append(out, int(t)) + case int: + out = append(out, t) + } + } + return out +} + +// mapToReviewFinding ports the model_validate(normalized) step in +// _extract_findings / _extract_compound_findings: it applies the per-key +// defaults, then JSON-round-trips into a ReviewFinding so the struct's +// UnmarshalJSON seeding and Severity normalization run exactly as pydantic does. +func mapToReviewFinding(item map[string]any, defaults map[string]any) schemas.ReviewFinding { + normalized := map[string]any{ + "dimension_id": getStr(item, "dimension_id", strDefault(defaults, "dimension_id")), + "dimension_name": getStr(item, "dimension_name", strDefault(defaults, "dimension_name")), + "file_path": getStr(item, "file_path", ""), + "line_start": getIntOrZero(item, "line_start"), + "line_end": lineEnd(item, defaults), + "hunk_context": getStr(item, "hunk_context", ""), + "severity": severityDefault(item, defaults), + "title": getStr(item, "title", strDefault(defaults, "title")), + "body": getStr(item, "body", ""), + "suggestion": item["suggestion"], + "evidence": getStr(item, "evidence", ""), + "confidence": getFloatOr(item, "confidence", 0.5), + "tags": getStrSlice(item, "tags"), + } + b, _ := json.Marshal(normalized) + var f schemas.ReviewFinding + _ = json.Unmarshal(b, &f) + return f +} + +func strDefault(defaults map[string]any, key string) string { + if defaults == nil { + return "" + } + if s, ok := defaults[key].(string); ok { + return s + } + return "" +} + +// lineEnd handles the compound path's `int(item.get("line_end", item.get( +// "line_start", 0)) or 0)`: default line_end falls back to line_start. +func lineEnd(item map[string]any, defaults map[string]any) int { + if _, present := item["line_end"]; present { + return getIntOrZero(item, "line_end") + } + if defaults != nil { + if v, ok := defaults["line_end_fallback"]; ok && v == "line_start" { + return getIntOrZero(item, "line_start") + } + } + return getIntOrZero(item, "line_end") +} + +// mapToStruct JSON-round-trips a decoded map into a typed struct, running the +// struct's UnmarshalJSON default-seeding — the Go analogue of +// SomeModel.model_validate(m). +func mapToStruct[T any](m map[string]any) T { + var v T + b, _ := json.Marshal(m) + _ = json.Unmarshal(b, &v) + return v +} + +// truncateRunes returns the first n code points of s (Python's s[:n]). +func truncateRunes(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) +} + +// stringSet builds a membership set from a slice. +func stringSet(xs []string) map[string]struct{} { + s := make(map[string]struct{}, len(xs)) + for _, x := range xs { + s[x] = struct{}{} + } + return s +} + +func severityDefault(item map[string]any, defaults map[string]any) any { + if v, ok := item["severity"]; ok { + return v + } + if defaults != nil { + if s, ok := defaults["severity"].(string); ok { + return s + } + } + return "suggestion" +} diff --git a/go/internal/orch/hitl_test.go b/go/internal/orch/hitl_test.go new file mode 100644 index 0000000..86af434 --- /dev/null +++ b/go/internal/orch/hitl_test.go @@ -0,0 +1,226 @@ +package orch + +// V9: Orchestrator HITL control flow with the heavy phases stubbed via seams +// (mirrors tests/test_orchestrator_hitl.py). HITL off posts directly (gate never +// consulted); approve-subset posts only the selected findings; rerun threads +// feedback then posts; zero findings skips the gate and posts nothing; reject +// posts nothing; a rerun past the revision cap posts nothing. + +import ( + "context" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/agent" + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/pr-af/go/internal/config" + "github.com/Agent-Field/pr-af/go/internal/hitl" + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// fakeApp records notes; the harness/AI/pause surfaces panic because the stubbed +// phases must never reach a live model in these control-flow tests. +type fakeApp struct{ notes []string } + +func (f *fakeApp) Note(_ context.Context, message string, _ ...string) { + f.notes = append(f.notes, message) +} +func (f *fakeApp) Harness(context.Context, string, map[string]any, any, harness.Options) (*harness.Result, error) { + panic("Harness not expected in HITL control-flow test") +} +func (f *fakeApp) AI(context.Context, string, ...ai.Option) (*ai.Response, error) { + panic("AI not expected in HITL control-flow test") +} +func (f *fakeApp) Pause(context.Context, agent.PauseOptions) (*agent.ApprovalResult, error) { + panic("Pause not expected in HITL control-flow test") +} + +type capturedOutput struct { + findings []schemas.ScoredFinding + post bool +} + +func scoredFinding(id string) schemas.ScoredFinding { + return schemas.ScoredFinding{ + ID: id, + DimensionID: "d1", + DimensionName: "dim", + FilePath: "src/foo.py", + LineStart: 10, + LineEnd: 10, + DiffSide: "RIGHT", + Severity: "important", + Title: "title-" + id, + Body: "body", + Confidence: 0.5, + } +} + +func strPtr(s string) *string { return &s } + +func makeHITLOrchestrator( + findings []schemas.ScoredFinding, + hitlOn bool, + decisions []hitl.ReviewDecision, +) (*Orchestrator, *capturedOutput, *[]string, *int) { + app := &fakeApp{} + in := schemas.ReviewInput{PrURL: strPtr("https://github.com/o/r/pull/1"), Depth: "auto"} + o := New(Deps{App: app}, in, config.DefaultReviewConfig()) + + cap := &capturedOutput{} + feedbacks := &[]string{} + callCount := 0 + + o.runIntakeFn = func(context.Context) (schemas.IntakeResult, error) { + o.prData = &schemas.GitHubPRData{Owner: "o", Repo: "r", Number: 1, Title: "t"} + return schemas.IntakeResult{PrSummary: "adds caching", PrType: "feature", Complexity: "low"}, nil + } + o.runAnatomyFn = func(context.Context, schemas.IntakeResult) (schemas.AnatomyResult, error) { + return schemas.AnatomyResult{}, nil + } + o.resolveDepthFn = func(schemas.IntakeResult) string { return "standard" } + o.runReviewPhasesFn = func(_ context.Context, _ schemas.IntakeResult, _ schemas.AnatomyResult, _, feedback string) (schemas.ReviewPlan, []schemas.ScoredFinding, error) { + *feedbacks = append(*feedbacks, feedback) + return schemas.ReviewPlan{}, append([]schemas.ScoredFinding(nil), findings...), nil + } + o.generateOutputFn = func(_ context.Context, scored []schemas.ScoredFinding, _ schemas.IntakeResult, _ schemas.AnatomyResult, _ schemas.ReviewPlan, post bool) (schemas.ReviewResult, error) { + cap.findings = append([]schemas.ScoredFinding(nil), scored...) + cap.post = post + return schemas.ReviewResult{Summary: schemas.ReviewSummary{TotalFindings: len(scored)}}, nil + } + o.cleanupFn = func() {} + o.approvalWebhookFn = func() *string { return nil } + if hitlOn { + o.buildHaxClientFn = func() *hitl.HaxClient { return &hitl.HaxClient{} } + } else { + o.buildHaxClientFn = func() *hitl.HaxClient { return nil } + } + o.requestApprovalFn = func(context.Context, hitl.RequestReviewApprovalArgs) hitl.ReviewDecision { + d := decisions[callCount] + callCount++ + return d + } + return o, cap, feedbacks, &callCount +} + +func idSetOf(findings []schemas.ScoredFinding) map[string]struct{} { + s := map[string]struct{}{} + for _, f := range findings { + s[f.ID] = struct{}{} + } + return s +} + +func TestHITLOffPostsDirectly(t *testing.T) { + findings := []schemas.ScoredFinding{scoredFinding("f1"), scoredFinding("f2")} + o, cap, _, calls := makeHITLOrchestrator(findings, false, nil) + if _, err := o.Run(context.Background()); err != nil { + t.Fatal(err) + } + if !cap.post { + t.Error("expected post=true") + } + got := idSetOf(cap.findings) + if len(got) != 2 || !contains(got, "f1") || !contains(got, "f2") { + t.Errorf("posted findings = %v, want {f1,f2}", got) + } + if *calls != 0 { + t.Errorf("gate consulted %d times, want 0", *calls) + } +} + +func TestApproveSubsetPostsOnlySelected(t *testing.T) { + findings := []schemas.ScoredFinding{scoredFinding("f1"), scoredFinding("f2"), scoredFinding("f3")} + decision := hitl.ReviewDecision{Action: hitl.ActionPost, SelectedFindingIDs: map[string]struct{}{"f1": {}, "f3": {}}} + o, cap, _, _ := makeHITLOrchestrator(findings, true, []hitl.ReviewDecision{decision}) + if _, err := o.Run(context.Background()); err != nil { + t.Fatal(err) + } + if !cap.post { + t.Error("expected post=true") + } + got := idSetOf(cap.findings) + if len(got) != 2 || !contains(got, "f1") || !contains(got, "f3") { + t.Errorf("posted findings = %v, want {f1,f3}", got) + } +} + +func TestRerunThenPostThreadsFeedback(t *testing.T) { + findings := []schemas.ScoredFinding{scoredFinding("f1")} + decisions := []hitl.ReviewDecision{ + {Action: hitl.ActionRerun, Instructions: "too aggressive, tone it down"}, + {Action: hitl.ActionPost, SelectedFindingIDs: map[string]struct{}{"f1": {}}}, + } + o, cap, feedbacks, calls := makeHITLOrchestrator(findings, true, decisions) + if _, err := o.Run(context.Background()); err != nil { + t.Fatal(err) + } + if *calls != 2 { + t.Errorf("asked %d times, want 2", *calls) + } + if len(*feedbacks) != 2 || (*feedbacks)[0] != "" { + t.Errorf("feedbacks = %v, want first empty", *feedbacks) + } + if !contains2((*feedbacks)[1], "tone it down") { + t.Errorf("second feedback = %q, want to contain 'tone it down'", (*feedbacks)[1]) + } + if !cap.post { + t.Error("expected post=true") + } +} + +func TestHITLZeroFindingsSkipsGate(t *testing.T) { + o, cap, _, calls := makeHITLOrchestrator(nil, true, nil) + if _, err := o.Run(context.Background()); err != nil { + t.Fatal(err) + } + if cap.post { + t.Error("expected post=false for zero findings") + } + if *calls != 0 { + t.Errorf("gate consulted %d times, want 0", *calls) + } +} + +func TestRejectPostsNothing(t *testing.T) { + findings := []schemas.ScoredFinding{scoredFinding("f1")} + decision := hitl.ReviewDecision{Action: hitl.ActionReject, DecisionRaw: "rejected"} + o, cap, _, _ := makeHITLOrchestrator(findings, true, []hitl.ReviewDecision{decision}) + if _, err := o.Run(context.Background()); err != nil { + t.Fatal(err) + } + if cap.post { + t.Error("expected post=false for reject") + } +} + +func TestRerunPastCapPostsNothing(t *testing.T) { + findings := []schemas.ScoredFinding{scoredFinding("f1")} + decisions := make([]hitl.ReviewDecision, 5) + for i := range decisions { + decisions[i] = hitl.ReviewDecision{Action: hitl.ActionRerun, Instructions: "again"} + } + o, cap, _, calls := makeHITLOrchestrator(findings, true, decisions) + o.config.HITL.MaxReviewRevisions = 2 // 3 prompts (iters 0,1,2) then no post + if _, err := o.Run(context.Background()); err != nil { + t.Fatal(err) + } + if cap.post { + t.Error("expected post=false past the revision cap") + } + if *calls != 3 { + t.Errorf("asked %d times, want 3", *calls) + } +} + +func contains2(s, sub string) bool { return len(s) >= len(sub) && indexOf(s, sub) >= 0 } + +func indexOf(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} diff --git a/go/internal/orch/orchestrator.go b/go/internal/orch/orchestrator.go new file mode 100644 index 0000000..d90a5f2 --- /dev/null +++ b/go/internal/orch/orchestrator.go @@ -0,0 +1,578 @@ +// Package orch is the PR-AF review orchestrator — the Go port of +// src/pr_af/orchestrator.py. It coordinates the multi-phase review pipeline: +// intake → anatomy → meta-selectors → review+layer (streaming) → coverage ‖ +// consistency → synthesis → merge-gate → output. It owns the HITL revision loop, +// the streaming producer/consumer channel, the order-preserving fan-outs, the +// (inert) wall-clock budget gate, and the byte-exact Markdown output builders. +// +// Concurrency parity: Python's asyncio is cooperative single-threaded, so its +// shared orchestrator state needs no locking. Go runs the fan-outs on real +// goroutines, so every field mutated from a parallel closure (agentInvocations, +// totalCostUSD, costBreakdown, the adversary/cross-ref counters, budgetExhausted) +// is guarded by o.mu. +package orch + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/agent" + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/pr-af/go/internal/config" + "github.com/Agent-Field/pr-af/go/internal/github" + "github.com/Agent-Field/pr-af/go/internal/hitl" + "github.com/Agent-Field/pr-af/go/internal/prompts" + "github.com/Agent-Field/pr-af/go/internal/reasoners" + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// ErrBadInput is the sentinel wrapping every ValueError-class failure Python's +// review() maps to HTTP 400: the "One of pr_url, diff_text, or repo_path is +// required" guard and the _compute_repo_diff failure. The node layer (T4.2) +// maps errors.Is(err, ErrBadInput) to 400 and everything else to 500 with the +// "review execution failed: " prefix. +var ErrBadInput = errors.New("bad input") + +// errBudgetExhausted mirrors Python's BudgetExhaustedError (a RuntimeError). It +// is NOT a ValueError, so it maps to 500 — not ErrBadInput. +var errBudgetExhausted = errors.New("budget exhausted") + +// errPRDataNotInitialized mirrors Python's RuntimeError("PR data not +// initialized") — a 500-class internal invariant failure. +var errPRDataNotInitialized = errors.New("PR data not initialized") + +// App is the agent capability surface the orchestrator depends on. The live +// *agent.Agent satisfies it, and it is the single seam every sub-package's +// narrower interface is fed from: harnessx.HarnessCaller (Harness), +// reasoners.AICaller / gates.AICaller (AI), hitl.Pauser (Pause), hitl.App (Note). +type App interface { + Harness(ctx context.Context, prompt string, schema map[string]any, dest any, opts harness.Options) (*harness.Result, error) + AI(ctx context.Context, prompt string, opts ...ai.Option) (*ai.Response, error) + Pause(ctx context.Context, opts agent.PauseOptions) (*agent.ApprovalResult, error) + Note(ctx context.Context, message string, tags ...string) +} + +// Compile-time proof the live agent satisfies the surface. +var _ App = (*agent.Agent)(nil) + +// Deps carries the injected capabilities. Divergence from design §C.6: the hax +// client is NOT a Deps field — Python builds it inside run() via +// build_hax_client_from_env (gated on pr_url && !dry_run), so the orchestrator +// mirrors that through the buildHaxClient seam. GH is the GitHub client +// interface (Python constructs GitHubClient() inline; Go injects it so tests can +// stub fetch/post). +type Deps struct { + App App + GH github.Client + NodeID string + AgentFieldServer string +} + +// phaseOrder ports ReviewOrchestrator.PHASE_ORDER — the cost-breakdown / +// phases_completed key list. +var phaseOrder = []string{ + "intake", "anatomy", "meta_selectors", "review", + "adversary", "cross_ref", "coverage", "synthesis", "output", +} + +// Meta-selector configuration (schemas/pipeline.py MetaSelectorConfig — not +// ported to Go config, so bound here). +var enabledLenses = []string{"semantic", "mechanical", "systemic"} + +const ( + adversaryBatchSize = 5 + maxAdversaryBatch = 4 +) + +// Orchestrator holds one review's state and seams. New wires the default +// (production) seams; tests override individual fields to stub phases. +type Orchestrator struct { + deps Deps + input schemas.ReviewInput + config config.ReviewConfig + + reviewID string + startedAt time.Time + + mu sync.Mutex // guards the counters below (mutated from fan-out goroutines) + totalCostUSD float64 + costBreakdown map[string]float64 + agentInvocations int + budgetExhausted bool + + // Single-threaded-written state (set before/after fan-outs, read after joins). + prData *schemas.GitHubPRData + intakeResult *schemas.IntakeResult + anatomyResult *schemas.AnatomyResult + metaSelectorResults []schemas.MetaDimensionResult + coverageIterations int + crossRefCount int + adversaryConfirmedCount int + adversaryChallengedCount int + effectiveDepth string + + patchesCache []prompts.StrPair + patchesCacheSet bool + + // clock is time.Since(startedAt) — indirected so budget tests can drive it. + clock func() time.Duration + + // layerBatchHook, when set, is called with each batch the streaming layer + // consumer receives — a test seam to prove the layer consumes as reviewers + // complete (streaming), not after all of them finish (batching). nil in prod. + layerBatchHook func([]schemas.ReviewFinding) + + // Control-flow seams (default to bound methods; V9 tests override). + runIntakeFn func(ctx context.Context) (schemas.IntakeResult, error) + runAnatomyFn func(ctx context.Context, intake schemas.IntakeResult) (schemas.AnatomyResult, error) + resolveDepthFn func(intake schemas.IntakeResult) string + runReviewPhasesFn func(ctx context.Context, intake schemas.IntakeResult, anatomy schemas.AnatomyResult, depth, feedback string) (schemas.ReviewPlan, []schemas.ScoredFinding, error) + generateOutputFn func(ctx context.Context, scored []schemas.ScoredFinding, intake schemas.IntakeResult, anatomy schemas.AnatomyResult, plan schemas.ReviewPlan, post bool) (schemas.ReviewResult, error) + cleanupFn func() + buildHaxClientFn func() *hitl.HaxClient + approvalWebhookFn func() *string + requestApprovalFn func(ctx context.Context, args hitl.RequestReviewApprovalArgs) hitl.ReviewDecision + + // Reasoner-call seams (default to reasoners.*; streaming/order tests override). + rfns reasonerSeams +} + +// reasonerSeams bundles the reasoner entry points the pipeline invokes so tests +// can inject latency/instrumentation without a live harness. +type reasonerSeams struct { + intake func(ctx context.Context, deps reasoners.Deps, in reasoners.IntakeInput) (map[string]any, error) + anatomy func(ctx context.Context, deps reasoners.Deps, in reasoners.AnatomyInput) (map[string]any, error) + metaSemantic func(ctx context.Context, deps reasoners.Deps, in reasoners.MetaInput) (map[string]any, error) + metaMechanical func(ctx context.Context, deps reasoners.Deps, in reasoners.MetaInput) (map[string]any, error) + metaSystemic func(ctx context.Context, deps reasoners.Deps, in reasoners.MetaInput) (map[string]any, error) + reviewDim func(ctx context.Context, deps reasoners.Deps, in reasoners.ReviewDimensionInput) (map[string]any, error) + postWorthiness func(ctx context.Context, deps reasoners.Deps, in reasoners.PostWorthinessInput) (map[string]any, error) + evidenceVerify func(ctx context.Context, deps reasoners.Deps, in reasoners.EvidenceVerifierInput) (map[string]any, error) + adversary func(ctx context.Context, deps reasoners.Deps, in reasoners.AdversaryInput) (map[string]any, error) + compoundFinder func(ctx context.Context, deps reasoners.Deps, in reasoners.CompoundFinderInput) (map[string]any, error) + compoundDedup func(ctx context.Context, deps reasoners.Deps, in reasoners.CompoundDedupInput) (map[string]any, error) + coverageGate func(ctx context.Context, deps reasoners.Deps, in reasoners.CoverageGateInput) (map[string]any, error) + extractOblig func(ctx context.Context, deps reasoners.Deps, in reasoners.ExtractObligationsInput) (map[string]any, error) + verifyOblig func(ctx context.Context, deps reasoners.Deps, in reasoners.VerifyObligationInput) (map[string]any, error) +} + +func defaultReasonerSeams() reasonerSeams { + return reasonerSeams{ + intake: reasoners.IntakePhase, + anatomy: reasoners.AnatomyPhase, + metaSemantic: reasoners.MetaSemantic, + metaMechanical: reasoners.MetaMechanical, + metaSystemic: reasoners.MetaSystemic, + reviewDim: reasoners.ReviewDimension, + postWorthiness: reasoners.PostWorthinessGate, + evidenceVerify: reasoners.EvidenceVerifier, + adversary: reasoners.AdversaryPhase, + compoundFinder: reasoners.CompoundFinderPhase, + compoundDedup: reasoners.CompoundDedupPhase, + coverageGate: reasoners.CoverageGate, + extractOblig: reasoners.ExtractObligations, + verifyOblig: reasoners.VerifyObligation, + } +} + +// New constructs an Orchestrator with production seams. startedAt is set now so +// the wall-clock budget gate measures from construction (parity with Python's +// time.monotonic() in __init__). +func New(d Deps, in schemas.ReviewInput, cfg config.ReviewConfig) *Orchestrator { + o := &Orchestrator{ + deps: d, + input: in, + config: cfg, + reviewID: "rev_" + hex12(), + startedAt: time.Now(), + costBreakdown: make(map[string]float64, len(phaseOrder)), + effectiveDepth: "standard", + rfns: defaultReasonerSeams(), + } + for _, p := range phaseOrder { + o.costBreakdown[p] = 0.0 + } + o.clock = func() time.Duration { return time.Since(o.startedAt) } + + o.runIntakeFn = o.runIntake + o.runAnatomyFn = o.runAnatomy + o.resolveDepthFn = o.resolveDepth + o.runReviewPhasesFn = o.runReviewPhases + o.generateOutputFn = o.generateOutput + o.cleanupFn = o.cleanupContextDir + o.buildHaxClientFn = hitl.BuildHaxClientFromEnv + o.approvalWebhookFn = func() *string { return hitl.ApprovalWebhookURL(d.AgentFieldServer) } + o.requestApprovalFn = hitl.RequestReviewApproval + return o +} + +// reasonerDeps builds the reasoner capability bundle from the single App seam. +func (o *Orchestrator) reasonerDeps() reasoners.Deps { + return reasoners.Deps{Harness: o.deps.App, AI: o.deps.App} +} + +// hex12 ports uuid4().hex[:12] — 6 random bytes rendered as 12 lowercase hex. +func hex12() string { + var b [6]byte + _, _ = rand.Read(b[:]) + return hex.EncodeToString(b[:]) +} + +// Run executes the pipeline with the HITL revision loop (orchestrator.py run()). +// ErrBadInput-wrapped errors map to 400 at the node; anything else to 500. +func (o *Orchestrator) Run(ctx context.Context) (schemas.ReviewResult, error) { + var zero schemas.ReviewResult + + intake, err := o.runIntakeFn(ctx) + if err != nil { + return zero, err + } + o.intakeResult = &intake + reviewDepth := o.resolveDepthFn(intake) + + anatomy, err := o.runAnatomyFn(ctx, intake) + if err != nil { + return zero, err + } + o.anatomyResult = &anatomy + + // HITL gate active only when there is a real PR to post to and we are not in + // dry-run — otherwise hax is nil and we post directly, exactly as before. + var haxClient *hitl.HaxClient + if strp(o.input.PrURL) != "" && !o.input.DryRun { + haxClient = o.buildHaxClientFn() + } + maxRevisions := 0 + if haxClient != nil { + maxRevisions = o.config.HITL.MaxReviewRevisions + } + revisionHistory := []string{} + reviewerFeedback := "" + + for revisionIter := 0; revisionIter <= maxRevisions; revisionIter++ { + plan, scored, err := o.runReviewPhasesFn(ctx, intake, anatomy, reviewDepth, reviewerFeedback) + if err != nil { + return zero, err + } + + if haxClient == nil { + return o.finish(ctx, scored, intake, anatomy, plan, true) + } + + // Nothing to triage: don't bother a human, and don't auto-post an empty + // "approved" review to a public repo. Complete silently. + if len(scored) == 0 { + o.deps.App.Note(ctx, "hitl: no findings — skipping review gate, posting nothing", + "hitl", "no-post", "no-findings") + return o.finish(ctx, scored, intake, anatomy, plan, false) + } + + decision := o.requestApprovalFn(ctx, hitl.RequestReviewApprovalArgs{ + App: o.deps.App, + Pauser: o.deps.App, + HaxClient: haxClient, + PRIntent: intake.PrSummary, + Findings: scored, + PRLabel: o.prLabel(), + WebhookURL: o.approvalWebhookFn(), + UserID: o.config.HITL.ApprovalUserID, + ExpiresInHours: o.config.HITL.ApprovalExpiresInHours, + PRMeta: o.prMeta(), + RevisionIter: revisionIter, + RevisionHistory: revisionHistory, + Metadata: o.hitlMetadata(ctx), + }) + + if decision.IsPost() { + approved := make([]schemas.ScoredFinding, 0, len(scored)) + for _, f := range scored { + if _, ok := decision.SelectedFindingIDs[f.ID]; ok { + approved = append(approved, f) + } + } + return o.finish(ctx, approved, intake, anatomy, plan, true) + } + + if decision.IsRerun() && revisionIter < maxRevisions { + revisionHistory = append(revisionHistory, decision.Instructions) + reviewerFeedback = o.mergeFeedback(revisionHistory) + continue + } + + // reject, expire/error, or a re-review past the revision cap → no post. + reason := decision.Action + if decision.IsRerun() { + reason = "revision cap reached" + } + detail := strings.TrimSpace(decision.Instructions) + detailSuffix := "" + if detail != "" { + detailSuffix = ": " + detail + } + o.deps.App.Note(ctx, + fmt.Sprintf("hitl: not posting review (%s; raw=%s)%s", reason, decision.DecisionRaw, detailSuffix), + "hitl", "no-post", decision.Action) + return o.finish(ctx, scored, intake, anatomy, plan, false) + } + + return zero, errors.New("review loop exited without producing a result") +} + +// finish generates output (optionally posting) and cleans up the context dir. +func (o *Orchestrator) finish( + ctx context.Context, + scored []schemas.ScoredFinding, + intake schemas.IntakeResult, + anatomy schemas.AnatomyResult, + plan schemas.ReviewPlan, + post bool, +) (schemas.ReviewResult, error) { + result, err := o.generateOutputFn(ctx, scored, intake, anatomy, plan, post) + if err != nil { + return schemas.ReviewResult{}, err + } + o.cleanupFn() + return result, nil +} + +// ---- HITL helper blocks (camelCase surfaces, design §B.5) ---- + +func (o *Orchestrator) prLabel() string { + pr := o.prData + if pr != nil && pr.Owner != "" && pr.Repo != "" { + return fmt.Sprintf("%s/%s#%d", pr.Owner, pr.Repo, pr.Number) + } + return "" +} + +// prMeta ports _pr_meta — the camelCase PR block for the hax template. +func (o *Orchestrator) prMeta() map[string]any { + pr := o.prData + if pr == nil { + return map[string]any{} + } + url := strp(o.input.PrURL) + if url == "" && pr.Owner != "" && pr.Repo != "" && pr.Number != 0 { + url = fmt.Sprintf("https://github.com/%s/%s/pull/%d", pr.Owner, pr.Repo, pr.Number) + } + repo := "" + if pr.Owner != "" && pr.Repo != "" { + repo = pr.Owner + "/" + pr.Repo + } else { + repo = pr.Repo + } + meta := map[string]any{ + "title": pr.Title, + "number": prNumberOrNil(pr.Number), + "url": url, + "repo": repo, + "author": pr.Author, + } + if len(pr.ChangedFiles) > 0 { + additions, deletions := 0, 0 + for _, f := range pr.ChangedFiles { + additions += f.Additions + deletions += f.Deletions + } + meta["filesChangedCount"] = len(pr.ChangedFiles) + meta["additionsCount"] = additions + meta["deletionsCount"] = deletions + } + return meta +} + +// prNumberOrNil mirrors `pr.number or None`: 0 → nil (JSON null). +func prNumberOrNil(n int) any { + if n == 0 { + return nil + } + return n +} + +// mergeFeedback ports _merge_feedback — collapse accumulated instructions with +// " | ", trimming empties. +func (o *Orchestrator) mergeFeedback(revisionHistory []string) string { + items := make([]string, 0, len(revisionHistory)) + for _, instr := range revisionHistory { + t := strings.TrimSpace(instr) + if t != "" { + items = append(items, t) + } + } + return strings.Join(items, " | ") +} + +// hitlMetadata ports _hitl_metadata (camelCase). executionId comes from the +// SDK's exported execution-context accessor — an empty string when the ctx +// carries no execution (matching Python's fallback when app.ctx is None). +func (o *Orchestrator) hitlMetadata(ctx context.Context) map[string]any { + return map[string]any{ + "prLabel": o.prLabel(), + "prUrl": strp(o.input.PrURL), + "reviewId": o.reviewID, + "executionId": agent.ExecutionContextFrom(ctx).ExecutionID, + } +} + +// ---- budget / cost (inert cost, live wall-clock) ---- + +// budgetOrTimeoutExhausted ports _budget_or_timeout_exhausted. Cost stays 0 +// (reasoner returns never carry cost_usd), so only the wall-clock check trips. +func (o *Orchestrator) budgetOrTimeoutExhausted(phase string) bool { + elapsed := o.clock().Seconds() + o.mu.Lock() + defer o.mu.Unlock() + if elapsed > float64(o.config.Budget.MaxDurationSeconds) { + o.budgetExhausted = true + return true + } + if o.totalCostUSD >= o.config.Budget.MaxCostUSD { + o.budgetExhausted = true + return true + } + phaseSpent := o.costBreakdown[phase] + phaseCap, ok := o.config.Budget.PhaseBudgets[phase] + if !ok { + return false // absent phase → cap is +inf → never trips + } + return phaseSpent >= phaseCap +} + +// registerCost ports _register_cost∘_extract_cost. extractCost reads "cost_usd" +// off the reasoner return (always absent), so total stays 0.0. +func (o *Orchestrator) registerCost(phase string, resultRaw map[string]any) { + cost, ok := extractCost(resultRaw) + if !ok { + return + } + o.mu.Lock() + o.totalCostUSD += cost + o.costBreakdown[phase] += cost + o.mu.Unlock() +} + +func (o *Orchestrator) incInvocations(n int) { + o.mu.Lock() + o.agentInvocations += n + o.mu.Unlock() +} + +func (o *Orchestrator) invocations() int { + o.mu.Lock() + defer o.mu.Unlock() + return o.agentInvocations +} + +func (o *Orchestrator) isBudgetExhausted() bool { + o.mu.Lock() + defer o.mu.Unlock() + return o.budgetExhausted +} + +func (o *Orchestrator) totalCost() float64 { + o.mu.Lock() + defer o.mu.Unlock() + return o.totalCostUSD +} + +// extractCost ports _extract_cost: reads cost_usd off the raw map or its +// unwrapped payload. Always returns (0, false) in practice. +func extractCost(resultRaw map[string]any) (float64, bool) { + if resultRaw == nil { + return 0, false + } + if c, ok := asFloat(resultRaw["cost_usd"]); ok { + return c, true + } + payload := unwrap(resultRaw) + if payload != nil { + if c, ok := asFloat(payload["cost_usd"]); ok { + return c, true + } + } + return 0, false +} + +// resolveDepth ports _resolve_depth. +func (o *Orchestrator) resolveDepth(intake schemas.IntakeResult) string { + if o.input.Depth != "auto" { + return o.input.Depth + } + if _, ok := config.DepthProfiles[intake.ReviewDepth]; ok { + return intake.ReviewDepth + } + if o.prData != nil && o.prData.Diff != "" { + lineCount := len(strings.Split(o.prData.Diff, "\n")) + // Python splitlines() does not count a trailing newline as an extra line; + // mirror it. + lineCount = countSplitlines(o.prData.Diff) + // Under the smallest threshold → that threshold's depth. + if len(config.AutoDepthThresholds) > 0 { + minTh := config.AutoDepthThresholds[0] + for _, th := range config.AutoDepthThresholds { + if th.Lines < minTh.Lines { + minTh = th + } + } + if lineCount < minTh.Lines { + return minTh.Depth + } + // Ascending scan: first threshold the count is under. + for _, th := range config.AutoDepthThresholds { + if lineCount < th.Lines { + return th.Depth + } + } + } + return "deep" + } + return "standard" +} + +// escalateDepth ports _escalate_depth. +func (o *Orchestrator) escalateDepth(currentDepth string) string { + if currentDepth == "deep" { + return "deep" + } + signals := 0 + if o.anatomyResult != nil { + if len(o.anatomyResult.BlastRadius) > 10 { + signals++ + } + if len(o.anatomyResult.IntentGaps) > 0 { + signals++ + } + if len(o.anatomyResult.RiskSurfaces) > 3 { + signals++ + } + if o.anatomyResult.Stats.TotalAdditions > 500 { + signals++ + } + } + if len(o.metaSelectorResults) > 0 { + low := 0 + for _, m := range o.metaSelectorResults { + if m.Confidence < 0.5 { + low++ + } + } + if low >= 2 { + signals++ + } + } + if signals >= 2 && currentDepth == "quick" { + return "standard" + } + if signals >= 3 && currentDepth == "standard" { + return "deep" + } + return currentDepth +} diff --git a/go/internal/orch/output.go b/go/internal/orch/output.go new file mode 100644 index 0000000..b9f3440 --- /dev/null +++ b/go/internal/orch/output.go @@ -0,0 +1,742 @@ +package orch + +// output.go ports Phase 8 (_generate_output) and every Markdown builder from +// orchestrator.py. The summary/comment Markdown is reproduced BYTE-FOR-BYTE +// (footer badges/links, verdict wording, severity emojis) — see the golden test. +// +// Float parity (design risk 3): duration is round(elapsed, 1) rendered with +// Python str(float) semantics via pyFloatStr; confidence percentages use int() +// truncation, which matches IEEE-754 across Go and Python. + +import ( + "context" + "errors" + "fmt" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/Agent-Field/pr-af/go/internal/gates" + "github.com/Agent-Field/pr-af/go/internal/github" + "github.com/Agent-Field/pr-af/go/internal/schemas" + "github.com/Agent-Field/pr-af/go/internal/scoring" +) + +// severityRank ports the comment-eligibility rank map. +var severityRank = map[string]int{"nitpick": 0, "suggestion": 1, "important": 2, "critical": 3} + +func (o *Orchestrator) generateOutput( + ctx context.Context, + scoredFindings []schemas.ScoredFinding, + intake schemas.IntakeResult, + anatomy schemas.AnatomyResult, + plan schemas.ReviewPlan, + postToGitHub bool, +) (schemas.ReviewResult, error) { + if o.prData == nil { + return schemas.ReviewResult{}, errPRDataNotInitialized + } + + diffFiles := map[string]struct{}{} + for _, cf := range o.prData.ChangedFiles { + diffFiles[cf.Path] = struct{}{} + } + diffRanges := o.diffLineRanges() + + minRank, ok := severityRank[o.config.Comments.MinSeverity] + if !ok { + minRank = 1 + } + + comments := []schemas.GitHubComment{} + var filteredForComments []schemas.ScoredFinding + for _, finding := range scoredFindings { + if severityRank[string(finding.Severity)] < minRank { + continue + } + filteredForComments = append(filteredForComments, finding) + normPath := o.normalizePath(finding.FilePath) + if normPath == "" || !contains(diffFiles, normPath) || finding.LineStart <= 0 { + continue + } + inRange := false + for _, r := range diffRanges[normPath] { + if r[0] <= finding.LineStart && finding.LineStart <= r[1] { + inRange = true + break + } + } + if !inRange { + continue + } + comments = append(comments, schemas.GitHubComment{ + Path: normPath, + Line: finding.LineStart, + Side: finding.DiffSide, + Body: o.formatCommentBody(finding), + }) + } + + if len(comments) > o.config.Comments.MaxComments { + comments = comments[:o.config.Comments.MaxComments] + } + if o.config.Comments.PolishEnabled && len(comments) > 0 { + comments = gates.PolishComments(ctx, o.deps.App, comments) + } + + // Decorate each comment with the blocking/advisory callout for its finding. + type locKey struct { + path string + line int + side string + } + findingsByLocation := map[locKey]schemas.ScoredFinding{} + for _, f := range filteredForComments { + findingsByLocation[locKey{o.normalizePath(f.FilePath), f.LineStart, f.DiffSide}] = f + } + decorated := make([]schemas.GitHubComment, len(comments)) + for i, c := range comments { + var fptr *schemas.ScoredFinding + if f, ok := findingsByLocation[locKey{c.Path, c.Line, c.Side}]; ok { + ff := f + fptr = &ff + } + nc := c + nc.Body = decorateWithBlocking(fptr, c.Body) + decorated[i] = nc + } + comments = decorated + + reviewEvent := scoring.DetermineReviewEvent(filteredForComments) + summaryBody := o.formatSummary(filteredForComments, reviewEvent, &intake, &plan) + + review := schemas.GitHubReview{ + Body: summaryBody, + Event: reviewEvent, + Comments: comments, + } + + if postToGitHub && !o.input.DryRun && strp(o.input.PrURL) != "" { + o.postReview(ctx, review, summaryBody, comments) + } + + bySeverity := map[string]int{} + for _, f := range scoredFindings { + bySeverity[string(f.Severity)]++ + } + blockingCount, advisoryCount := 0, 0 + for _, f := range scoredFindings { + if f.Blocking { + blockingCount++ + } else { + advisoryCount++ + } + } + + summary := schemas.ReviewSummary{ + TotalFindings: len(scoredFindings), + BySeverity: bySeverity, + BlockingCount: blockingCount, + AdvisoryCount: advisoryCount, + DimensionsRun: len(plan.Dimensions), + CrossRefInteractions: o.crossRefCount, + AdversaryChallenged: o.adversaryChallengedCount, + AdversaryConfirmed: o.adversaryConfirmedCount, + CoverageIterations: o.coverageIterations, + AIGeneratedConfidence: intake.AIGenerated, + CostUSD: localPyRound(o.totalCost(), 4), + DurationSeconds: localPyRound(o.clock().Seconds(), 3), + BudgetExhausted: o.isBudgetExhausted(), + } + + intakeMap, _ := structToMap(&intake) + anatomyMap, _ := structToMap(&anatomy) + planMap, _ := structToMap(&plan) + budgetBreakdown := map[string]any{} + o.mu.Lock() + for k, v := range o.costBreakdown { + budgetBreakdown[k] = v + } + total := o.totalCostUSD + exhausted := o.budgetExhausted + o.mu.Unlock() + metadata := schemas.ReviewMetadata{ + Intake: intakeMap, + Anatomy: anatomyMap, + Plan: planMap, + Budget: map[string]any{ + "total_cost_usd": total, + "cost_breakdown": budgetBreakdown, + "budget_exhausted": exhausted, + "max_cost_usd": o.config.Budget.MaxCostUSD, + "max_duration_seconds": o.config.Budget.MaxDurationSeconds, + }, + AgentInvocations: o.invocations(), + PhasesCompleted: append([]string(nil), phaseOrder...), + } + + return schemas.ReviewResult{ + ReviewID: o.reviewID, + PrURL: strp(o.input.PrURL), + Review: review, + Findings: scoredFindings, + Summary: summary, + Metadata: metadata, + }, nil +} + +// postReview posts the review, downgrading a 422 "own pull request" REQUEST_ +// CHANGES to a COMMENT and retrying once (orchestrator.py's httpx handling). +func (o *Orchestrator) postReview(ctx context.Context, review schemas.GitHubReview, summaryBody string, comments []schemas.GitHubComment) { + _, err := o.deps.GH.PostReview(ctx, o.prData.Owner, o.prData.Repo, o.prData.Number, review, o.prData.HeadSHA) + if err == nil { + return + } + var apiErr *github.APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == 422 && strings.Contains(strings.ToLower(apiErr.Body), "own pull request") { + fallback := schemas.GitHubReview{Body: summaryBody, Event: "COMMENT", Comments: comments} + if _, retryErr := o.deps.GH.PostReview(ctx, o.prData.Owner, o.prData.Repo, o.prData.Number, fallback, o.prData.HeadSHA); retryErr != nil { + fmt.Printf("[PR-AF] Failed to post review on retry: %v\n", retryErr) + } + return + } + fmt.Printf("[PR-AF] Failed to post review: %v\n", err) +} + +// ---- path / range helpers ---- + +func (o *Orchestrator) normalizePath(path string) string { + if path == "" { + return path + } + repoPath := strp(o.input.RepoPath) + if repoPath != "" && strings.HasPrefix(path, repoPath) { + path = strings.TrimLeft(path[len(repoPath):], "/") + } + if strings.HasPrefix(path, "/workspaces/") { + parts := strings.SplitN(path, "/", 4) + if len(parts) >= 4 { + path = parts[3] + } + } + return path +} + +var hunkRe = regexp.MustCompile(`\+(\d+)(?:,(\d+))?`) + +func (o *Orchestrator) diffLineRanges() map[string][][2]int { + ranges := map[string][][2]int{} + if o.prData == nil { + return ranges + } + for _, cf := range o.prData.ChangedFiles { + if cf.Patch == "" { + ranges[cf.Path] = [][2]int{{1, 999999}} + continue + } + var fileRanges [][2]int + for _, line := range strings.Split(cf.Patch, "\n") { + if !strings.HasPrefix(line, "@@") { + continue + } + m := hunkRe.FindStringSubmatch(line) + if m == nil { + continue + } + start, _ := strconv.Atoi(m[1]) + count := 1 + if m[2] != "" { + count, _ = strconv.Atoi(m[2]) + } + fileRanges = append(fileRanges, [2]int{start, start + count}) + } + if len(fileRanges) > 0 { + ranges[cf.Path] = fileRanges + } else { + ranges[cf.Path] = [][2]int{{1, 999999}} + } + } + return ranges +} + +// ---- comment body ---- + +func (o *Orchestrator) formatCommentBody(finding schemas.ScoredFinding) string { + emoji := o.config.Comments.SeverityEmojis[string(finding.Severity)] + severityLabel := strings.ToUpper(string(finding.Severity)) + lines := []string{ + "**" + finding.Title + "**", + "", + fmt.Sprintf("%s `%s` · confidence %d%%", emoji, severityLabel, int(finding.Confidence*100)), + "", + strings.TrimSpace(finding.Body), + } + + if finding.Evidence != "" { + lines = append(lines, "", "
Evidence", "") + for _, evLine := range splitLines(strings.TrimSpace(finding.Evidence)) { + lines = append(lines, "> "+evLine) + } + lines = append(lines, "", "
") + } + + if o.config.Comments.IncludeSuggestions && finding.Suggestion != nil && *finding.Suggestion != "" { + suggestionText := strings.TrimSpace(*finding.Suggestion) + if o.config.Comments.SuggestionMode == "code" { + lines = append(lines, "", "```suggestion", suggestionText, "```") + } else { + lines = append(lines, "", "**💡 Suggested Fix**", "", suggestionText) + } + } + + var metaParts []string + if o.config.Comments.IncludeDimensionAttribution { + metaParts = append(metaParts, "`"+finding.DimensionName+"`") + } + if o.config.Comments.IncludeConfidence { + metaParts = append(metaParts, fmt.Sprintf("confidence %d%%", int(finding.Confidence*100))) + } + if len(metaParts) > 0 { + lines = append(lines, "", "---", "*"+strings.Join(metaParts, " · ")+"*") + } + + lines = append(lines, "", "🤖 Reviewed by [AgentField PR-AF](https://github.com/Agent-Field/pr-af)") + return strings.TrimSpace(strings.Join(lines, "\n")) +} + +func decorateWithBlocking(finding *schemas.ScoredFinding, body string) string { + var header string + if finding != nil && finding.Blocking { + header = "> [!CAUTION]\n> **Must-fix before merge.**" + if finding.BlockingReason != "" { + header += " " + finding.BlockingReason + } + } else { + header = "> [!NOTE]\n> **Advisory — non-blocking.** Safe to merge and address in a follow-up." + if finding != nil && finding.BlockingReason != "" { + header += "\n>\n> _Why non-blocking:_ " + finding.BlockingReason + } + } + return header + "\n\n" + body +} + +// ---- summary Markdown ---- + +func (o *Orchestrator) formatSummary( + findings []schemas.ScoredFinding, + reviewEvent string, + intake *schemas.IntakeResult, + plan *schemas.ReviewPlan, +) string { + _ = reviewEvent + bySeverity := map[string]int{"critical": 0, "important": 0, "suggestion": 0, "nitpick": 0} + for _, f := range findings { + bySeverity[string(f.Severity)]++ + } + emojis := o.config.Comments.SeverityEmojis + duration := localPyRound(o.clock().Seconds(), 1) + + blockingCount := 0 + for _, f := range findings { + if f.Blocking { + blockingCount++ + } + } + advisoryCount := len(findings) - blockingCount + emoji, label := computeRatingV2(blockingCount, advisoryCount, len(findings)) + + var verdictLine string + switch { + case blockingCount == 0 && advisoryCount == 0: + verdictLine = "> ✅ **Safe to merge.** No findings from automated review." + case blockingCount == 0: + verdictLine = fmt.Sprintf("> ✅ **Safe to merge.** %d advisory note%s below — non-blocking, safe to address in a follow-up.", + advisoryCount, plural(advisoryCount)) + default: + verdictLine = fmt.Sprintf("> 🚫 **Merge blocked.** %d must-fix issue%s found by automated review.", + blockingCount, plural(blockingCount)) + } + + var lines []string + lines = append(lines, + fmt.Sprintf("## %s PR-AF Review — **%s**", emoji, label), + "", + verdictLine, + "", + "*Automated multi-agent code review · [PR-AF](https://github.com/Agent-Field/pr-af) built with [AgentField](https://github.com/Agent-Field/agentfield)*", + "", + fmt.Sprintf("> **%d findings** · 🚫 %d blocking · 💬 %d advisory · %s %d critical · %s %d important · %s %d suggestions · %s %d nitpicks", + len(findings), blockingCount, advisoryCount, + emojis["critical"], bySeverity["critical"], + emojis["important"], bySeverity["important"], + emojis["suggestion"], bySeverity["suggestion"], + emojis["nitpick"], bySeverity["nitpick"]), + "", + ) + + if intake != nil { + lines = append(lines, "
", "PR Overview", "", intake.PrSummary, "", "
", "") + } + + lines = append(lines, o.buildKeyFindings(findings)...) + + if len(findings) > 0 { + lines = append(lines, "
", "All Findings by Severity", "") + for _, sev := range []string{"critical", "important", "suggestion", "nitpick"} { + var sevFindings []schemas.ScoredFinding + for _, f := range findings { + if string(f.Severity) == sev { + sevFindings = append(sevFindings, f) + } + } + if len(sevFindings) == 0 { + continue + } + lines = append(lines, fmt.Sprintf("#### %s %s (%d)", emojis[sev], titleCase(sev), len(sevFindings))) + lines = append(lines, "") + for _, f := range sevFindings { + pathRef := "" + if f.FilePath != "" { + pathRef = fmt.Sprintf("`%s:%d`", o.normalizePath(f.FilePath), f.LineStart) + } + lines = append(lines, fmt.Sprintf("- **%s** %s", f.Title, pathRef)) + } + lines = append(lines, "") + } + lines = append(lines, "
", "") + } + + lines = append(lines, o.buildReviewDetails(findings, plan)...) + lines = append(lines, o.buildPipelineStats(intake, duration)...) + lines = append(lines, "Review ID: `"+o.reviewID+"`") + lines = append(lines, + "", + "
", + `
`, + ` `, + ` AgentField PR-AF`, + " ", + "
", + ) + + return strings.Join(lines, "\n") +} + +func computeRatingV2(blocking, advisory, total int) (emoji, label string) { + switch { + case total == 0: + return "🟢", "Looks Good" + case blocking >= 3: + return "🔴", "Multiple Merge-Blockers" + case blocking >= 1: + return "🔴", "Merge Blocked — Must-Fix Found" + case advisory >= 10: + return "🟢", "Safe to Merge — Many Advisories" + case advisory >= 3: + return "🟢", "Safe to Merge — Advisories Only" + default: + return "🟢", "Safe to Merge — Minor Advisories" + } +} + +func (o *Orchestrator) buildKeyFindings(findings []schemas.ScoredFinding) []string { + if len(findings) == 0 { + return []string{"**No issues found.** This PR looks clean across all review dimensions.", ""} + } + var lines []string + var blocking, nonBlocking []schemas.ScoredFinding + for _, f := range findings { + if f.Blocking { + blocking = append(blocking, f) + } else { + nonBlocking = append(nonBlocking, f) + } + } + + lines = append(lines, "### Key Findings", "") + + if len(blocking) > 0 { + lines = append(lines, fmt.Sprintf("**%d issue(s) should be addressed before merge:**", len(blocking)), "") + for _, f := range firstNScored(blocking, 8) { + emoji := o.config.Comments.SeverityEmojis[string(f.Severity)] + pathRef := "" + if f.FilePath != "" { + pathRef = fmt.Sprintf(" (`%s:%d`)", o.normalizePath(f.FilePath), f.LineStart) + } + reason := "" + if f.BlockingReason != "" { + reason = " Gate: " + f.BlockingReason + } + lines = append(lines, fmt.Sprintf("- %s **%s**%s — %s%s", emoji, f.Title, pathRef, firstSentence(f.Body), reason)) + } + if len(blocking) > 8 { + lines = append(lines, fmt.Sprintf("- … and %d more (see All Findings by Severity)", len(blocking)-8)) + } + lines = append(lines, "") + } + + if len(nonBlocking) > 0 { + lines = append(lines, fmt.Sprintf("**%d advisory finding(s) surfaced as non-blocking:**", len(nonBlocking)), "") + for _, f := range firstNScored(nonBlocking, 5) { + emoji := o.config.Comments.SeverityEmojis[string(f.Severity)] + pathRef := "" + if f.FilePath != "" { + pathRef = fmt.Sprintf(" (`%s:%d`)", o.normalizePath(f.FilePath), f.LineStart) + } + lines = append(lines, fmt.Sprintf("- %s %s%s", emoji, f.Title, pathRef)) + } + if len(nonBlocking) > 5 { + lines = append(lines, fmt.Sprintf("- … and %d more (see All Findings by Severity)", len(nonBlocking)-5)) + } + lines = append(lines, "") + } + + affectedSet := map[string]struct{}{} + for _, f := range findings { + if f.FilePath != "" { + affectedSet[o.normalizePath(f.FilePath)] = struct{}{} + } + } + affected := make([]string, 0, len(affectedSet)) + for p := range affectedSet { + affected = append(affected, p) + } + sort.Strings(affected) + if len(affected) > 0 { + shown := affected + if len(shown) > 10 { + shown = shown[:10] + } + quoted := make([]string, len(shown)) + for i, p := range shown { + quoted[i] = "`" + p + "`" + } + lines = append(lines, "**Files with findings:** "+strings.Join(quoted, ", ")) + if len(affected) > 10 { + lines = append(lines, fmt.Sprintf(" … and %d more", len(affected)-10)) + } + lines = append(lines, "") + } + + return lines +} + +func (o *Orchestrator) buildReviewDetails(findings []schemas.ScoredFinding, plan *schemas.ReviewPlan) []string { + var detailParts []string + + if plan != nil && len(plan.Dimensions) > 0 { + detailParts = append(detailParts, fmt.Sprintf("**Dimensions Analyzed (%d):**", len(plan.Dimensions)), "") + for _, dim := range plan.Dimensions { + detailParts = append(detailParts, fmt.Sprintf("- **%s** — %d file(s)", dim.Name, len(dim.TargetFiles))) + } + detailParts = append(detailParts, "") + } + + if len(o.metaSelectorResults) > 0 { + detailParts = append(detailParts, fmt.Sprintf("**Meta-Dimension Lenses (%d):**", len(o.metaSelectorResults)), "") + for _, meta := range o.metaSelectorResults { + detailParts = append(detailParts, fmt.Sprintf("- **%s** — %d dimension(s), %d%% coverage confidence", + titleCase(meta.Lens), len(meta.Dimensions), int(meta.Confidence*100))) + } + detailParts = append(detailParts, "") + } + + subReviewSet := map[string]struct{}{} + for _, f := range findings { + if strings.Contains(f.DimensionName, "→") { + subReviewSet[f.DimensionName] = struct{}{} + } + } + if len(subReviewSet) > 0 { + detailParts = append(detailParts, fmt.Sprintf("**Sub-Reviews Spawned (%d deep-dives):**", len(subReviewSet)), "") + names := make([]string, 0, len(subReviewSet)) + for n := range subReviewSet { + names = append(names, n) + } + sort.Strings(names) + for _, dimName := range names { + count := 0 + for _, f := range findings { + if f.DimensionName == dimName { + count++ + } + } + detailParts = append(detailParts, fmt.Sprintf("- **%s** (%d finding(s))", dimName, count)) + } + detailParts = append(detailParts, "") + } + + if o.crossRefCount > 0 || o.adversaryConfirmedCount > 0 || o.adversaryChallengedCount > 0 { + detailParts = append(detailParts, "**Cross-Reference & Adversary Analysis:**", "") + if o.crossRefCount > 0 { + detailParts = append(detailParts, fmt.Sprintf("- **%d** compound finding(s) synthesized", o.crossRefCount)) + } + totalAdv := o.adversaryConfirmedCount + o.adversaryChallengedCount + if totalAdv > 0 { + detailParts = append(detailParts, fmt.Sprintf("- **%d** finding(s) adversarially tested: %d confirmed, %d challenged", + totalAdv, o.adversaryConfirmedCount, o.adversaryChallengedCount)) + } + detailParts = append(detailParts, "") + } + + if len(detailParts) == 0 { + return nil + } + out := []string{"
", "Review Process Details", ""} + out = append(out, detailParts...) + out = append(out, "
", "") + return out +} + +func (o *Orchestrator) buildPipelineStats(intake *schemas.IntakeResult, duration float64) []string { + total := o.totalCost() + costDisplay := "N/A (provider does not report cost)" + if total > 0 { + costDisplay = fmt.Sprintf("$%.4f", total) + } + exhaustionReason := "" + if o.isBudgetExhausted() { + elapsed := o.clock().Seconds() + if elapsed > float64(o.config.Budget.MaxDurationSeconds) { + exhaustionReason = fmt.Sprintf(" (timeout: %ds > %ds limit)", int(elapsed), o.config.Budget.MaxDurationSeconds) + } else if total >= o.config.Budget.MaxCostUSD { + exhaustionReason = fmt.Sprintf(" (cost: $%.2f ≥ $%.2f limit)", total, o.config.Budget.MaxCostUSD) + } + } + budgetExhaustedCell := "No" + if o.isBudgetExhausted() { + budgetExhaustedCell = "Yes" + exhaustionReason + } + + statsRows := []string{ + fmt.Sprintf("| Duration | %ss |", pyFloatStr(duration)), + fmt.Sprintf("| Agent invocations | %d |", o.invocations()), + fmt.Sprintf("| Coverage iterations | %d |", o.coverageIterations), + fmt.Sprintf("| Estimated cost | %s |", costDisplay), + fmt.Sprintf("| Budget exhausted | %s |", budgetExhaustedCell), + } + if intake != nil { + statsRows = append(statsRows, + fmt.Sprintf("| PR type | %s |", intake.PrType), + fmt.Sprintf("| Complexity | %s |", intake.Complexity)) + } + + out := []string{"
", "Pipeline Stats", "", "| Metric | Value |", "|--------|-------|"} + out = append(out, statsRows...) + out = append(out, "", "
", "") + return out +} + +// firstSentence ports _first_sentence. +func firstSentence(text string) string { + text = strings.ReplaceAll(strings.TrimSpace(text), "\n", " ") + for _, sep := range []string{". ", ".\n", "! ", "?\n"} { + idx := strings.Index(text, sep) + if idx != -1 && idx < 200 { + return runeSlice(text, 0, idx+1) + } + } + if runeLen(text) > 200 { + return runeSlice(text, 0, 200) + "…" + } + return text +} + +// ---- small text helpers ---- + +func plural(n int) string { + if n != 1 { + return "s" + } + return "" +} + +func contains(m map[string]struct{}, key string) bool { + _, ok := m[key] + return ok +} + +// titleCase ports Python str.title() for the single lowercase words used here +// (severity/lens names): capitalize the first letter of each run of letters. +func titleCase(s string) string { + var b strings.Builder + prevAlpha := false + for _, r := range s { + isAlpha := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') + switch { + case isAlpha && !prevAlpha: + b.WriteRune(toUpperRune(r)) + case isAlpha: + b.WriteRune(toLowerRune(r)) + default: + b.WriteRune(r) + } + prevAlpha = isAlpha + } + return b.String() +} + +func toUpperRune(r rune) rune { + if r >= 'a' && r <= 'z' { + return r - 32 + } + return r +} + +func toLowerRune(r rune) rune { + if r >= 'A' && r <= 'Z' { + return r + 32 + } + return r +} + +// splitLines ports Python str.splitlines() (no trailing empty element for a +// trailing newline; splits on \n, \r, \r\n). +func splitLines(s string) []string { + if s == "" { + return nil + } + normalized := strings.ReplaceAll(s, "\r\n", "\n") + normalized = strings.ReplaceAll(normalized, "\r", "\n") + parts := strings.Split(normalized, "\n") + if len(parts) > 0 && parts[len(parts)-1] == "" { + parts = parts[:len(parts)-1] + } + return parts +} + +func firstNScored(xs []schemas.ScoredFinding, n int) []schemas.ScoredFinding { + if len(xs) <= n { + return xs + } + return xs[:n] +} + +func runeLen(s string) int { return len([]rune(s)) } + +func runeSlice(s string, start, end int) string { + r := []rune(s) + if end > len(r) { + end = len(r) + } + return string(r[start:end]) +} + +// localPyRound reproduces Python round(x, digits) (round-half-to-even) via +// correctly-rounded decimal formatting, matching scoring.pyRound (private there). +func localPyRound(x float64, digits int) float64 { + v, _ := strconv.ParseFloat(strconv.FormatFloat(x, 'f', digits, 64), 64) + return v +} + +// pyFloatStr renders a float the way Python str(float) does: shortest round-trip +// decimal, always with a decimal point (or exponent). Used for the `{duration}s` +// cell so a whole-second duration renders "12.0", not "12". +func pyFloatStr(f float64) string { + s := strconv.FormatFloat(f, 'g', -1, 64) + if !strings.ContainsAny(s, ".eEnN") { + s += ".0" + } + return s +} diff --git a/go/internal/orch/output_test.go b/go/internal/orch/output_test.go new file mode 100644 index 0000000..72e326d --- /dev/null +++ b/go/internal/orch/output_test.go @@ -0,0 +1,208 @@ +package orch + +// V11: the summary Markdown is byte-identical to the real Python formatter for a +// fixed fixture (testdata/summary_golden.txt, generated by gen_summary_golden.py +// via the venv). V2: ReviewResult marshals with exactly Python's model_dump() key +// set at every level (testdata/result_keys.json, generated by gen_result_keys.py). + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "sort" + "testing" + "time" + + "github.com/Agent-Field/pr-af/go/internal/config" + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +func fixedClockOrch(t *testing.T) *Orchestrator { + t.Helper() + o := New(Deps{App: &fakeApp{}}, schemas.ReviewInput{}, config.DefaultReviewConfig()) + o.reviewID = "rev_abc123def456" + o.clock = func() time.Duration { return 12300 * time.Millisecond } + o.crossRefCount = 1 + o.adversaryConfirmedCount = 2 + o.adversaryChallengedCount = 1 + o.coverageIterations = 1 + o.agentInvocations = 7 + o.metaSelectorResults = []schemas.MetaDimensionResult{ + {Lens: "semantic", Dimensions: []schemas.ReviewDimension{{ID: "s1", Name: "A"}, {ID: "s2", Name: "B"}}, Confidence: 0.8}, + {Lens: "mechanical", Dimensions: []schemas.ReviewDimension{{ID: "m1", Name: "C"}}, Confidence: 0.6}, + } + return o +} + +func TestSummaryGoldenByteExact(t *testing.T) { + o := fixedClockOrch(t) + findings := []schemas.ScoredFinding{ + { + ID: "f_000", DimensionID: "d1", DimensionName: "Security", + FilePath: "src/a.py", LineStart: 10, LineEnd: 10, DiffSide: "RIGHT", + Severity: "critical", Title: "Null dereference", + Body: "The value can be None. Handle it before use.", Confidence: 0.9, + Blocking: true, BlockingReason: "Crashes on null input.", + }, + { + ID: "f_001", DimensionID: "d2", DimensionName: "Performance", + FilePath: "src/b.py", LineStart: 20, LineEnd: 25, DiffSide: "RIGHT", + Severity: "important", Title: "Quadratic loop", + Body: "This nested loop is O(n^2) and will not scale.", Confidence: 0.7, + }, + { + ID: "f_002", DimensionID: "d3", DimensionName: "Style", + FilePath: "", LineStart: 0, LineEnd: 0, DiffSide: "RIGHT", + Severity: "nitpick", Title: "Typo in comment", + Body: "Fix the spelling.", Confidence: 0.5, + }, + } + intake := schemas.IntakeResult{ + PrType: "feature", Complexity: "standard", + Languages: []string{"python"}, AreasTouched: []string{"api"}, RiskSignals: []string{}, + AIGenerated: 0.1, ReviewDepth: "standard", PrSummary: "Adds a caching layer to the API.", + } + plan := schemas.ReviewPlan{ + Dimensions: []schemas.ReviewDimension{ + {ID: "sec", Name: "Security", TargetFiles: []string{"src/a.py"}}, + {ID: "perf", Name: "Performance", TargetFiles: []string{"src/b.py", "src/c.py"}}, + }, + CrossRefHints: []string{}, + } + + got := o.formatSummary(findings, "REQUEST_CHANGES", &intake, &plan) + + want, err := os.ReadFile(filepath.Join("testdata", "summary_golden.txt")) + if err != nil { + t.Fatal(err) + } + if got != string(want) { + reportFirstDiff(t, string(want), got) + } +} + +// reportFirstDiff surfaces the first differing byte with a small window. +func reportFirstDiff(t *testing.T, want, got string) { + t.Helper() + n := len(want) + if len(got) < n { + n = len(got) + } + for i := 0; i < n; i++ { + if want[i] != got[i] { + lo := i - 40 + if lo < 0 { + lo = 0 + } + hiW := i + 40 + if hiW > len(want) { + hiW = len(want) + } + hiG := i + 40 + if hiG > len(got) { + hiG = len(got) + } + t.Fatalf("summary differs at byte %d\n want …%q…\n got …%q…", i, want[lo:hiW], got[lo:hiG]) + } + } + if len(want) != len(got) { + t.Fatalf("summary length differs: want %d bytes, got %d bytes\ngot tail: %q", len(want), len(got), got[n:]) + } +} + +func TestReviewResultKeySetParity(t *testing.T) { + app := &fakeApp{} + cfg := config.DefaultReviewConfig() + cfg.Comments.PolishEnabled = false // avoid the AI polish pass in this unit test + in := schemas.ReviewInput{PrURL: strPtr("https://github.com/o/r/pull/1"), DryRun: true} + o := New(Deps{App: app}, in, cfg) + o.prData = &schemas.GitHubPRData{ + Owner: "o", Repo: "r", Number: 1, + ChangedFiles: []schemas.ChangedFile{{Path: "src/a.py", Patch: "@@ -1,1 +1,5 @@\n+x"}}, + } + + scored := []schemas.ScoredFinding{{ + ID: "f_000", DimensionID: "d1", DimensionName: "Dim", + FilePath: "src/a.py", LineStart: 1, LineEnd: 1, DiffSide: "RIGHT", + Severity: "important", Title: "t", Body: "b", Confidence: 0.5, + }} + intake := schemas.IntakeResult{ + PrType: "feature", Complexity: "standard", + Languages: []string{}, AreasTouched: []string{}, RiskSignals: []string{}, + AIGenerated: 0.0, ReviewDepth: "standard", PrSummary: "s", + } + anatomy := schemas.AnatomyResult{ + Files: []schemas.FileChange{}, Clusters: []schemas.ChangeCluster{}, + BlastRadius: []string{}, DependencyGraph: map[string][]string{}, + RiskSurfaces: []string{}, UnrelatedChanges: []string{}, IntentGaps: []string{}, + } + plan := schemas.ReviewPlan{Dimensions: []schemas.ReviewDimension{}, CrossRefHints: []string{}, TotalBudget: defaultBudgetAllocation()} + + result, err := o.generateOutput(context.Background(), scored, intake, anatomy, plan, true) + if err != nil { + t.Fatal(err) + } + + b, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatal(err) + } + + expected := loadExpectedKeys(t) + + assertKeys(t, "result", m, expected["result"]) + review := m["review"].(map[string]any) + assertKeys(t, "review", review, expected["review"]) + comments := review["comments"].([]any) + if len(comments) == 0 { + t.Fatal("expected at least one comment for the key-set check") + } + assertKeys(t, "comment", comments[0].(map[string]any), expected["comment"]) + findings := m["findings"].([]any) + assertKeys(t, "finding", findings[0].(map[string]any), expected["finding"]) + assertKeys(t, "summary", m["summary"].(map[string]any), expected["summary"]) + meta := m["metadata"].(map[string]any) + assertKeys(t, "metadata", meta, expected["metadata"]) + assertKeys(t, "intake", meta["intake"].(map[string]any), expected["intake"]) + assertKeys(t, "anatomy", meta["anatomy"].(map[string]any), expected["anatomy"]) + assertKeys(t, "plan", meta["plan"].(map[string]any), expected["plan"]) + assertKeys(t, "budget", meta["budget"].(map[string]any), expected["budget"]) +} + +func loadExpectedKeys(t *testing.T) map[string][]string { + t.Helper() + b, err := os.ReadFile(filepath.Join("testdata", "result_keys.json")) + if err != nil { + t.Fatal(err) + } + var expected map[string][]string + if err := json.Unmarshal(b, &expected); err != nil { + t.Fatal(err) + } + return expected +} + +func assertKeys(t *testing.T, level string, obj map[string]any, want []string) { + t.Helper() + got := make([]string, 0, len(obj)) + for k := range obj { + got = append(got, k) + } + sort.Strings(got) + sort.Strings(want) + if len(got) != len(want) { + t.Errorf("%s: key count mismatch\n got %v\nwant %v", level, got, want) + return + } + for i := range got { + if got[i] != want[i] { + t.Errorf("%s: key set mismatch\n got %v\nwant %v", level, got, want) + return + } + } +} diff --git a/go/internal/orch/phases.go b/go/internal/orch/phases.go new file mode 100644 index 0000000..5879240 --- /dev/null +++ b/go/internal/orch/phases.go @@ -0,0 +1,1180 @@ +package orch + +// phases.go ports the finding-producing phases of orchestrator.py: intake, +// anatomy, meta-selectors, the streaming review+layer, evidence verification, +// parallel adversary, coverage loop, and consistency verify. The concurrency +// primitives map (design §D): asyncio.gather → order-preserving errgroup with a +// pre-indexed result slice; asyncio.Semaphore → semaphore.Weighted; the +// streaming asyncio.Queue → an (unbuffered) chan []ReviewFinding with the +// producer closing on completion and the consumer ranging it. + +import ( + "context" + "os" + "path/filepath" + "sort" + "strings" + "sync" + + "golang.org/x/sync/errgroup" + "golang.org/x/sync/semaphore" + + "github.com/Agent-Field/pr-af/go/internal/config" + "github.com/Agent-Field/pr-af/go/internal/diffengine" + "github.com/Agent-Field/pr-af/go/internal/evidence" + "github.com/Agent-Field/pr-af/go/internal/gates" + "github.com/Agent-Field/pr-af/go/internal/prompts" + "github.com/Agent-Field/pr-af/go/internal/reasoners" + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// ---- Phase 1: INTAKE ---- + +func (o *Orchestrator) runIntake(ctx context.Context) (schemas.IntakeResult, error) { + if o.budgetOrTimeoutExhausted("intake") { + return schemas.IntakeResult{}, budgetExhaustedErr("Budget exhausted before intake") + } + + switch { + case strp(o.input.PrURL) != "": + owner, repo, number, err := o.deps.GH.ParsePRURL(strp(o.input.PrURL)) + if err != nil { + return schemas.IntakeResult{}, badInput(err.Error()) + } + prData, err := o.deps.GH.FetchPR(ctx, owner, repo, number) + if err != nil { + return schemas.IntakeResult{}, err + } + o.prData = &prData + case strp(o.input.DiffText) != "": + diff := strp(o.input.DiffText) + parsed := diffengine.ParseUnifiedDiff(diff) + o.prData = &schemas.GitHubPRData{ + Title: "Local Diff Review", + Diff: diff, + ChangedFiles: toChangedFiles(parsed), + } + case strp(o.input.RepoPath) != "": + diff, err := computeRepoDiff(ctx, strp(o.input.RepoPath), strp(o.input.BaseRef), strp(o.input.HeadRef)) + if err != nil { + return schemas.IntakeResult{}, err + } + parsed := diffengine.ParseUnifiedDiff(diff) + number := 0 + if o.input.PostPRNumber != nil { + number = *o.input.PostPRNumber + } + o.prData = &schemas.GitHubPRData{ + Number: number, + Title: "Local Repository Review", + Diff: diff, + ChangedFiles: toChangedFiles(parsed), + } + default: + return schemas.IntakeResult{}, badInput("One of pr_url, diff_text, or repo_path is required") + } + + resultRaw, err := o.rfns.intake(ctx, o.reasonerDeps(), reasoners.IntakeInput{ + PRData: *o.prData, + Depth: o.input.Depth, + }) + if err != nil { + return schemas.IntakeResult{}, err + } + o.incInvocations(1) + o.registerCost("intake", resultRaw) + return mapToStruct[schemas.IntakeResult](resultRaw), nil +} + +// ---- Phase 2: ANATOMY ---- + +func (o *Orchestrator) runAnatomy(ctx context.Context, intake schemas.IntakeResult) (schemas.AnatomyResult, error) { + if o.budgetOrTimeoutExhausted("anatomy") { + return schemas.AnatomyResult{}, budgetExhaustedErr("Budget exhausted before anatomy") + } + if o.prData == nil { + return schemas.AnatomyResult{}, errPRDataNotInitialized + } + resultRaw, err := o.rfns.anatomy(ctx, o.reasonerDeps(), reasoners.AnatomyInput{ + PRData: *o.prData, + Intake: intake, + RepoPath: strp(o.input.RepoPath), + }) + if err != nil { + return schemas.AnatomyResult{}, err + } + o.incInvocations(1) + o.registerCost("anatomy", resultRaw) + return mapToStruct[schemas.AnatomyResult](resultRaw), nil +} + +// ---- runReviewPhases: meta-selectors → review+layer → coverage ‖ consistency → synthesize → merge-gate ---- + +func (o *Orchestrator) runReviewPhases( + ctx context.Context, + intake schemas.IntakeResult, + anatomy schemas.AnatomyResult, + reviewDepth, reviewerFeedback string, +) (schemas.ReviewPlan, []schemas.ScoredFinding, error) { + plan, err := o.runMetaSelectors(ctx, intake, anatomy, reviewDepth, reviewerFeedback) + if err != nil { + return schemas.ReviewPlan{}, nil, err + } + + var allFindings []schemas.ReviewFinding + var adversaryResults []schemas.AdversaryResult + + if o.config.Comments.PostWorthinessGate { + // EARLY-GATE reorder: run reviewers to completion, gate, then heavy layer. + reviewerFindings, err := o.collectParallelReview(ctx, plan, reviewerFeedback) + if err != nil { + return schemas.ReviewPlan{}, nil, err + } + kept := reviewerFindings + if len(reviewerFindings) > 1 { + if sel, ok := o.postWorthinessSelect(ctx, reviewerFindings); ok && len(sel) > 0 { + kept = sel + } + } + lq := make(chan []schemas.ReviewFinding, 1) + if len(kept) > 0 { + lq <- kept + } + close(lq) + allFindings, adversaryResults, err = o.runReviewLayer(ctx, plan, lq, anatomy) + if err != nil { + return schemas.ReviewPlan{}, nil, err + } + } else { + // Gate OFF (default): review and layer stream through one channel. + var err error + allFindings, adversaryResults, err = o.streamReviewLayer(ctx, plan, anatomy, reviewerFeedback) + if err != nil { + return schemas.ReviewPlan{}, nil, err + } + } + + // Phase 6 (coverage) ‖ Phase 6.7 (consistency) — independent, run concurrently. + covFindings, covAdversary, cvFindings, err := o.runCoverageAndConsistency(ctx, plan, anatomy, allFindings, adversaryResults) + if err != nil { + return schemas.ReviewPlan{}, nil, err + } + adversaryResults = covAdversary + + challenged, confirmed := 0, 0 + for _, r := range adversaryResults { + if r.Verdict == "challenged" { + challenged++ + } + if r.Verdict == "confirmed" { + confirmed++ + } + } + o.adversaryChallengedCount = challenged + o.adversaryConfirmedCount = confirmed + + var consistencyNew []schemas.ReviewFinding + for _, f := range cvFindings { + if f.DimensionID == "consistency-verify" { + consistencyNew = append(consistencyNew, f) + } + } + merged := append(append([]schemas.ReviewFinding(nil), covFindings...), consistencyNew...) + + scored := o.synthesize(merged, adversaryResults) + + if o.config.Comments.MergeGateEnabled { + scored = gates.ClassifyFindings(ctx, o.deps.App, scored) + } + return plan, scored, nil +} + +// runCoverageAndConsistency runs the coverage loop and consistency verify +// concurrently (asyncio.gather(coverage_loop, consistency_verify)), preserving +// the ordered destructuring of the two results. +func (o *Orchestrator) runCoverageAndConsistency( + ctx context.Context, + plan schemas.ReviewPlan, + anatomy schemas.AnatomyResult, + allFindings []schemas.ReviewFinding, + adversaryResults []schemas.AdversaryResult, +) ([]schemas.ReviewFinding, []schemas.AdversaryResult, []schemas.ReviewFinding, error) { + var covFindings []schemas.ReviewFinding + var covAdversary []schemas.AdversaryResult + var cvFindings []schemas.ReviewFinding + + g, gctx := errgroup.WithContext(ctx) + g.Go(func() error { + var e error + covFindings, covAdversary, e = o.runCoverageLoop(gctx, plan, anatomy, + append([]schemas.ReviewFinding(nil), allFindings...), adversaryResults) + return e + }) + g.Go(func() error { + var e error + cvFindings, e = o.runConsistencyVerify(gctx, append([]schemas.ReviewFinding(nil), allFindings...)) + return e + }) + if err := g.Wait(); err != nil { + return nil, nil, nil, err + } + return covFindings, covAdversary, cvFindings, nil +} + +// postWorthinessSelect runs the pre-layer post-worthiness gate. Returns the kept +// subset and ok=false when the gate errored (Python's try/except → keep all). +func (o *Orchestrator) postWorthinessSelect(ctx context.Context, findings []schemas.ReviewFinding) ([]schemas.ReviewFinding, bool) { + raw, err := o.rfns.postWorthiness(ctx, o.reasonerDeps(), reasoners.PostWorthinessInput{Findings: findings}) + if err != nil { + return nil, false + } + // Default keep set = all indices (pw.get("keep_indices", range(len))). + keep := make(map[int]struct{}) + if v, present := raw["keep_indices"]; present { + for _, i := range getIntSlice(v) { + keep[i] = struct{}{} + } + } else { + for i := range findings { + keep[i] = struct{}{} + } + } + sel := make([]schemas.ReviewFinding, 0, len(findings)) + for i, f := range findings { + if _, ok := keep[i]; ok { + sel = append(sel, f) + } + } + return sel, true +} + +// ---- Phase 3: META-SELECTORS ---- + +func (o *Orchestrator) runMetaSelectors( + ctx context.Context, + intake schemas.IntakeResult, + anatomy schemas.AnatomyResult, + reviewDepth, reviewerFeedback string, +) (schemas.ReviewPlan, error) { + if o.budgetOrTimeoutExhausted("meta_selectors") { + return schemas.ReviewPlan{}, budgetExhaustedErr("Budget exhausted before meta-selectors") + } + + lensFns := map[string]func(context.Context, reasoners.Deps, reasoners.MetaInput) (map[string]any, error){ + "semantic": o.rfns.metaSemantic, + "mechanical": o.rfns.metaMechanical, + "systemic": o.rfns.metaSystemic, + } + + // Order-preserving fan-out: pre-indexed results in enabledLenses order. + type lensJob struct { + lens string + fn func(context.Context, reasoners.Deps, reasoners.MetaInput) (map[string]any, error) + } + jobs := make([]lensJob, 0, len(enabledLenses)) + for _, lens := range enabledLenses { + if fn, ok := lensFns[lens]; ok { + jobs = append(jobs, lensJob{lens: lens, fn: fn}) + } + } + results := make([]schemas.MetaDimensionResult, len(jobs)) + diffPatches := reasoners.OrderedPatches(o.filePatches()) + + g, gctx := errgroup.WithContext(ctx) + for i := range jobs { + i := i + g.Go(func() error { + raw, err := jobs[i].fn(gctx, o.reasonerDeps(), reasoners.MetaInput{ + Intake: intake, + Anatomy: anatomy, + Depth: reviewDepth, + RepoPath: strp(o.input.RepoPath), + DiffPatches: diffPatches, + ReviewerFeedback: reviewerFeedback, + }) + if err != nil { + return err + } + o.incInvocations(1) + o.registerCost("meta_selectors", raw) + results[i] = mapToStruct[schemas.MetaDimensionResult](raw) + return nil + }) + } + if err := g.Wait(); err != nil { + return schemas.ReviewPlan{}, err + } + + o.metaSelectorResults = results + o.effectiveDepth = o.escalateDepth(reviewDepth) + + // Prefix each dimension id by its lens, preserving lens then dimension order. + var allDimensions []schemas.ReviewDimension + for _, meta := range results { + for _, dim := range meta.Dimensions { + d := dim + d.ID = meta.Lens + "_" + dim.ID + allDimensions = append(allDimensions, d) + } + } + allDimensions = dedupCrossMeta(allDimensions) + + if profile, ok := config.DepthProfiles[reviewDepth]; ok && len(allDimensions) > profile.MaxDimensions { + sort.SliceStable(allDimensions, func(i, j int) bool { + return allDimensions[i].Priority > allDimensions[j].Priority + }) + allDimensions = allDimensions[:profile.MaxDimensions] + } + + // ReviewPlan is NOT default-seeded in schemas (design §5), so seed the + // pydantic BudgetAllocation() default for total_budget explicitly — otherwise + // plan.model_dump() (into metadata) would carry an all-zero budget. + return schemas.ReviewPlan{ + Dimensions: allDimensions, + CrossRefHints: []string{}, + TotalBudget: defaultBudgetAllocation(), + }, nil +} + +// defaultBudgetAllocation reproduces pydantic BudgetAllocation()'s seeded +// defaults (schemas defaults.go), for structs the orchestrator constructs +// directly (ReviewPlan.TotalBudget). +func defaultBudgetAllocation() schemas.BudgetAllocation { + return schemas.BudgetAllocation{ + MaxCostUSD: 0.5, + MaxDurationSeconds: 60, + MaxReferenceFollows: 3, + MaxChildSpawns: 2, + } +} + +// dedupCrossMeta ports _dedup_cross_meta: dedup by sorted target-files key, +// keeping the higher-priority dimension. +func dedupCrossMeta(dimensions []schemas.ReviewDimension) []schemas.ReviewDimension { + seen := map[string]schemas.ReviewDimension{} + var deduped []schemas.ReviewDimension + for _, dim := range dimensions { + key := append([]string(nil), dim.TargetFiles...) + sort.Strings(key) + keyStr := strings.Join(key, "|") + if existing, ok := seen[keyStr]; ok { + if dim.Priority > existing.Priority { + filtered := deduped[:0] + for _, d := range deduped { + if d.ID != existing.ID { + filtered = append(filtered, d) + } + } + deduped = filtered + deduped = append(deduped, dim) + seen[keyStr] = dim + } + } else { + seen[keyStr] = dim + deduped = append(deduped, dim) + } + } + return deduped +} + +// ---- Phase 4: REVIEW (streaming producer) ---- + +// collectParallelReview drives runParallelReview to completion, closing the +// channel and collecting every batch (the gate-ON and coverage-gap pattern where +// Python awaits the producer fully then drains the queue). +func (o *Orchestrator) collectParallelReview(ctx context.Context, plan schemas.ReviewPlan, feedback string) ([]schemas.ReviewFinding, error) { + ch := make(chan []schemas.ReviewFinding) + errc := make(chan error, 1) + go func() { + err := o.runParallelReview(ctx, plan, ch, 0, feedback) + close(ch) + errc <- err + }() + var out []schemas.ReviewFinding + for batch := range ch { + out = append(out, batch...) + } + return out, <-errc +} + +// runParallelReview ports _run_parallel_review. It fans out one reviewer per +// dimension bounded by a weighted semaphore, streaming each reviewer's findings +// to ch as it completes; a reviewer may recursively spawn ≤2 sub-reviews up to +// max_review_depth. It does NOT close ch — the caller owns the sentinel/close. +func (o *Orchestrator) runParallelReview( + ctx context.Context, + plan schemas.ReviewPlan, + ch chan<- []schemas.ReviewFinding, + currentDepth int, + feedback string, +) error { + maxDepth := o.config.Budget.MaxReviewDepth + sem := semaphore.NewWeighted(int64(o.config.Budget.MaxConcurrentReviewers)) + g, gctx := errgroup.WithContext(ctx) + + var runDim func(dim schemas.ReviewDimension, depth int) + runDim = func(dim schemas.ReviewDimension, depth int) { + g.Go(func() error { + if o.budgetOrTimeoutExhausted("review") { + return nil + } + if err := sem.Acquire(gctx, 1); err != nil { + return err + } + defer sem.Release(1) + + // Filter patches to this dimension's target files (order immaterial). + targets := stringSet(dim.TargetFiles) + dimPatches := map[string]string{} + for _, p := range o.filePatches() { + if _, ok := targets[p.Key]; ok { + dimPatches[p.Key] = p.Val + } + } + + primed := "" + if o.config.Budget.EvidencePackReviewers && strp(o.input.RepoPath) != "" { + primed = evidence.BuildDimensionPack(gctx, strp(o.input.RepoPath), dim.TargetFiles, dimPatches) + } + + var narrative, intakeSummary string + var riskSurfaces []string + if o.anatomyResult != nil { + narrative = o.anatomyResult.PrNarrative + riskSurfaces = o.anatomyResult.RiskSurfaces + } + if o.intakeResult != nil { + intakeSummary = o.intakeResult.PrSummary + } + otherNames := make([]string, 0, len(plan.Dimensions)) + for _, d := range plan.Dimensions { + if d.ID != dim.ID { + otherNames = append(otherNames, d.Name) + } + } + var patchArg map[string]string + if len(dimPatches) > 0 { + patchArg = dimPatches + } + + resultRaw, err := o.rfns.reviewDim(gctx, o.reasonerDeps(), reasoners.ReviewDimensionInput{ + ReviewPrompt: dim.ReviewPrompt, + TargetFiles: dim.TargetFiles, + ContextFiles: dim.ContextFiles, + RepoPath: strp(o.input.RepoPath), + CurrentDepth: depth, + MaxDepth: maxDepth, + PrNarrative: narrative, + RiskSurfaces: riskSurfaces, + IntakeSummary: intakeSummary, + DiffPatches: patchArg, + AllDimensionNames: otherNames, + ReviewerFeedback: feedback, + PrimedCode: primed, + }) + if err != nil { + return err + } + o.incInvocations(1) + o.registerCost("review", resultRaw) + + findings := o.extractFindings(resultRaw, dim) + select { + case ch <- findings: + case <-gctx.Done(): + return gctx.Err() + } + + subReviews := extractSubReviews(resultRaw, dim) + if len(subReviews) > 0 && depth < maxDepth && !o.budgetOrTimeoutExhausted("review") { + for _, sub := range subReviews { + runDim(sub, depth+1) + } + } + return nil + }) + } + + for _, dim := range plan.Dimensions { + runDim(dim, currentDepth) + } + return g.Wait() +} + +// extractSubReviews ports _extract_sub_reviews: at most the first 2 sub_reviews +// with a non-empty prompt and target_files become child dimensions. +func extractSubReviews(resultRaw map[string]any, parentDim schemas.ReviewDimension) []schemas.ReviewDimension { + payload := unwrap(resultRaw) + if payload == nil { + return nil + } + rawSubs, ok := payload["sub_reviews"].([]any) + if !ok { + return nil + } + var dims []schemas.ReviewDimension + for idx, s := range rawSubs { + if idx >= 2 { + break + } + sub, ok := s.(map[string]any) + if !ok { + continue + } + prompt := getStr(sub, "review_prompt", "") + targets := getStrSlice(sub, "target_files") + if prompt == "" || len(targets) == 0 { + continue + } + reason := getStr(sub, "reason", "deep-dive") + dims = append(dims, schemas.ReviewDimension{ + ID: parentDim.ID + "_sub" + itoa(idx), + Name: parentDim.Name + " → " + truncateRunes(reason, 40), + ReviewPrompt: prompt, + TargetFiles: targets, + ContextFiles: getStrSlice(sub, "context_files"), + Priority: parentDim.Priority, + }) + } + return dims +} + +// extractFindings ports _extract_findings. +func (o *Orchestrator) extractFindings(resultRaw map[string]any, dim schemas.ReviewDimension) []schemas.ReviewFinding { + payload := unwrap(resultRaw) + var raw []map[string]any + if payload != nil { + if lst := asObjListStrict(payload, "findings"); lst != nil { + raw = lst + } else if lst := asObjListStrict(payload, "results"); lst != nil { + raw = lst + } + } + findings := make([]schemas.ReviewFinding, 0, len(raw)) + for _, item := range raw { + findings = append(findings, mapToReviewFinding(item, map[string]any{ + "dimension_id": dim.ID, + "dimension_name": dim.Name, + "title": "Untitled finding", + })) + } + return findings +} + +// streamReviewLayer runs the gate-OFF default path: _run_parallel_review and +// _run_review_layer CONCURRENTLY, findings streaming through one channel +// (producer errgroup closes on completion; the layer ranges it). This is NOT a +// gather-then-process — the layer begins consuming as reviewers complete +// (design risk 1). A producer error still closes the channel so the consumer's +// range terminates; the errgroup surfaces the first error. +func (o *Orchestrator) streamReviewLayer( + ctx context.Context, + plan schemas.ReviewPlan, + anatomy schemas.AnatomyResult, + reviewerFeedback string, +) ([]schemas.ReviewFinding, []schemas.AdversaryResult, error) { + ch := make(chan []schemas.ReviewFinding) + var allFindings []schemas.ReviewFinding + var adversaryResults []schemas.AdversaryResult + g, gctx := errgroup.WithContext(ctx) + g.Go(func() error { + defer close(ch) + return o.runParallelReview(gctx, plan, ch, 0, reviewerFeedback) + }) + g.Go(func() error { + var e error + allFindings, adversaryResults, e = o.runReviewLayer(gctx, plan, ch, anatomy) + return e + }) + if err := g.Wait(); err != nil { + return nil, nil, err + } + return allFindings, adversaryResults, nil +} + +// ---- Phase 5: LAYER (streaming consumer) ---- + +func (o *Orchestrator) runReviewLayer( + ctx context.Context, + plan schemas.ReviewPlan, + ch <-chan []schemas.ReviewFinding, + anatomy schemas.AnatomyResult, +) ([]schemas.ReviewFinding, []schemas.AdversaryResult, error) { + _ = plan + var allFindings []schemas.ReviewFinding + for batch := range ch { + if o.layerBatchHook != nil { + o.layerBatchHook(batch) + } + allFindings = append(allFindings, batch...) + } + + evidenceMap := map[string]evidence.EvidencePackage{} + if len(allFindings) > 0 && strp(o.input.RepoPath) != "" { + em, err := evidence.ExtractEvidenceForFindings(ctx, allFindings, strp(o.input.RepoPath), o.filePatchesMap(), o.blastRadius()) + if err != nil { + return nil, nil, err + } + evidenceMap = em + } + + verificationMap := map[string]map[string]any{} + if hasHighPriority(allFindings) && len(evidenceMap) > 0 && !o.budgetOrTimeoutExhausted("adversary") { + updated, vmap, err := o.runEvidenceVerification(ctx, allFindings, evidenceMap) + if err != nil { + return nil, nil, err + } + allFindings = updated + verificationMap = vmap + } + + var adversaryResults []schemas.AdversaryResult + if len(allFindings) > 0 && !o.budgetOrTimeoutExhausted("adversary") { + ar, err := o.runParallelAdversary(ctx, allFindings, evidenceMap, verificationMap) + if err != nil { + return nil, nil, err + } + adversaryResults = ar + } + + challenged := challengedTitles(adversaryResults) + var confirmed []schemas.ReviewFinding + for _, f := range allFindings { + if _, ok := challenged[f.Title]; !ok { + confirmed = append(confirmed, f) + } + } + + compound, err := o.runCompoundAnalysis(ctx, confirmed, evidenceMap) + if err != nil { + return nil, nil, err + } + allFindings = append(allFindings, compound...) + return allFindings, adversaryResults, nil +} + +// runEvidenceVerification ports _run_evidence_verification. +func (o *Orchestrator) runEvidenceVerification( + ctx context.Context, + findings []schemas.ReviewFinding, + evidenceMap map[string]evidence.EvidencePackage, +) ([]schemas.ReviewFinding, map[string]map[string]any, error) { + var highPriority []schemas.ReviewFinding + for _, f := range findings { + if f.Severity == "critical" || f.Severity == "important" { + highPriority = append(highPriority, f) + } + } + if len(highPriority) == 0 { + return findings, map[string]map[string]any{}, nil + } + + evPackages := map[string]map[string]any{} + for _, f := range highPriority { + if pkg, ok := evidenceMap[f.Title]; ok { + evPackages[f.Title] = evidencePackToMap(pkg) + } + } + var evArg map[string]map[string]any + if len(evPackages) > 0 { + evArg = evPackages + } + + raw, err := o.rfns.evidenceVerify(ctx, o.reasonerDeps(), reasoners.EvidenceVerifierInput{ + Findings: highPriority, + EvidencePackages: evArg, + PrContext: o.buildPRContextString(), + RepoPath: strp(o.input.RepoPath), + }) + if err != nil { + return nil, nil, err + } + o.incInvocations(1) + o.registerCost("adversary", raw) + + verificationMap := map[string]map[string]any{} + for _, vf := range asObjListStrict(raw, "verified_findings") { + title := getStr(vf, "title", "") + if title == "" { + continue + } + verificationMap[title] = vf + } + + updated := make([]schemas.ReviewFinding, 0, len(findings)) + for _, f := range findings { + vf, ok := verificationMap[f.Title] + if !ok { + updated = append(updated, f) + continue + } + verified := getBoolDefault(vf, "verified", true) + if !verified { + nf := f + conf := getFloatOr(vf, "revised_confidence", 0.3) + if conf < 0.1 { + conf = 0.1 + } + nf.Confidence = conf + nf.Severity = schemas.NormalizeSeverity(vf["revised_severity"], schemas.DefaultSeverity) + updated = append(updated, nf) + continue + } + nf := f + changed := false + if rc, present := vf["revised_confidence"]; present { + if c, ok := asFloat(rc); ok { + nf.Confidence = c + changed = true + } + } + if rs, present := vf["revised_severity"]; present { + if s, ok := rs.(string); ok && s != "" { + nf.Severity = schemas.NormalizeSeverity(s, schemas.DefaultSeverity) + changed = true + } + } + _ = changed + updated = append(updated, nf) + } + return updated, verificationMap, nil +} + +// runParallelAdversary ports _run_parallel_adversary (order-preserving batches). +func (o *Orchestrator) runParallelAdversary( + ctx context.Context, + findings []schemas.ReviewFinding, + evidenceMap map[string]evidence.EvidencePackage, + verificationMap map[string]map[string]any, +) ([]schemas.AdversaryResult, error) { + if len(findings) == 0 || o.budgetOrTimeoutExhausted("adversary") { + return nil, nil + } + aiConfidence := 0.0 + if o.intakeResult != nil { + aiConfidence = o.intakeResult.AIGenerated + } + + var batches [][]schemas.ReviewFinding + for i := 0; i < len(findings); i += adversaryBatchSize { + end := i + adversaryBatchSize + if end > len(findings) { + end = len(findings) + } + batches = append(batches, findings[i:end]) + if len(batches) >= maxAdversaryBatch { + break + } + } + + results := make([][]schemas.AdversaryResult, len(batches)) + g, gctx := errgroup.WithContext(ctx) + for i := range batches { + i := i + g.Go(func() error { + if o.budgetOrTimeoutExhausted("adversary") { + return nil + } + batch := batches[i] + batchEvidence := map[string]map[string]any{} + for _, f := range batch { + entry := map[string]any{} + if pkg, ok := evidenceMap[f.Title]; ok { + entry = evidencePackToMap(pkg) + } + if vf, ok := verificationMap[f.Title]; ok { + entry["verification"] = map[string]any{ + "verified": mapGet(vf, "verified", true), + "actual_behavior": getStr(vf, "actual_behavior", ""), + "verification_notes": getStr(vf, "verification_notes", ""), + } + } + if len(entry) > 0 { + batchEvidence[f.Title] = entry + } + } + var evArg map[string]map[string]any + if len(batchEvidence) > 0 { + evArg = batchEvidence + } + raw, err := o.rfns.adversary(gctx, o.reasonerDeps(), reasoners.AdversaryInput{ + Findings: batch, + AIGeneratedConfidence: aiConfidence, + PrContext: o.buildPRContextString(), + RepoPath: strp(o.input.RepoPath), + EvidencePackages: evArg, + }) + if err != nil { + return err + } + o.incInvocations(1) + o.registerCost("adversary", raw) + results[i] = extractAdversaryResults(raw) + return nil + }) + } + if err := g.Wait(); err != nil { + return nil, err + } + var all []schemas.AdversaryResult + for _, br := range results { + all = append(all, br...) + } + return all, nil +} + +// extractAdversaryResults ports _extract_adversary_results. +func extractAdversaryResults(resultRaw map[string]any) []schemas.AdversaryResult { + payload := unwrap(resultRaw) + var raw []map[string]any + if payload != nil { + for _, key := range []string{"results", "adversary_results", "findings"} { + if lst := asObjListStrict(payload, key); lst != nil { + raw = lst + break + } + } + } + out := make([]schemas.AdversaryResult, 0, len(raw)) + for _, item := range raw { + out = append(out, mapToStruct[schemas.AdversaryResult](item)) + } + return out +} + +// ---- Phase 6: COVERAGE LOOP ---- + +func (o *Orchestrator) runCoverageLoop( + ctx context.Context, + plan schemas.ReviewPlan, + anatomy schemas.AnatomyResult, + findings []schemas.ReviewFinding, + adversaryResults []schemas.AdversaryResult, +) ([]schemas.ReviewFinding, []schemas.AdversaryResult, error) { + for iter := 0; iter < o.config.Budget.MaxCoverageIterations; iter++ { + if o.budgetOrTimeoutExhausted("coverage") { + break + } + reviewedClusters := reviewedClusters(anatomy, findings) + dimensionNames := make([]string, 0, len(plan.Dimensions)) + for _, d := range plan.Dimensions { + dimensionNames = append(dimensionNames, d.Name) + } + gateRaw, err := o.rfns.coverageGate(ctx, o.reasonerDeps(), reasoners.CoverageGateInput{ + Anatomy: anatomy, + ReviewedClusters: reviewedClusters, + DimensionNamesReviewed: dimensionNames, + }) + if err != nil { + return nil, nil, err + } + o.incInvocations(1) + o.registerCost("coverage", gateRaw) + + fullyCovered := getBoolDefault(gateRaw, "fully_covered", false) + confident := getBoolDefault(gateRaw, "confident", true) + gapDescriptions := getStrSlice(gateRaw, "gap_descriptions") + o.coverageIterations++ + + if fullyCovered || !confident || len(gapDescriptions) == 0 { + break + } + gapDims := buildGapDimensions(anatomy, gapDescriptions, reviewedClusters) + if len(gapDims) == 0 { + break + } + + gapFindings, err := o.collectParallelReview(ctx, schemas.ReviewPlan{ + Dimensions: gapDims, + CrossRefHints: plan.CrossRefHints, + }, "") + if err != nil { + return nil, nil, err + } + findings = append(findings, gapFindings...) + + gapEvidence := map[string]evidence.EvidencePackage{} + if len(findings) > 0 && strp(o.input.RepoPath) != "" { + em, err := evidence.ExtractEvidenceForFindings(ctx, findings, strp(o.input.RepoPath), o.filePatchesMap(), o.blastRadius()) + if err != nil { + return nil, nil, err + } + gapEvidence = em + } + if len(findings) > 0 && !o.budgetOrTimeoutExhausted("adversary") { + ar, err := o.runParallelAdversary(ctx, findings, gapEvidence, map[string]map[string]any{}) + if err != nil { + return nil, nil, err + } + adversaryResults = ar + } + challenged := challengedTitles(adversaryResults) + var kept []schemas.ReviewFinding + for _, f := range findings { + if _, ok := challenged[f.Title]; !ok { + kept = append(kept, f) + } + } + findings = kept + } + return findings, adversaryResults, nil +} + +// reviewedClusters ports _reviewed_clusters. +func reviewedClusters(anatomy schemas.AnatomyResult, findings []schemas.ReviewFinding) []string { + findingPaths := map[string]struct{}{} + for _, f := range findings { + if f.FilePath != "" { + findingPaths[f.FilePath] = struct{}{} + } + } + reviewed := map[string]struct{}{} + for _, cluster := range anatomy.Clusters { + for _, path := range cluster.Files { + if _, ok := findingPaths[path]; ok { + reviewed[cluster.ID] = struct{}{} + break + } + } + } + out := make([]string, 0, len(reviewed)) + for id := range reviewed { + out = append(out, id) + } + sort.Strings(out) + return out +} + +// buildGapDimensions ports _build_gap_dimensions. +func buildGapDimensions(anatomy schemas.AnatomyResult, gapDescriptions, reviewedClusters []string) []schemas.ReviewDimension { + reviewed := stringSet(reviewedClusters) + var candidates []schemas.ChangeCluster + for _, c := range anatomy.Clusters { + if _, ok := reviewed[c.ID]; !ok { + candidates = append(candidates, c) + } + } + if len(candidates) == 0 { + return nil + } + var dims []schemas.ReviewDimension + for idx, gap := range gapDescriptions { + if idx >= len(candidates) { + break + } + cluster := candidates[idx] + dims = append(dims, schemas.ReviewDimension{ + ID: "coverage_gap_" + itoa(idx), + Name: "Coverage Gap " + itoa(idx+1), + ReviewPrompt: prompts.CoverageGapPrompt(gap), + TargetFiles: cluster.Files, + ContextFiles: []string{}, + Priority: 1, + }) + } + return dims +} + +// ---- Phase 6.7: CONSISTENCY VERIFY ---- + +func (o *Orchestrator) runConsistencyVerify(ctx context.Context, allFindings []schemas.ReviewFinding) ([]schemas.ReviewFinding, error) { + if o.budgetOrTimeoutExhausted("review") { + return allFindings, nil + } + diffPatches := reasoners.OrderedPatches(o.filePatches()) + if len(diffPatches) == 0 { + return allFindings, nil + } + repo := strp(o.input.RepoPath) + + obRaw, err := o.rfns.extractOblig(ctx, o.reasonerDeps(), reasoners.ExtractObligationsInput{ + DiffPatches: diffPatches, + RepoPath: repo, + PrContext: o.buildPRContextString(), + }) + if err != nil { + // Python: except → skip, return existing findings unchanged. + return allFindings, nil + } + o.incInvocations(1) + o.registerCost("review", obRaw) + + obligations := asObjListStrict(obRaw, "obligations") + if len(obligations) > 12 { + obligations = obligations[:12] + } + if len(obligations) == 0 { + return allFindings, nil + } + + // Order-preserving fan-out: one verify_obligation per obligation. + verdicts := make([]map[string]any, len(obligations)) + var wg sync.WaitGroup + for i := range obligations { + i := i + wg.Add(1) + go func() { + defer wg.Done() + raw, err := o.rfns.verifyOblig(ctx, o.reasonerDeps(), reasoners.VerifyObligationInput{ + Obligation: obligations[i], + RepoPath: repo, + }) + if err != nil { + verdicts[i] = map[string]any{"holds": true} + return + } + verdicts[i] = raw + }() + } + wg.Wait() + o.incInvocations(len(verdicts)) + + var newFindings []schemas.ReviewFinding + for _, v := range verdicts { + if v == nil { + continue + } + holds, present := v["holds"] + if !present || holds != false || getStr(v, "title", "") == "" { + continue + } + lineEndVal := getIntOrZero(v, "line_end") + if lineEndVal == 0 { + lineEndVal = getIntOrZero(v, "line_start") + } + normalized := map[string]any{ + "dimension_id": "consistency-verify", + "dimension_name": "Consistency Verifier", + "file_path": getStr(v, "file_path", ""), + "line_start": getIntOrZero(v, "line_start"), + "line_end": lineEndVal, + "severity": severityOr(v, "important"), + "title": getStr(v, "title", ""), + "body": getStr(v, "body", ""), + "suggestion": v["suggestion"], + "evidence": getStr(v, "evidence", ""), + "confidence": getFloatOr(v, "confidence", 0.7), + "tags": []string{"consistency"}, + } + newFindings = append(newFindings, mapToStruct[schemas.ReviewFinding](normalized)) + } + return append(allFindings, newFindings...), nil +} + +// ---- shared phase helpers ---- + +func (o *Orchestrator) filePatches() []prompts.StrPair { + o.mu.Lock() + defer o.mu.Unlock() + if o.patchesCacheSet { + return o.patchesCache + } + var pairs []prompts.StrPair + if o.prData != nil { + idx := map[string]int{} + for _, cf := range o.prData.ChangedFiles { + if cf.Patch == "" { + continue + } + if i, ok := idx[cf.Path]; ok { + pairs[i].Val = cf.Patch + continue + } + idx[cf.Path] = len(pairs) + pairs = append(pairs, prompts.StrPair{Key: cf.Path, Val: cf.Patch}) + } + } + o.patchesCache = pairs + o.patchesCacheSet = true + return pairs +} + +func (o *Orchestrator) filePatchesMap() map[string]string { + pairs := o.filePatches() + m := make(map[string]string, len(pairs)) + for _, p := range pairs { + m[p.Key] = p.Val + } + return m +} + +func (o *Orchestrator) blastRadius() []string { + if o.anatomyResult != nil { + return o.anatomyResult.BlastRadius + } + return nil +} + +// buildPRContextString ports _build_pr_context_string. +func (o *Orchestrator) buildPRContextString() string { + var parts []string + if o.intakeResult != nil { + parts = append(parts, "PR Type: "+o.intakeResult.PrType) + parts = append(parts, "Complexity: "+o.intakeResult.Complexity) + parts = append(parts, "Summary: "+o.intakeResult.PrSummary) + if len(o.intakeResult.RiskSignals) > 0 { + parts = append(parts, "Risk Signals: "+strings.Join(o.intakeResult.RiskSignals, ", ")) + } + } + if o.anatomyResult != nil { + parts = append(parts, "PR Narrative: "+o.anatomyResult.PrNarrative) + if len(o.anatomyResult.IntentGaps) > 0 { + parts = append(parts, "Intent Gaps: "+strings.Join(o.anatomyResult.IntentGaps, ", ")) + } + } + return strings.Join(parts, "\n") +} + +// cleanupContextDir ports _cleanup_context_dir. +func (o *Orchestrator) cleanupContextDir() { + repoPath := strp(o.input.RepoPath) + if repoPath == "" { + return + } + ctxDir := filepath.Join(repoPath, ".pr-af-context") + if info, err := os.Stat(ctxDir); err == nil && info.IsDir() { + _ = os.RemoveAll(ctxDir) + } +} + +func toChangedFiles(files []schemas.FileChange) []schemas.ChangedFile { + out := make([]schemas.ChangedFile, 0, len(files)) + for _, fc := range files { + hunks := make([]string, 0, len(fc.Hunks)) + for _, h := range fc.Hunks { + hunks = append(hunks, h.Content) + } + out = append(out, schemas.ChangedFile{ + Path: fc.Path, + Status: fc.Status, + Additions: fc.LinesAdded, + Deletions: fc.LinesRemoved, + Patch: strings.Join(hunks, "\n\n"), + }) + } + return out +} + +func hasHighPriority(findings []schemas.ReviewFinding) bool { + for _, f := range findings { + if f.Severity == "critical" || f.Severity == "important" { + return true + } + } + return false +} + +func challengedTitles(results []schemas.AdversaryResult) map[string]struct{} { + out := map[string]struct{}{} + for _, ar := range results { + if ar.Verdict == "challenged" { + out[ar.FindingTitle] = struct{}{} + } + } + return out +} + +func evidencePackToMap(pkg evidence.EvidencePackage) map[string]any { + m, _ := structToMap(&pkg) + return m +} diff --git a/go/internal/orch/resolve.go b/go/internal/orch/resolve.go new file mode 100644 index 0000000..b68a419 --- /dev/null +++ b/go/internal/orch/resolve.go @@ -0,0 +1,224 @@ +package orch + +// resolve.go ports the repo-resolution / PR-branch-checkout code that lives in +// src/pr_af/app.py (_resolve_repo, _checkout_pr_branch, _extract_pr_number) plus +// the orchestrator's _compute_repo_diff. Python's github/client.py::clone_repo +// was deliberately NOT ported into internal/github, so the clone + checkout +// semantics live here and shell out to git directly, tokenizing the remote with +// GH_TOKEN exactly as Python does. +// +// The verbatim error strings (design §B.4) are reproduced for the fetch/checkout +// failures so callers (and tests) see byte-identical messages. + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" +) + +// git subprocess timeouts, matching app.py's subprocess.run(..., timeout=…). +const ( + cloneTimeout = 600 * time.Second // large repos need time + fetchAllTimeout = 600 * time.Second // reused workspace refresh + prFetchTimeout = 300 * time.Second // fetch of the PR head + checkoutTimeout = 30 * time.Second + diffTimeout = 120 * time.Second +) + +// gitEnv reproduces app.py's git_env: the process environment plus +// GIT_TERMINAL_PROMPT=0 and GIT_ASKPASS=echo so a missing credential fails fast +// instead of blocking on an interactive prompt. +func gitEnv() []string { + return append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=echo") +} + +// runGit executes a git command with a hard timeout and returns stdout, stderr, +// and the error. dir is passed via -C by the caller (kept out of here so the +// clone command — which has no -C — works too). +func runGit(parent context.Context, timeout time.Duration, args ...string) (stdout, stderr string, err error) { + ctx, cancel := context.WithTimeout(parent, timeout) + defer cancel() + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Env = gitEnv() + var outBuf, errBuf strings.Builder + cmd.Stdout = &outBuf + cmd.Stderr = &errBuf + err = cmd.Run() + return outBuf.String(), errBuf.String(), err +} + +// ExtractPRNumber ports app.py::_extract_pr_number: the integer following +// "/pull/" in a github.com URL, or (0, false) when absent/unparsable. +func ExtractPRNumber(prURL string) (int, bool) { + if !strings.Contains(prURL, "github.com") || !strings.Contains(prURL, "/pull/") { + return 0, false + } + tail := prURL[strings.LastIndex(prURL, "/pull/")+len("/pull/"):] + // split on "/" and strip, matching .split("/")[0].strip("/"). + seg := tail + if i := strings.Index(seg, "/"); i >= 0 { + seg = seg[:i] + } + seg = strings.Trim(seg, "/") + n, convErr := strconv.Atoi(seg) + if convErr != nil { + return 0, false + } + return n, true +} + +// checkoutPRBranch ports app.py::_checkout_pr_branch. It fetches the PR head into +// FETCH_HEAD (which always succeeds, even when the workspace is reused and +// pr-review is the current branch) and then checkout -B (re)points pr-review at +// it — the fix for the silent "reused workspace reviews the first PR forever" +// bug. The two failure strings are the §B.4 verbatim contracts. +func checkoutPRBranch(ctx context.Context, targetDir string, prNumber int) error { + _, stderr, err := runGit(ctx, prFetchTimeout, + "-C", targetDir, "fetch", "--depth", "1", "origin", fmt.Sprintf("pull/%d/head", prNumber)) + if err != nil { + return fmt.Errorf("git fetch of PR #%d head failed: %s", prNumber, strings.TrimSpace(stderr)) + } + _, stderr, err = runGit(ctx, checkoutTimeout, + "-C", targetDir, "checkout", "-B", "pr-review", "FETCH_HEAD") + if err != nil { + return fmt.Errorf("git checkout of PR #%d (pr-review) failed: %s", prNumber, strings.TrimSpace(stderr)) + } + return nil +} + +// ResolveRepo ports app.py::_resolve_repo. It resolves repoPath / prURL to a +// local directory: an existing dir is returned as-is (resolved absolute), an +// http(s)/git@ URL is (shallow) cloned into $PR_AF_WORKDIR (with GH_TOKEN +// injected into a github.com HTTPS remote) and the PR branch checked out when a +// PR number is known, and anything else falls back to $PR_AF_REPO_PATH / cwd. +// +// Empty strings stand in for Python's None. Errors mirror Python's ValueError +// ("git clone failed: …", plus the checkout strings via checkoutPRBranch). +func ResolveRepo(ctx context.Context, repoPath, prURL string) (string, error) { + workdir := os.Getenv("PR_AF_WORKDIR") + if workdir == "" { + workdir = "/workspaces" + } + target := repoPath + prNumber := 0 + hasPR := false + + if target == "" && strings.Contains(prURL, "github.com") && strings.Contains(prURL, "/pull/") { + // parts = pr_url.split("github.com/")[-1].split("/pull/")[0].strip("/") + afterHost := prURL[strings.LastIndex(prURL, "github.com/")+len("github.com/"):] + parts := afterHost + if i := strings.Index(parts, "/pull/"); i >= 0 { + parts = parts[:i] + } + parts = strings.Trim(parts, "/") + if strings.Count(parts, "/") == 1 { + target = fmt.Sprintf("https://github.com/%s.git", parts) + } + prNumber, hasPR = ExtractPRNumber(prURL) + } + + // Existing directory → return resolved absolute path. + if target != "" && isDir(target) { + abs, err := filepath.Abs(target) + if err != nil { + return target, nil + } + return abs, nil + } + + // Remote URL → clone (or refresh) into the workspace. + if target != "" && (strings.HasPrefix(target, "https://") || + strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "git@")) { + repoName := strings.TrimSuffix(lastSegment(strings.TrimRight(target, "/")), ".git") + targetDir := filepath.Join(workdir, repoName) + if err := os.MkdirAll(workdir, 0o755); err != nil { + return "", fmt.Errorf("git clone failed: %s", strings.TrimSpace(err.Error())) + } + + cloneURL := target + ghToken := os.Getenv("GH_TOKEN") + if ghToken != "" && strings.HasPrefix(cloneURL, "https://github.com/") { + cloneURL = strings.Replace(cloneURL, "https://github.com/", + fmt.Sprintf("https://%s@github.com/", ghToken), 1) + } + + if isDir(targetDir) && isDir(filepath.Join(targetDir, ".git")) { + // Reused workspace: refresh all refs (errors swallowed, as Python does). + _, _, _ = runGit(ctx, fetchAllTimeout, "-C", targetDir, "fetch", "--all") + } else { + cloneCmd := []string{"clone", "--depth", "1", "--no-tags", cloneURL, targetDir} + if hasPR && prNumber != 0 { + // Skip default-branch checkout; the PR ref is fetched next. + cloneCmd = []string{"clone", "--depth", "1", "--no-tags", "--no-checkout", cloneURL, targetDir} + } + _, stderr, err := runGit(ctx, cloneTimeout, cloneCmd...) + if err != nil { + return "", fmt.Errorf("git clone failed: %s", strings.TrimSpace(stderr)) + } + } + + if hasPR && prNumber != 0 { + if err := checkoutPRBranch(ctx, targetDir, prNumber); err != nil { + return "", err + } + } + return targetDir, nil + } + + // Fallback: PR_AF_REPO_PATH or cwd. + fallback := os.Getenv("PR_AF_REPO_PATH") + if fallback == "" { + if cwd, err := os.Getwd(); err == nil { + fallback = cwd + } + } + abs, err := filepath.Abs(fallback) + if err != nil { + return fallback, nil + } + return abs, nil +} + +// computeRepoDiff ports orchestrator._compute_repo_diff: a `git diff` over a +// revision range derived from base/head refs. A non-zero exit is a ValueError in +// Python → wrapped in ErrBadInput here (the review()-caught 400 class). +func computeRepoDiff(ctx context.Context, repoPath, baseRef, headRef string) (string, error) { + if headRef != "" && baseRef == "" { + baseRef = "HEAD" + } + var revision string + switch { + case baseRef != "" && headRef != "": + revision = fmt.Sprintf("%s...%s", baseRef, headRef) + case baseRef != "": + revision = fmt.Sprintf("%s...HEAD", baseRef) + default: + revision = "HEAD~1...HEAD" + } + stdout, stderr, err := runGit(ctx, diffTimeout, "-C", repoPath, "diff", "--no-color", revision) + if err != nil { + msg := strings.TrimSpace(stderr) + if msg == "" { + msg = "Failed to compute git diff" + } + return "", badInput(msg) + } + return stdout, nil +} + +func isDir(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} + +func lastSegment(path string) string { + if i := strings.LastIndex(path, "/"); i >= 0 { + return path[i+1:] + } + return path +} diff --git a/go/internal/orch/resolve_test.go b/go/internal/orch/resolve_test.go new file mode 100644 index 0000000..325e450 --- /dev/null +++ b/go/internal/orch/resolve_test.go @@ -0,0 +1,156 @@ +package orch + +// V8: PR-branch checkout is idempotent — a reused workspace re-points the +// pr-review branch to the NEW PR head (the regression that previously reviewed +// every PR against the first one), and an unfetchable ref surfaces the exact +// "git fetch of PR #N head failed: …" error. Derived from tests/test_resolve_repo.py. + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func git(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=echo") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s (in %s): %v\n%s", strings.Join(args, " "), dir, err, out) + } + return strings.TrimSpace(string(out)) +} + +func makeUpstream(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatal(err) + } + git(t, path, "init", "-q", "-b", "main") + git(t, path, "config", "user.email", "test@example.com") + git(t, path, "config", "user.name", "test") + git(t, path, "config", "commit.gpgsign", "false") + if err := os.WriteFile(filepath.Join(path, "marker.txt"), []byte("main\n"), 0o644); err != nil { + t.Fatal(err) + } + git(t, path, "add", "-A") + git(t, path, "commit", "-qm", "initial") +} + +func publishPRRef(t *testing.T, upstream string, prNumber int, content string) { + t.Helper() + branch := "_pr" + itoa(prNumber) + git(t, upstream, "checkout", "-q", "-b", branch, "main") + if err := os.WriteFile(filepath.Join(upstream, "marker.txt"), []byte(content+"\n"), 0o644); err != nil { + t.Fatal(err) + } + git(t, upstream, "commit", "-qam", "pr"+itoa(prNumber)) + sha := git(t, upstream, "rev-parse", "HEAD") + git(t, upstream, "update-ref", "refs/pull/"+itoa(prNumber)+"/head", sha) + git(t, upstream, "checkout", "-q", "main") + git(t, upstream, "branch", "-qD", branch) +} + +func cloneWorkspace(t *testing.T, upstream, target string) { + t.Helper() + git(t, filepath.Dir(upstream), "clone", "--depth", "1", "--no-tags", "--no-checkout", upstream, target) +} + +func TestCheckoutReusedWorkspaceUpdatesToNewPRHead(t *testing.T) { + tmp := t.TempDir() + upstream := filepath.Join(tmp, "upstream") + makeUpstream(t, upstream) + publishPRRef(t, upstream, 1, "pr1") + + target := filepath.Join(tmp, "workspace") + cloneWorkspace(t, upstream, target) + marker := filepath.Join(target, "marker.txt") + + if err := checkoutPRBranch(context.Background(), target, 1); err != nil { + t.Fatalf("first checkout: %v", err) + } + if got := readFile(t, marker); got != "pr1\n" { + t.Fatalf("after PR #1: marker = %q, want %q", got, "pr1\n") + } + if got := git(t, target, "rev-parse", "--abbrev-ref", "HEAD"); got != "pr-review" { + t.Fatalf("branch = %q, want pr-review", got) + } + + // Second PR arrives; the workspace is reused (pr-review still checked out). + publishPRRef(t, upstream, 2, "pr2") + git(t, target, "fetch", "--all") // what ResolveRepo does for a reused dir + + if err := checkoutPRBranch(context.Background(), target, 2); err != nil { + t.Fatalf("second checkout: %v", err) + } + if got := readFile(t, marker); got != "pr2\n" { + t.Fatalf("regression: after PR #2 marker = %q, want %q (still on PR #1?)", got, "pr2\n") + } +} + +func TestCheckoutFreshWorkspaceReflectsPRHead(t *testing.T) { + tmp := t.TempDir() + upstream := filepath.Join(tmp, "upstream") + makeUpstream(t, upstream) + publishPRRef(t, upstream, 7, "pr7") + + target := filepath.Join(tmp, "workspace") + cloneWorkspace(t, upstream, target) + + if err := checkoutPRBranch(context.Background(), target, 7); err != nil { + t.Fatalf("checkout: %v", err) + } + if got := readFile(t, filepath.Join(target, "marker.txt")); got != "pr7\n" { + t.Fatalf("marker = %q, want %q", got, "pr7\n") + } +} + +func TestCheckoutRaisesOnUnfetchablePRRef(t *testing.T) { + tmp := t.TempDir() + upstream := filepath.Join(tmp, "upstream") + makeUpstream(t, upstream) + + target := filepath.Join(tmp, "workspace") + cloneWorkspace(t, upstream, target) + + err := checkoutPRBranch(context.Background(), target, 999) + if err == nil { + t.Fatal("expected error for unfetchable PR ref") + } + if !strings.HasPrefix(err.Error(), "git fetch of PR #999 head failed:") { + t.Fatalf("error = %q, want prefix %q", err.Error(), "git fetch of PR #999 head failed:") + } +} + +func TestExtractPRNumber(t *testing.T) { + cases := []struct { + url string + want int + ok bool + }{ + {"https://github.com/o/r/pull/42", 42, true}, + {"https://github.com/o/r/pull/42/files", 42, true}, + {"https://github.com/o/r/issues/42", 0, false}, + {"not a url", 0, false}, + } + for _, c := range cases { + got, ok := ExtractPRNumber(c.url) + if got != c.want || ok != c.ok { + t.Errorf("ExtractPRNumber(%q) = (%d,%v), want (%d,%v)", c.url, got, ok, c.want, c.ok) + } + } +} + +func readFile(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(b) +} diff --git a/go/internal/orch/streaming_test.go b/go/internal/orch/streaming_test.go new file mode 100644 index 0000000..5a9bdee --- /dev/null +++ b/go/internal/orch/streaming_test.go @@ -0,0 +1,114 @@ +package orch + +// Streaming-path parity (design risk 1): the gate-OFF default runs the reviewers +// and the layer CONCURRENTLY through one channel — the layer must consume as +// reviewers complete, not after all of them finish. Order-preservation parity +// (risk 2): gather-based fan-outs (meta-selectors) write into a pre-indexed +// slice, so the output order matches the input order regardless of the order +// goroutines actually complete in. + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/Agent-Field/pr-af/go/internal/config" + "github.com/Agent-Field/pr-af/go/internal/reasoners" + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +func TestStreamingLayerConsumesWhileReviewersRun(t *testing.T) { + o := New(Deps{App: &fakeApp{}}, schemas.ReviewInput{}, config.DefaultReviewConfig()) + + // The third reviewer blocks until the layer signals it has consumed a batch + // from one of the first two — provable only if the layer streams. A + // gather-then-process layer would never consume until every reviewer finished, + // but the third reviewer waits on that consumption → deadlock → test timeout. + release := make(chan struct{}) + var once sync.Once + o.rfns.reviewDim = func(_ context.Context, _ reasoners.Deps, in reasoners.ReviewDimensionInput) (map[string]any, error) { + if in.ReviewPrompt == "block" { + <-release + } + return map[string]any{ + "findings": []any{map[string]any{ + "title": "t-" + in.ReviewPrompt, "file_path": in.ReviewPrompt + ".py", + "line_start": 1, "severity": "suggestion", + }}, + "sub_reviews": []any{}, + "current_depth": 0, + }, nil + } + o.rfns.adversary = func(context.Context, reasoners.Deps, reasoners.AdversaryInput) (map[string]any, error) { + return map[string]any{"results": []any{}}, nil + } + o.layerBatchHook = func([]schemas.ReviewFinding) { + once.Do(func() { close(release) }) + } + + plan := schemas.ReviewPlan{Dimensions: []schemas.ReviewDimension{ + {ID: "d0", Name: "D0", ReviewPrompt: "a", TargetFiles: []string{"a.py"}}, + {ID: "d1", Name: "D1", ReviewPrompt: "b", TargetFiles: []string{"b.py"}}, + {ID: "d2", Name: "D2", ReviewPrompt: "block", TargetFiles: []string{"c.py"}}, + }} + + done := make(chan error, 1) + go func() { + _, _, err := o.streamReviewLayer(context.Background(), plan, schemas.AnatomyResult{}, "") + done <- err + }() + select { + case err := <-done: + if err != nil { + t.Fatalf("streamReviewLayer: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("deadlock: layer did not consume until all reviewers finished (not streaming)") + } +} + +func metaResultMap(lens, dimID, target string) map[string]any { + return map[string]any{ + "lens": lens, + "dimensions": []any{map[string]any{ + "id": dimID, "name": "N-" + dimID, "target_files": []any{target}, "priority": 1, + }}, + "confidence": 0.9, + "rationale": "", + } +} + +func TestMetaSelectorOrderPreservedUnderAdversarialScheduling(t *testing.T) { + for iter := 0; iter < 8; iter++ { + o := New(Deps{App: &fakeApp{}}, schemas.ReviewInput{}, config.DefaultReviewConfig()) + + // Force out-of-order completion: systemic finishes first, semantic last. + o.rfns.metaSemantic = func(context.Context, reasoners.Deps, reasoners.MetaInput) (map[string]any, error) { + time.Sleep(12 * time.Millisecond) + return metaResultMap("semantic", "a", "fa"), nil + } + o.rfns.metaMechanical = func(context.Context, reasoners.Deps, reasoners.MetaInput) (map[string]any, error) { + time.Sleep(6 * time.Millisecond) + return metaResultMap("mechanical", "b", "fb"), nil + } + o.rfns.metaSystemic = func(context.Context, reasoners.Deps, reasoners.MetaInput) (map[string]any, error) { + return metaResultMap("systemic", "c", "fc"), nil + } + + plan, err := o.runMetaSelectors(context.Background(), schemas.IntakeResult{}, schemas.AnatomyResult{}, "standard", "") + if err != nil { + t.Fatalf("iter %d: runMetaSelectors: %v", iter, err) + } + want := []string{"semantic_a", "mechanical_b", "systemic_c"} + if len(plan.Dimensions) != len(want) { + t.Fatalf("iter %d: got %d dimensions, want %d", iter, len(plan.Dimensions), len(want)) + } + for i, w := range want { + if plan.Dimensions[i].ID != w { + t.Fatalf("iter %d: dimension[%d].ID = %q, want %q (fan-out order not preserved)", + iter, i, plan.Dimensions[i].ID, w) + } + } + } +} diff --git a/go/internal/orch/synthesis.go b/go/internal/orch/synthesis.go new file mode 100644 index 0000000..be91ceb --- /dev/null +++ b/go/internal/orch/synthesis.go @@ -0,0 +1,29 @@ +package orch + +// synthesis.go ports Phase 7 (_synthesize): deterministic dedup → scoring → +// truncate to max_comments. + +import ( + "github.com/Agent-Field/pr-af/go/internal/schemas" + "github.com/Agent-Field/pr-af/go/internal/scoring" +) + +func (o *Orchestrator) synthesize( + findings []schemas.ReviewFinding, + adversaryResults []schemas.AdversaryResult, +) []schemas.ScoredFinding { + deduped := scoring.DeduplicateExact(findings) + aiGen := 0.0 + if o.intakeResult != nil { + aiGen = o.intakeResult.AIGenerated + } + blastSize := 0 + if o.anatomyResult != nil { + blastSize = len(o.anatomyResult.BlastRadius) + } + scored := scoring.ScoreFindings(deduped, adversaryResults, o.config.Scoring, aiGen, blastSize) + if len(scored) > o.config.Comments.MaxComments { + scored = scored[:o.config.Comments.MaxComments] + } + return scored +} diff --git a/go/internal/orch/testdata/gen_result_keys.py b/go/internal/orch/testdata/gen_result_keys.py new file mode 100644 index 0000000..751fa74 --- /dev/null +++ b/go/internal/orch/testdata/gen_result_keys.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python +"""Emit the ReviewResult.model_dump() key structure (per §B.2) as JSON. + +The Go marshal-parity test builds an equivalent ReviewResult, marshals it, and +asserts the key set at each level matches these Python-derived keys exactly — +catching any accidental omitempty (dropped key) or stray extra field. + +Regenerate: + PYTHONPATH=/home/abir/gb/pr-af/src /bin/python \ + go/internal/orch/testdata/gen_result_keys.py +""" +from __future__ import annotations + +import json +import os +import sys + +sys.path.insert(0, "/home/abir/gb/pr-af/src") + +from pr_af.schemas.input import GitHubPRData +from pr_af.schemas.output import ( + GitHubComment, + GitHubReview, + ReviewMetadata, + ReviewResult, + ReviewSummary, + ScoredFinding, +) +from pr_af.schemas.pipeline import ( + AnatomyResult, + DiffStats, + IntakeResult, + ReviewPlan, +) + + +def keys(d: dict) -> list[str]: + return sorted(d.keys()) + + +def main() -> None: + finding = ScoredFinding( + id="f_000", + dimension_id="d1", + dimension_name="Dim", + file_path="src/a.py", + line_start=1, + line_end=1, + severity="important", + title="t", + body="b", + ) + comment = GitHubComment(path="src/a.py", line=1, body="c") + review = GitHubReview(body="body", event="COMMENT", comments=[comment]) + summary = ReviewSummary(total_findings=1, by_severity={"important": 1}) + intake = IntakeResult( + pr_type="feature", + complexity="standard", + languages=[], + areas_touched=[], + risk_signals=[], + ai_generated=0.0, + review_depth="standard", + pr_summary="s", + ) + anatomy = AnatomyResult( + files=[], + clusters=[], + blast_radius=[], + dependency_graph={}, + stats=DiffStats(), + pr_narrative="", + risk_surfaces=[], + unrelated_changes=[], + intent_gaps=[], + context_notes="", + ) + plan = ReviewPlan(dimensions=[], cross_ref_hints=[]) + metadata = ReviewMetadata( + intake=intake.model_dump(), + anatomy=anatomy.model_dump(), + plan=plan.model_dump(), + budget={ + "total_cost_usd": 0.0, + "cost_breakdown": {}, + "budget_exhausted": False, + "max_cost_usd": 2.0, + "max_duration_seconds": 300, + }, + agent_invocations=1, + phases_completed=[], + ) + result = ReviewResult( + review_id="rev_x", + pr_url="", + review=review, + findings=[finding], + summary=summary, + metadata=metadata, + ) + dump = result.model_dump() + _ = GitHubPRData # imported for parity of the module surface + + out = { + "result": keys(dump), + "review": keys(dump["review"]), + "comment": keys(dump["review"]["comments"][0]), + "finding": keys(dump["findings"][0]), + "summary": keys(dump["summary"]), + "metadata": keys(dump["metadata"]), + "intake": keys(dump["metadata"]["intake"]), + "anatomy": keys(dump["metadata"]["anatomy"]), + "plan": keys(dump["metadata"]["plan"]), + "budget": keys(dump["metadata"]["budget"]), + } + path = os.path.join(os.path.dirname(__file__), "result_keys.json") + with open(path, "w", encoding="utf-8") as f: + json.dump(out, f, indent=2, sort_keys=True) + f.write("\n") + print(f"wrote {path}") + + +if __name__ == "__main__": + main() diff --git a/go/internal/orch/testdata/gen_summary_golden.py b/go/internal/orch/testdata/gen_summary_golden.py new file mode 100644 index 0000000..94fe909 --- /dev/null +++ b/go/internal/orch/testdata/gen_summary_golden.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python +"""Generate the byte-exact summary-Markdown golden for the Go orch output test. + +Runs the REAL Python ReviewOrchestrator._format_summary against a fixed fixture +with a controlled clock/review_id, writing testdata/summary_golden.txt. The Go +test builds the identical fixture and asserts its formatSummary output matches +these bytes exactly (validation contract V11 / design risk 3: float + Markdown +parity). + +Regenerate: + PYTHONPATH=/home/abir/gb/pr-af/src \ + /bin/python go/internal/orch/testdata/gen_summary_golden.py +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, "/home/abir/gb/pr-af/src") + +import pr_af.orchestrator as orch_mod +from pr_af.orchestrator import ReviewOrchestrator +from pr_af.schemas.input import ReviewInput +from pr_af.schemas.output import ScoredFinding +from pr_af.schemas.pipeline import ( + IntakeResult, + MetaDimensionResult, + ReviewDimension, + ReviewPlan, +) + + +def _dim(did: str, name: str, targets: list[str]) -> ReviewDimension: + return ReviewDimension(id=did, name=name, review_prompt="", target_files=targets) + + +def main() -> None: + orch = ReviewOrchestrator(app=object(), input=ReviewInput()) + orch.review_id = "rev_abc123def456" + orch.cross_ref_count = 1 + orch.adversary_confirmed_count = 2 + orch.adversary_challenged_count = 1 + orch.coverage_iterations = 1 + orch.agent_invocations = 7 + orch.total_cost_usd = 0.0 + orch.budget_exhausted = False + orch.meta_selector_results = [ + MetaDimensionResult( + lens="semantic", + dimensions=[_dim("s1", "A", []), _dim("s2", "B", [])], + confidence=0.8, + ), + MetaDimensionResult(lens="mechanical", dimensions=[_dim("m1", "C", [])], confidence=0.6), + ] + + # Controlled clock: started_at 0.0, time.monotonic() == 12.3 → duration 12.3s. + orch.started_at = 0.0 + orch_mod.time.monotonic = lambda: 12.3 + + findings = [ + ScoredFinding( + id="f_000", + dimension_id="d1", + dimension_name="Security", + file_path="src/a.py", + line_start=10, + line_end=10, + severity="critical", + title="Null dereference", + body="The value can be None. Handle it before use.", + confidence=0.9, + blocking=True, + blocking_reason="Crashes on null input.", + ), + ScoredFinding( + id="f_001", + dimension_id="d2", + dimension_name="Performance", + file_path="src/b.py", + line_start=20, + line_end=25, + severity="important", + title="Quadratic loop", + body="This nested loop is O(n^2) and will not scale.", + confidence=0.7, + blocking=False, + ), + ScoredFinding( + id="f_002", + dimension_id="d3", + dimension_name="Style", + file_path="", + line_start=0, + line_end=0, + severity="nitpick", + title="Typo in comment", + body="Fix the spelling.", + confidence=0.5, + blocking=False, + ), + ] + intake = IntakeResult( + pr_type="feature", + complexity="standard", + languages=["python"], + areas_touched=["api"], + risk_signals=[], + ai_generated=0.1, + review_depth="standard", + pr_summary="Adds a caching layer to the API.", + ) + plan = ReviewPlan( + dimensions=[ + _dim("sec", "Security", ["src/a.py"]), + _dim("perf", "Performance", ["src/b.py", "src/c.py"]), + ], + cross_ref_hints=[], + ) + + body = orch._format_summary( + findings=findings, review_event="REQUEST_CHANGES", intake=intake, plan=plan + ) + out = os.path.join(os.path.dirname(__file__), "summary_golden.txt") + with open(out, "w", encoding="utf-8") as f: + f.write(body) + print(f"wrote {out} ({len(body.encode('utf-8'))} bytes)") + + +if __name__ == "__main__": + main() diff --git a/go/internal/orch/testdata/result_keys.json b/go/internal/orch/testdata/result_keys.json new file mode 100644 index 0000000..ad3524c --- /dev/null +++ b/go/internal/orch/testdata/result_keys.json @@ -0,0 +1,100 @@ +{ + "anatomy": [ + "blast_radius", + "clusters", + "context_notes", + "dependency_graph", + "files", + "intent_gaps", + "pr_narrative", + "risk_surfaces", + "stats", + "unrelated_changes" + ], + "budget": [ + "budget_exhausted", + "cost_breakdown", + "max_cost_usd", + "max_duration_seconds", + "total_cost_usd" + ], + "comment": [ + "body", + "line", + "path", + "side" + ], + "finding": [ + "active_multipliers", + "blocking", + "blocking_reason", + "body", + "confidence", + "diff_line", + "diff_side", + "dimension_id", + "dimension_name", + "evidence", + "file_path", + "id", + "line_end", + "line_start", + "score", + "severity", + "suggestion", + "tags", + "title" + ], + "intake": [ + "ai_generated", + "areas_touched", + "complexity", + "languages", + "pr_summary", + "pr_type", + "review_depth", + "risk_signals" + ], + "metadata": [ + "agent_invocations", + "anatomy", + "budget", + "intake", + "phases_completed", + "plan" + ], + "plan": [ + "ai_adjusted", + "cross_ref_hints", + "dimensions", + "total_budget" + ], + "result": [ + "findings", + "metadata", + "pr_url", + "review", + "review_id", + "summary" + ], + "review": [ + "body", + "comments", + "event" + ], + "summary": [ + "adversary_challenged", + "adversary_confirmed", + "advisory_count", + "ai_generated_confidence", + "blocking_count", + "budget_exhausted", + "by_severity", + "cost_usd", + "coverage_iterations", + "cross_ref_interactions", + "dimensions_run", + "duration_seconds", + "total_findings" + ] +} diff --git a/go/internal/orch/testdata/summary_golden.txt b/go/internal/orch/testdata/summary_golden.txt new file mode 100644 index 0000000..b29f1fc --- /dev/null +++ b/go/internal/orch/testdata/summary_golden.txt @@ -0,0 +1,88 @@ +## 🔴 PR-AF Review — **Merge Blocked — Must-Fix Found** + +> 🚫 **Merge blocked.** 1 must-fix issue found by automated review. + +*Automated multi-agent code review · [PR-AF](https://github.com/Agent-Field/pr-af) built with [AgentField](https://github.com/Agent-Field/agentfield)* + +> **3 findings** · 🚫 1 blocking · 💬 2 advisory · 🔴 1 critical · 🟠 1 important · 🔵 0 suggestions · ⚪ 1 nitpicks + +
+PR Overview + +Adds a caching layer to the API. + +
+ +### Key Findings + +**1 issue(s) should be addressed before merge:** + +- 🔴 **Null dereference** (`src/a.py:10`) — The value can be None. Gate: Crashes on null input. + +**2 advisory finding(s) surfaced as non-blocking:** + +- 🟠 Quadratic loop (`src/b.py:20`) +- ⚪ Typo in comment + +**Files with findings:** `src/a.py`, `src/b.py` + +
+All Findings by Severity + +#### 🔴 Critical (1) + +- **Null dereference** `src/a.py:10` + +#### 🟠 Important (1) + +- **Quadratic loop** `src/b.py:20` + +#### ⚪ Nitpick (1) + +- **Typo in comment** + +
+ +
+Review Process Details + +**Dimensions Analyzed (2):** + +- **Security** — 1 file(s) +- **Performance** — 2 file(s) + +**Meta-Dimension Lenses (2):** + +- **Semantic** — 2 dimension(s), 80% coverage confidence +- **Mechanical** — 1 dimension(s), 60% coverage confidence + +**Cross-Reference & Adversary Analysis:** + +- **1** compound finding(s) synthesized +- **3** finding(s) adversarially tested: 2 confirmed, 1 challenged + +
+ +
+Pipeline Stats + +| Metric | Value | +|--------|-------| +| Duration | 12.3s | +| Agent invocations | 7 | +| Coverage iterations | 1 | +| Estimated cost | N/A (provider does not report cost) | +| Budget exhausted | No | +| PR type | feature | +| Complexity | standard | + +
+ +Review ID: `rev_abc123def456` + +
+ \ No newline at end of file diff --git a/go/internal/prompts/adversary.go b/go/internal/prompts/adversary.go new file mode 100644 index 0000000..10e3f7d --- /dev/null +++ b/go/internal/prompts/adversary.go @@ -0,0 +1,123 @@ +package prompts + +import ( + "path/filepath" + "unicode/utf8" + + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// Ports adversary_phase from reasoners/harnesses.py. + +const adversaryIntro = "You are the adversarial reviewer. Your job is to CHALLENGE every finding and " + + "determine whether it is real or a false positive. You are skeptical by default.\n\n" + +const adversaryHasEvidence = "## Ground-Truth Evidence (CRITICAL)\n\n" + + "Each finding below includes a `ground_truth` section containing ACTUAL CODE " + + "extracted programmatically from the repository. This is the REAL code — not the " + + "reviewer's description of it. Use this as your primary verification source:\n\n" + + "- `primary_code`: The actual source code around the finding location (with line numbers)\n" + + "- `caller_snippets`: Real call sites of functions mentioned in the finding\n" + + "- `diff_hunk`: The actual diff patch for this file\n" + + "- `import_context`: What this file imports and what imports it\n" + + "- `related_code`: Code from non-PR files that interact with the finding\n\n" + + "**VERIFICATION PROTOCOL**: For each finding:\n" + + "1. Read the reviewer's CLAIM about what the code does\n" + + "2. Read the `ground_truth.primary_code` to see what the code ACTUALLY does\n" + + "3. If the claim contradicts the ground truth → CHALLENGE as false positive\n" + + "4. If the claim matches the ground truth → check caller_snippets to verify " + + "the failure scenario is reachable\n" + + "5. You may ALSO browse the repo for additional verification, but the ground " + + "truth should catch most false positives\n\n" + +const adversaryNoEvidence = "## Verification Protocol\n\n" + + "No ground-truth evidence was extracted for these findings. You MUST read the " + + "actual repository files yourself to verify each finding. Open the file mentioned, " + + "read the function, and confirm the behavior the reviewer claims exists.\n\n" + +const adversaryMiddle = "## For Each Finding, Determine:\n\n" + + "1. **Does the ground truth match the claim?** Compare the reviewer's description " + + "against the actual code in `ground_truth.primary_code`. If the reviewer says " + + "'function X uses string comparison' but the actual code uses `errors.Is()`, " + + "that is a false positive — CHALLENGE it immediately.\n\n" + + "2. **Is the failure scenario reachable?** Check `ground_truth.caller_snippets` " + + "to see if the described call path actually exists. Are there guards upstream " + + "that prevent the bad state? Does the calling code handle the condition?\n\n" + + "3. **Is the severity correct?** A 'critical' finding must have a concrete crash " + + "or corruption scenario traceable through the ground truth. If the primary code " + + "shows the issue is handled, downgrade or challenge.\n\n" + + "4. **Cross-file interactions**: Check `ground_truth.related_code` and " + + "`ground_truth.import_context` to understand the broader context. A finding " + + "might look valid in isolation but be prevented by code in another file.\n\n" + + "5. **Hidden traps**: Did the reviewer find a real issue but miss a WORSE " + + "version visible in the ground truth code?\n\n" + + "## Verdicts\n\n" + + "- **confirmed**: The ground truth supports the finding. The claim matches the " + + "actual code. The failure scenario is reachable.\n" + + "- **challenged**: The ground truth contradicts the finding. The actual code " + + "does NOT do what the reviewer claims, OR upstream guards prevent the failure.\n" + + "- **escalated**: The ground truth reveals the issue is WORSE than the reviewer " + + "described.\n\n" + +// AdversaryPrompt ports adversary_phase. Skepticism escalates to "high" when the +// AI-generated confidence exceeds 0.5; the extra skepticism line appears on the +// same condition. has_evidence is bool(evidenceMap). +func AdversaryPrompt(findings []schemas.ReviewFinding, aiGeneratedConfidence float64, prContext, repoPath string, evidenceMap map[string]*OMap) string { + skepticism := "standard" + if aiGeneratedConfidence > 0.5 { + skepticism = "high" + } + + withEvidence := make([]*OMap, 0, 20) + for _, f := range firstN(findings, 20) { + entry := omap( + "title", f.Title, + "severity", string(f.Severity), + "file_path", f.FilePath, + "dimension_name", f.DimensionName, + "body", f.Body, + "evidence", f.Evidence, + "suggestion", f.Suggestion, + "confidence", f.Confidence, + ) + if ev, ok := evidenceMap[f.Title]; ok && ev != nil { + entry.Set("ground_truth", omap( + "primary_code", runeSlice(ev.GetStr("primary_code", ""), 3000), + "caller_snippets", firstN(ev.GetStrSlice("caller_snippets"), 5), + "diff_hunk", runeSlice(ev.GetStr("diff_hunk", ""), 2000), + "import_context", ev.GetStr("import_context", ""), + "related_code", runeSlice(ev.GetStr("related_code", ""), 2000), + )) + } + withEvidence = append(withEvidence, entry) + } + summary := pyJSON(withEvidence) + + var findingsRef string + if utf8.RuneCountInString(summary) > 10000 && repoPath != "" { + fp := filepath.Join(repoPath, ".pr-af-context", "adversary_findings.json") + findingsRef = "Full findings with ground-truth evidence written to: " + fp + "\n" + + "Read this file for complete finding details and code evidence." + } else { + findingsRef = "Findings with ground-truth evidence:\n" + summary + } + + evidenceInstruction := adversaryNoEvidence + if len(evidenceMap) > 0 { + evidenceInstruction = adversaryHasEvidence + } + + skepLine := "\n" + if aiGeneratedConfidence > 0.5 { + skepLine = "(Higher AI confidence: be MORE skeptical of trivial findings)\n\n" + } + prBlock := "" + if prContext != "" { + prBlock = "## PR Context\n\n" + prContext + "\n\n" + } + + return adversaryIntro + evidenceInstruction + adversaryMiddle + + "Skepticism mode: " + skepticism + "\n" + + "AI-generated confidence: " + pyFloat(aiGeneratedConfidence) + "\n" + + skepLine + prBlock + findingsRef +} diff --git a/go/internal/prompts/anatomy.go b/go/internal/prompts/anatomy.go new file mode 100644 index 0000000..eb33df5 --- /dev/null +++ b/go/internal/prompts/anatomy.go @@ -0,0 +1,49 @@ +package prompts + +import "github.com/Agent-Field/pr-af/go/internal/schemas" + +// Ports the anatomy_phase .harness() prompt from reasoners/harnesses.py. + +const anatomyPreamble = "You are a senior engineer performing structural analysis of a pull request before " + + "review dimensions are assigned. Your job is NOT to find bugs yet — it is to deeply " + + "understand WHAT changed, WHY it changed, and WHERE the risk surfaces are.\n\n" + + "Think like an architect reviewing a change set:\n\n" + + "1. **PR Narrative**: Write a clear technical narrative of what this PR actually does " + + "(not what the PR description says — what the CODE says). Trace the change from " + + "entry point to effect. If the PR replaces one mechanism with another, describe both " + + "the old and new mechanisms and where they differ.\n\n" + + "2. **Risk Surfaces**: Identify areas where this change could break things that are " + + "NOT obvious from the diff alone. Think about:\n" + + " - Callers of changed functions/methods that might pass arguments differently\n" + + " - Implicit contracts (ordering, timing, state) that the change might violate\n" + + " - Error paths — if the old code handled errors one way, does the new code preserve that?\n" + + " - Concurrency: thread safety, shared state, decorator-injected arguments\n" + + " - API boundaries: do callers still get what they expect?\n" + + " - Configuration/defaults that changed (especially security-sensitive ones)\n\n" + + "3. **Unrelated Changes**: Flag anything that doesn't belong in this PR's stated intent.\n\n" + + "4. **Intent Gaps**: Where does the code diverge from what the PR description promises? " + + "Where is the PR description silent about something the code actually does?\n\n" + + "Be specific. Name files, functions, and line ranges. A vague risk surface is useless.\n\n" + +// AnatomyPrompt builds the anatomy_phase prompt. clusters/stats/blastRadiusCount/ +// files are the deterministic diff-engine outputs (T2.1) the reasoner computed; +// they are passed in typed so this builder never touches diff parsing. +func AnatomyPrompt(intake schemas.IntakeResult, prTitle, prDescription string, prLabels []string, clusters []schemas.ChangeCluster, stats schemas.DiffStats, blastRadiusCount int, files []schemas.FileChange) string { + ctx := omap( + "intake", omap( + "pr_type", intake.PrType, + "complexity", intake.Complexity, + "pr_summary", intake.PrSummary, + ), + "pr_metadata", omap( + "title", prTitle, + "description", runeSlice(prDescription, 500), + "labels", orEmpty(prLabels), + ), + "clusters", clusterDescriptions(clusters), + "stats", statsOMap(stats), + "blast_radius_count", blastRadiusCount, + "files_changed", fileChangeOMaps(files), + ) + return anatomyPreamble + pyJSON(ctx) +} diff --git a/go/internal/prompts/compound.go b/go/internal/prompts/compound.go new file mode 100644 index 0000000..4162fc2 --- /dev/null +++ b/go/internal/prompts/compound.go @@ -0,0 +1,134 @@ +package prompts + +import ( + "path/filepath" + "strconv" + "strings" + "unicode/utf8" + + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// Ports compound_finder_phase and compound_dedup_phase from +// reasoners/harnesses.py. + +const compoundFinderPreamble = "You are a compound-risk investigator for PR findings. You are given a SMALL cluster " + + "of findings that might interact. Your task is to investigate whether these findings " + + "combine into something worse than each finding alone, then synthesize NEW first-class " + + "findings when that combined risk is real.\n\n" + + "Use repository access to verify interactions. Treat this as hypothesis-driven analysis, " + + "not pattern matching: investigate whether there is a real chain or shared mechanism that " + + "creates an issue an individual reviewer would likely miss.\n\n" + + "Guidance for investigation depth:\n" + + "- Check whether one finding creates a precondition that enables another.\n" + + "- Check whether separately minor issues create an escalation path together.\n" + + "- Check whether a safety mechanism exists in one place but is disconnected elsewhere.\n" + + "- Check whether fixing one issue can worsen behavior exposed by another.\n" + + "- Check whether repeated patterns indicate a systemic control gap.\n\n" + + "Output contract:\n" + + "- If no credible compound issue exists, return an empty findings list.\n" + + "- If a compound issue exists, emit NEW findings only. Do not repeat original findings.\n" + + "- Each output finding must include: title, severity, file_path, line_start, line_end, " + + "body, evidence, suggestion, confidence, tags, and contributing_findings.\n" + + "- `severity` MUST be exactly one of: `critical`, `important`, `suggestion`, `nitpick`.\n" + + "- `contributing_findings` must list the exact titles from this cluster that combine.\n" + + "- Only emit findings with confidence >= 0.6 and concrete evidence.\n\n" + +// CompoundFinderPrompt ports compound_finder_phase. Caller invokes only when +// len(findings) >= 2. evidenceMap is keyed by finding title; each value is the +// raw evidence package (rendered verbatim into cluster_evidence and mined for +// the per-finding evidence_package sub-object). +func CompoundFinderPrompt(findings []schemas.ReviewFinding, repoPath string, evidenceMap map[string]*OMap) string { + withContext := make([]*OMap, 0, 4) + for _, f := range firstN(findings, 4) { + entry := omap( + "title", f.Title, + "severity", string(f.Severity), + "file_path", f.FilePath, + "line_start", f.LineStart, + "line_end", f.LineEnd, + "dimension_name", f.DimensionName, + "body", f.Body, + "evidence", f.Evidence, + "suggestion", f.Suggestion, + "tags", orEmpty(f.Tags), + ) + if ev, ok := evidenceMap[f.Title]; ok && ev != nil { + entry.Set("evidence_package", omap( + "primary_code", runeSlice(ev.GetStr("primary_code", ""), 4000), + "import_context", runeSlice(ev.GetStr("import_context", ""), 2500), + "caller_snippets", firstN(ev.GetStrSlice("caller_snippets"), 5), + "related_code", runeSlice(ev.GetStr("related_code", ""), 2500), + "cross_ref_snippets", firstN(ev.GetStrSlice("cross_ref_snippets"), 4), + )) + } + withContext = append(withContext, entry) + } + // cluster_evidence: unique finding titles (in list order) present in evidenceMap. + clusterEvidence := omap() + seen := map[string]bool{} + for _, f := range findings { + if seen[f.Title] { + continue + } + seen[f.Title] = true + if ev, ok := evidenceMap[f.Title]; ok { + clusterEvidence.Set(f.Title, ev) + } + } + summary := pyJSON(omap("cluster_findings", withContext, "cluster_evidence", clusterEvidence)) + + var findingsRef string + if utf8.RuneCountInString(summary) > 10000 && repoPath != "" { + fp := filepath.Join(repoPath, ".pr-af-context", "compound_cluster_findings.json") + findingsRef = "Cluster findings and evidence written to: " + fp + + "\nRead this file for complete compound-analysis context." + } else { + findingsRef = "Cluster context:\n" + summary + } + return compoundFinderPreamble + findingsRef + "\n\nReturn strict JSON matching the schema." +} + +const compoundDedupPreamble = "You are a deduplication specialist reviewing compound findings from a PR review.\n\n" + + "Compound findings are synthesized from clusters of individual findings. Because " + + "clusters are analyzed independently and in parallel, different clusters sometimes " + + "produce findings that cover the SAME underlying insight from slightly different " + + "angles.\n\n" + + "Your task: identify which compound findings represent genuinely DISTINCT insights " + + "and which are near-duplicates. Two findings are duplicates when they describe the " + + "same root cause, same attack vector, or same systemic pattern — even if phrased " + + "differently or using different terminology.\n\n" + + "When duplicates exist, keep the finding that is:\n" + + "- Most specific and actionable\n" + + "- Best evidenced\n" + + "- Highest severity\n\n" + + "Also check: does any compound finding merely RESTATE what an individual finding " + + "already says without adding a genuinely new cross-cutting insight? If so, drop it.\n\n" + +// CompoundDedupPrompt ports compound_dedup_phase. Caller invokes only when +// len(findings) > 1. The Tags field interpolates as a Python list repr +// (single-quoted). +func CompoundDedupPrompt(findings []schemas.ReviewFinding, individualFindingsSummary string) string { + numbered := make([]string, len(findings)) + for idx, f := range findings { + numbered[idx] = "[" + strconv.Itoa(idx) + "] Title: " + f.Title + "\n" + + " Severity: " + string(f.Severity) + "\n" + + " File: " + f.FilePath + "\n" + + " Tags: " + pyRepr(orEmpty(f.Tags)) + "\n" + + " Body: " + runeSlice(f.Body, 500) + "\n" + + " Evidence: " + runeSlice(f.Evidence, 300) + } + findingsText := strings.Join(numbered, "\n\n") + + individualContext := "" + if individualFindingsSummary != "" { + individualContext = "\n\nFor reference, these are the INDIVIDUAL findings that the compound " + + "findings were synthesized from:\n" + individualFindingsSummary + } + + return compoundDedupPreamble + + "COMPOUND FINDINGS TO EVALUATE (" + strconv.Itoa(len(findings)) + " total):\n\n" + + findingsText + individualContext + + "\n\nReturn `keep_indices` as a list of 0-based indices of findings to KEEP. " + + "Include your reasoning." +} diff --git a/go/internal/prompts/context.go b/go/internal/prompts/context.go new file mode 100644 index 0000000..9252a92 --- /dev/null +++ b/go/internal/prompts/context.go @@ -0,0 +1,65 @@ +package prompts + +import "github.com/Agent-Field/pr-af/go/internal/schemas" + +// Shared context-payload helpers. These mirror the small dict/list constructions +// the reasoners perform before json.dumps: _cluster_descriptions, stats +// model_dump, the files_changed projection, and the intake sub-object. + +// clusterDescriptions ports _cluster_descriptions(clusters): a list of ordered +// objects keyed id, name, description, primary_language, files. +func clusterDescriptions(clusters []schemas.ChangeCluster) []*OMap { + out := make([]*OMap, len(clusters)) + for i, c := range clusters { + out[i] = omap( + "id", c.ID, + "name", c.Name, + "description", c.Description, + "primary_language", c.PrimaryLanguage, + "files", orEmpty(c.Files), + ) + } + return out +} + +// statsOMap ports DiffStats.model_dump() — field order fixed by the pydantic +// model. test_to_code_ratio is a float and renders with a decimal point. +func statsOMap(s schemas.DiffStats) *OMap { + return omap( + "total_files", s.TotalFiles, + "total_additions", s.TotalAdditions, + "total_deletions", s.TotalDeletions, + "files_added", s.FilesAdded, + "files_modified", s.FilesModified, + "files_removed", s.FilesRemoved, + "files_renamed", s.FilesRenamed, + "test_files_changed", s.TestFilesChanged, + "test_to_code_ratio", s.TestToCodeRatio, + ) +} + +// filePaths ports [f.path for f in files[:30]]. +func filePaths(files []schemas.FileChange) []string { + files = firstN(files, 30) + out := make([]string, len(files)) + for i, f := range files { + out[i] = f.Path + } + return out +} + +// fileChangeOMaps ports the anatomy files_changed projection over files[:30]: +// objects keyed path, status, lines_added, lines_removed. +func fileChangeOMaps(files []schemas.FileChange) []*OMap { + files = firstN(files, 30) + out := make([]*OMap, len(files)) + for i, f := range files { + out[i] = omap( + "path", f.Path, + "status", f.Status, + "lines_added", f.LinesAdded, + "lines_removed", f.LinesRemoved, + ) + } + return out +} diff --git a/go/internal/prompts/context_test.go b/go/internal/prompts/context_test.go new file mode 100644 index 0000000..55a792f --- /dev/null +++ b/go/internal/prompts/context_test.go @@ -0,0 +1,84 @@ +package prompts + +import ( + "testing" + + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +func TestAnatomyGolden(t *testing.T) { + // anatomy_A: prA metadata + intake_dict(); diff-engine derived clusters/stats + // transcribed from the fixture (root cluster over all 3 metadata files). + clustersA := []schemas.ChangeCluster{ + {ID: "cluster_0", Name: "root", Files: []string{"client.py", "retry.py", "client.test.ts", "README.md"}, PrimaryLanguage: "python", Description: ""}, + } + statsA := schemas.DiffStats{TotalFiles: 4, FilesAdded: 2, FilesModified: 2, TestFilesChanged: 1, TestToCodeRatio: 1.0 / 3.0} + filesA := []schemas.FileChange{ + {Path: "client.py", Status: "modified"}, + {Path: "retry.py", Status: "added"}, + {Path: "client.test.ts", Status: "added"}, + {Path: "README.md", Status: "modified"}, + } + assertGolden(t, "anatomy_A", AnatomyPrompt( + intakeFix(nil), + "Add retry logic to HTTP client", + "Wraps the client in a retry decorator with exponential backoff.\nCloses #42.", + []string{"enhancement", "backend"}, + clustersA, statsA, 0, filesA, + )) + + // anatomy_B: prB minimal, empty changed_files -> empty derivations. + assertGolden(t, "anatomy_B", AnatomyPrompt( + intakeFix(func(in *schemas.IntakeResult) { + in.PrSummary = "Bumps a dependency." + in.AreasTouched = []string{"config"} + }), + "Bump dep", "", nil, + []schemas.ChangeCluster{}, schemas.DiffStats{}, 0, []schemas.FileChange{}, + )) +} + +func TestPlanningGolden(t *testing.T) { + assertGolden(t, "planning_A", PlanningPrompt( + intakeFix(func(in *schemas.IntakeResult) { in.AreasTouched = []string{"api", "config"} }), + anatA(), "deep", []string{"focus on idempotency", "ignore style"}, + )) + assertGolden(t, "planning_B", PlanningPrompt( + intakeFix(nil), anatomyFix(nil), "standard", []string{}, + )) +} + +func TestMetaGolden(t *testing.T) { + patches := []StrPair{{Key: "client.py", Val: "@@ -1,3 +1,5 @@\n+def retry():\n+ pass"}} + lenses := []struct { + name string + fn func(context, repoPath, depth string) string + }{ + {"semantic", MetaSemanticPrompt}, + {"mechanical", MetaMechanicalPrompt}, + {"systemic", MetaSystemicPrompt}, + } + for _, l := range lenses { + ctxA := MetaContext(intakeFix(nil), anatA(), patches, "tone down the nitpicks") + assertGolden(t, "meta_"+l.name+"_A", l.fn(ctxA, "", "deep")) + ctxB := MetaContext(intakeFix(nil), anatomyFix(nil), nil, "") + assertGolden(t, "meta_"+l.name+"_B", l.fn(ctxB, "", "standard")) + } + // C: large-context file-write branch (semantic only). + bigPatches := []StrPair{{Key: "client.py", Val: bigFiller("patch", 9000)}} + ctxC := MetaContext(intakeFix(nil), anatA(), bigPatches, "focus on auth") + assertGolden(t, "meta_semantic_C", MetaSemanticPrompt(ctxC, fixtureRepo, "deep")) +} + +func TestCoverageGolden(t *testing.T) { + assertGolden(t, "coverage_gate_system", CoverageGateSystem) + covan := anatomyFix(func(a *schemas.AnatomyResult) { + a.Clusters = []schemas.ChangeCluster{ + clusterFix("cluster_0", "Client core", []string{"client.py"}, ""), + clusterFix("cluster_1", "Tests", []string{"t.py"}, ""), + } + a.RiskSurfaces = []string{"error propagation"} + }) + assertGolden(t, "coverage_gate_A", CoverageGatePrompt(covan, []string{"cluster_0"}, []string{"Semantic: error paths", "Mechanical"})) + assertGolden(t, "coverage_gate_B", CoverageGatePrompt(covan, []string{}, nil)) +} diff --git a/go/internal/prompts/coverage.go b/go/internal/prompts/coverage.go new file mode 100644 index 0000000..8be6bb6 --- /dev/null +++ b/go/internal/prompts/coverage.go @@ -0,0 +1,34 @@ +package prompts + +import "github.com/Agent-Field/pr-af/go/internal/schemas" + +// Ports the coverage_gate .ai() system + user prompt from +// reasoners/harnesses.py. + +// CoverageGateSystem is the system prompt for the coverage_gate .ai() call. +const CoverageGateSystem = "Analyze the coverage state and return the structured result." + +// CoverageGatePrompt builds the coverage_gate user prompt. The cluster payload +// here is keyed id, name, description, files (no primary_language — distinct +// from _cluster_descriptions). +func CoverageGatePrompt(anatomy schemas.AnatomyResult, reviewedClusters, dimensionNamesReviewed []string) string { + clusters := make([]*OMap, len(anatomy.Clusters)) + for i, c := range anatomy.Clusters { + clusters[i] = omap( + "id", c.ID, + "name", c.Name, + "description", c.Description, + "files", orEmpty(c.Files), + ) + } + ctx := omap( + "all_clusters", clusters, + "reviewed_clusters", orEmpty(reviewedClusters), + "dimensions_reviewed", orEmpty(dimensionNamesReviewed), + "risk_surfaces", orEmpty(anatomy.RiskSurfaces), + ) + return "Determine whether review coverage is complete. " + + "Compare reviewed cluster identifiers against all change clusters. " + + "Dimensions already reviewed: " + joinComma(orEmpty(dimensionNamesReviewed)) + ". " + + "If gaps exist, return concise gap_descriptions.\n\n" + pyJSON(ctx) +} diff --git a/go/internal/prompts/deepen.go b/go/internal/prompts/deepen.go new file mode 100644 index 0000000..44a33a8 --- /dev/null +++ b/go/internal/prompts/deepen.go @@ -0,0 +1,105 @@ +package prompts + +import ( + "path/filepath" + "strings" + "unicode/utf8" +) + +// Ports deepen_findings from reasoners/harnesses.py, plus the diff-patch +// rendering helpers shared with extract_obligations. + +// filterPatches ports `{k: v for k, v in (diff_patches or {}).items() if v}`: +// drops empty-valued patches, dedups keys (last value wins, first position). +func filterPatches(patches []StrPair) []StrPair { + var order []string + idx := map[string]int{} + out := []StrPair{} + for _, p := range patches { + if p.Val == "" { + continue + } + if i, ok := idx[p.Key]; ok { + out[i].Val = p.Val + continue + } + idx[p.Key] = len(out) + order = append(order, p.Key) + out = append(out, p) + } + _ = order + return out +} + +// renderPatchesText ports "\n\n".join(f"### {p}\n```diff\n{d}\n```" ...) over +// the first 20 (already-filtered) patches. +func renderPatchesText(patches []StrPair) string { + patches = firstN(patches, 20) + parts := make([]string, len(patches)) + for i, p := range patches { + parts[i] = "### " + p.Key + "\n```diff\n" + p.Val + "\n```" + } + return strings.Join(parts, "\n\n") +} + +// diffRef ports the shared diff_ref resolution: inline unless a repo path is set +// and the rendered patches exceed 9000 characters, in which case they are +// referenced by file path. fileName is "deepen_diff.md" / "obligations_diff.md". +func diffRef(patches []StrPair, repoPath, fileName string) string { + text := renderPatchesText(patches) + if utf8.RuneCountInString(text) > 9000 && repoPath != "" { + fp := filepath.Join(repoPath, ".pr-af-context", fileName) + return "Changed-code diffs written to: " + fp + "\nRead it for the full set of hunks." + } + return "## Changed code (diffs)\n\n" + text +} + +const deepenPre1 = "You are the LITERAL-CORRECTNESS verifier on a review. Other reviewers have already " + + "covered architecture, design, tests, and systemic concerns. Your job is the opposite " + + "and complementary one: go line by line through the CHANGED code and verify it is " + + "literally correct against GROUND TRUTH — the real definitions of every symbol it " + + "touches. You have full repository access; USE it to resolve definitions, do not guess.\n\n" + + "## The single discipline\n" + + "For each changed line, identify every external thing the code DEPENDS ON and RELIES ON " + + "being true, then open the actual definition and verify the assumption holds. When the " + + "ground truth contradicts the code's assumption, that is a finding.\n\n" + + "Be EXHAUSTIVE, not selective. Walk EVERY changed call, argument, assignment, condition, " + + "and type assumption — one at a time. Emit a finding for EVERY violation you confirm. " + + "Stopping after the single most salient issue is a FAILURE of this pass; completeness is " + + "the goal. Two sibling bugs on adjacent lines are TWO findings, not one.\n\n" + + "Let the specific things to check EMERGE from this code — do NOT work from a remembered " + + "list of common bug types or categories. For each changed element, ask the question the " + + "code itself raises: 'what must be true — elsewhere in this codebase, or in the runtime — " + + "for this exact line to be correct?' Then read the real definition, caller, creation site, " + + "or surrounding logic and check whether it actually is true. The right questions are " + + "different for every change; derive them from what the code does, never from memory of " + + "past bugs. Follow an assumption across files when the answer lives elsewhere — a value " + + "produced in one place and relied on in another must agree. When the ground truth " + + "contradicts what the code assumes, that is a finding — whether the violation is mechanical " + + "(a symbol that does not resolve or behave as used) or a logic invariant the code is " + + "meant to preserve but does not. State the concrete consequence you can demonstrate.\n\n" + + "## Output contract\n" + + "- Findings already reported by other reviewers (do NOT duplicate): " + +const deepenPre2 = ".\n" + + "- Emit a finding only for a concrete, code-verified literal-correctness violation. Each: " + + "title, severity, file_path, line_start, line_end, body, evidence (quote the exact code " + + "AND the conflicting definition you read), suggestion, confidence, tags. severity MUST be " + + "one of critical/important/suggestion/nitpick.\n" + + "- Verify every claim by reading the real definition. Do NOT speculate or invent. " + + "confidence >= 0.6. If the changed code is literally correct, return zero findings.\n\n" + +// DeepenFindingsPrompt ports deepen_findings. Caller invokes only when there is +// at least one non-empty patch. existingTitles is truncated to 40 and joined +// with "; " (empty -> "none"). +func DeepenFindingsPrompt(diffPatches []StrPair, existingTitles []string, repoPath, prContext string) string { + seen := strings.Join(firstN(existingTitles, 40), "; ") + if seen == "" { + seen = "none" + } + prBlock := "" + if prContext != "" { + prBlock = "## PR Context\n\n" + prContext + "\n\n" + } + return deepenPre1 + seen + deepenPre2 + prBlock + diffRef(filterPatches(diffPatches), repoPath, "deepen_diff.md") +} diff --git a/go/internal/prompts/direct_test.go b/go/internal/prompts/direct_test.go new file mode 100644 index 0000000..1088ffb --- /dev/null +++ b/go/internal/prompts/direct_test.go @@ -0,0 +1,179 @@ +package prompts + +import ( + "strings" + "testing" + + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +func TestMergeGateGolden(t *testing.T) { + assertGolden(t, "merge_gate_system", MergeGateSystem) + base := schemas.ScoredFinding{ + ID: "f_001", DimensionID: "d1", DimensionName: "Retry semantics", + FilePath: "client.py", LineStart: 10, LineEnd: 12, + Severity: "important", Title: "Retry loop can spin forever", + Body: "The loop never decrements the counter.", Confidence: 0.73, + } + a := base + a.Evidence = "Trace: A->B->C." + a.Suggestion = strptr("Decrement the counter.") + assertGolden(t, "merge_gate_user_A", MergeGateUserPrompt(a)) + assertGolden(t, "merge_gate_user_B", MergeGateUserPrompt(base)) +} + +func TestPolishGolden(t *testing.T) { + assertGolden(t, "polish_system", PolishSystem) + assertGolden(t, "polish_user_A", PolishUserPrompt("> [!CAUTION] **Must-fix before merge.**\n\nThe retry loop never terminates. See `client.py:10`.")) + assertGolden(t, "polish_user_B", PolishUserPrompt("Minor: rename `x` to `retries`.")) +} + +func TestGapGolden(t *testing.T) { + assertGolden(t, "gap_dimension_A", CoverageGapPrompt("The config loader that reads RETRIES was not reviewed.")) + assertGolden(t, "gap_dimension_B", CoverageGapPrompt("Untested error path.")) +} + +func TestVerifyObligationGolden(t *testing.T) { + assertGolden(t, "verify_obligation_A", VerifyObligationPrompt("client.py:10 store(key)", "the loader that reads these keys", "the store key equals the lookup key")) + assertGolden(t, "verify_obligation_B", VerifyObligationPrompt("a.py:1 f()", "def of f", "f is pure")) +} + +func TestPostWorthinessGolden(t *testing.T) { + pw := []schemas.ScoredFinding{ + {Severity: "critical", FilePath: "a.py", LineStart: 3, Title: "Null deref", Body: bigFiller("body", 400), Evidence: bigFiller("ev", 250)}, + {Severity: "nitpick", FilePath: "b.py", LineStart: 9, Title: "Rename var", Body: "cosmetic", Evidence: ""}, + {Severity: "important", FilePath: "c.py", LineStart: 1, Title: "Race", Body: "shared state", Evidence: "two goroutines"}, + } + assertGolden(t, "post_worthiness_A", PostWorthinessPrompt(pw)) + assertGolden(t, "post_worthiness_B", PostWorthinessPrompt(pw[:2])) +} + +func TestCompoundDedupGolden(t *testing.T) { + cd := []schemas.ReviewFinding{ + {Title: "Shared retry gap", Severity: "important", FilePath: "a.py", Tags: []string{"correctness", "compound"}, Body: bigFiller("bd", 600), Evidence: bigFiller("ed", 400)}, + {Title: "Retry storm", Severity: "critical", FilePath: "b.py", Tags: []string{}, Body: "storm", Evidence: "load"}, + } + assertGolden(t, "compound_dedup_A", CompoundDedupPrompt(cd, "- Unbounded retry loop\n- Backoff ignored")) + assertGolden(t, "compound_dedup_B", CompoundDedupPrompt(cd, "")) +} + +func TestCompoundFinderGolden(t *testing.T) { + f1 := findingFix(func(f *schemas.ReviewFinding) { f.Title = "Unbounded retry loop"; f.Tags = []string{"correctness"} }) + f2 := findingFix(func(f *schemas.ReviewFinding) { + f.Title = "Backoff ignored" + f.FilePath = "retry.py" + f.LineStart = 5 + f.Tags = []string{"performance"} + f.Suggestion = strptr("Sleep between attempts.") + }) + f3 := findingFix(func(f *schemas.ReviewFinding) { + f.Title = "Error type swallowed" + f.FilePath = "errors.py" + f.Severity = "critical" + f.Suggestion = strptr("Re-raise original.") + }) + ev := map[string]*OMap{ + "Unbounded retry loop": omap( + "primary_code", "def retry(): ...", + "import_context", "import time", + "caller_snippets", []string{"client.call()"}, + "related_code", "config.RETRIES", + "cross_ref_snippets", []string{"x=1"}, + ), + } + assertGolden(t, "compound_finder_A", CompoundFinderPrompt([]schemas.ReviewFinding{f1, f2, f3}, "", ev)) + assertGolden(t, "compound_finder_B", CompoundFinderPrompt([]schemas.ReviewFinding{f1, f2}, "", nil)) + + bigf := []schemas.ReviewFinding{ + findingFix(func(f *schemas.ReviewFinding) { f.Title = strings.Repeat("A", 10); f.Body = bigFiller("b", 5000) }), + findingFix(func(f *schemas.ReviewFinding) { f.Title = strings.Repeat("B", 10); f.Body = bigFiller("c", 5000) }), + findingFix(func(f *schemas.ReviewFinding) { f.Title = "Cc"; f.Body = bigFiller("d", 5000) }), + } + assertGolden(t, "compound_finder_C", CompoundFinderPrompt(bigf, fixtureRepo, nil)) +} + +func TestEvidenceVerifierGolden(t *testing.T) { + evpk := map[string]*OMap{ + "Retry loop can spin forever": omap( + "primary_code", "while True: try()", + "caller_snippets", []string{"client.call()"}, + "diff_hunk", "@@ -1 +1 @@", + "import_context", "import time", + "related_code", "config", + "cross_ref_snippets", []string{"r=1"}, + ), + } + assertGolden(t, "evidence_verifier_A", EvidenceVerifierPrompt([]schemas.ReviewFinding{findingFix(nil)}, evpk, "PR adds retry.", "")) + assertGolden(t, "evidence_verifier_B", EvidenceVerifierPrompt([]schemas.ReviewFinding{findingFix(nil)}, nil, "", "")) + assertGolden(t, "evidence_verifier_C", EvidenceVerifierPrompt( + []schemas.ReviewFinding{findingFix(func(f *schemas.ReviewFinding) { f.Body = bigFiller("bd", 13000) })}, nil, "", fixtureRepo)) +} + +func TestAdversaryGolden(t *testing.T) { + advpk := map[string]*OMap{ + "Retry loop can spin forever": omap( + "primary_code", "while True: try()", + "caller_snippets", []string{"client.call()"}, + "diff_hunk", "@@ -1 +1 @@", + "import_context", "import time", + "related_code", "config", + ), + } + assertGolden(t, "adversary_A", AdversaryPrompt([]schemas.ReviewFinding{findingFix(nil)}, 0.7, "PR adds retry.", "", advpk)) + assertGolden(t, "adversary_B", AdversaryPrompt([]schemas.ReviewFinding{findingFix(nil)}, 0.0, "", "", nil)) + assertGolden(t, "adversary_C", AdversaryPrompt( + []schemas.ReviewFinding{findingFix(func(f *schemas.ReviewFinding) { f.Body = bigFiller("bd", 11000) })}, 0.3, "", fixtureRepo, nil)) +} + +func TestDeepenGolden(t *testing.T) { + dp := []StrPair{{Key: "client.py", Val: "@@ -1,2 +1,4 @@\n+def retry():\n+ return call()"}} + assertGolden(t, "deepen_A", DeepenFindingsPrompt(dp, []string{"Unbounded retry loop", "Backoff ignored"}, "", "PR adds retry.")) + assertGolden(t, "deepen_B", DeepenFindingsPrompt(dp, nil, "", "")) + assertGolden(t, "deepen_C", DeepenFindingsPrompt([]StrPair{{Key: "client.py", Val: bigFiller("hunk", 9500)}}, nil, fixtureRepo, "")) +} + +func TestObligationsGolden(t *testing.T) { + dp := []StrPair{{Key: "client.py", Val: "@@ -1,2 +1,4 @@\n+def retry():\n+ return call()"}} + assertGolden(t, "obligations_A", ExtractObligationsPrompt(dp, "", "PR adds retry.")) + assertGolden(t, "obligations_B", ExtractObligationsPrompt(dp, "", "")) + assertGolden(t, "obligations_C", ExtractObligationsPrompt([]StrPair{{Key: "client.py", Val: bigFiller("hunk", 9500)}}, fixtureRepo, "")) +} + +func TestReviewDimensionGolden(t *testing.T) { + assertGolden(t, "review_dimension_A", ReviewDimensionPrompt(ReviewDimensionOptions{ + ReviewPrompt: "Verify the retry decorator preserves error types raised by the wrapped call.", + TargetFiles: []string{"client.py", "retry.py"}, + ContextFiles: []string{"errors.py"}, + RepoPath: "", + CurrentDepth: 0, + MaxDepth: 2, + PrNarrative: "Adds a retry decorator.", + RiskSurfaces: []string{"error propagation", "timeout handling"}, + IntakeSummary: "Feature PR touching the HTTP client.", + DiffPatches: map[string]string{"client.py": "@@ -1 +1 @@\n-x\n+y", "retry.py": "@@ -2 +2 @@\n-a\n+b"}, + AllDimensionNames: []string{"Semantic: error paths", "Mechanical: signatures"}, + ReviewerFeedback: "drop nitpicks, focus on correctness", + PrimedCode: "1: def retry(fn):\n2: return fn", + })) + assertGolden(t, "review_dimension_B", ReviewDimensionPrompt(ReviewDimensionOptions{ + ReviewPrompt: "Verify the retry decorator preserves error types raised by the wrapped call.", + TargetFiles: []string{"client.py", "retry.py"}, + RepoPath: "", + CurrentDepth: 2, + MaxDepth: 2, + })) + assertGolden(t, "review_dimension_C", ReviewDimensionPrompt(ReviewDimensionOptions{ + ReviewPrompt: "Verify retry preserves error types.", + TargetFiles: []string{"client.py"}, + ContextFiles: []string{"errors.py"}, + RepoPath: fixtureRepo, + CurrentDepth: 0, + MaxDepth: 2, + PrNarrative: "Adds retry.", + RiskSurfaces: []string{"error propagation"}, + IntakeSummary: "Feature PR.", + DiffPatches: map[string]string{"client.py": bigFiller("hunk", 6500)}, + AllDimensionNames: []string{"Semantic"}, + PrimedCode: bigFiller("code", 6500), + })) +} diff --git a/go/internal/prompts/export.go b/go/internal/prompts/export.go new file mode 100644 index 0000000..3d00d4c --- /dev/null +++ b/go/internal/prompts/export.go @@ -0,0 +1,23 @@ +package prompts + +// This file is the small exported construction/rendering seam the reasoners +// package (T3.1) compiles against. The evidence-taking builders +// (CompoundFinderPrompt, AdversaryPrompt, EvidenceVerifierPrompt) accept +// map[string]*OMap, but OMap's fields are unexported and omap() is package +// private — without these exports no other package could build an evidence +// map or reproduce the json.dumps context blocks it must write to +// .pr-af-context files byte-identically. + +// NewOMap returns an empty insertion-ordered JSON object, ready for Set calls. +// It is the exported counterpart of the package-private omap() constructor. +func NewOMap() *OMap { + return &OMap{vals: make(map[string]any)} +} + +// PyJSON renders v exactly as Python's json.dumps(v, default=str): ", "/": " +// separators, insertion-ordered *OMap keys, ensure_ascii escaping, and Python +// float repr. Exported so the reasoners can write context files whose content +// is byte-identical to the JSON blocks the prompt builders embed or reference. +func PyJSON(v any) string { + return pyJSON(v) +} diff --git a/go/internal/prompts/fixtures_test.go b/go/internal/prompts/fixtures_test.go new file mode 100644 index 0000000..1af8ef6 --- /dev/null +++ b/go/internal/prompts/fixtures_test.go @@ -0,0 +1,99 @@ +package prompts + +import "github.com/Agent-Field/pr-af/go/internal/schemas" + +// Go mirrors of the fixture inputs in scripts/gen_golden.py. The Go builders are +// driven with these and compared against the committed testdata/*.txt fixtures +// the generator produced from the real Python builders. + +const fixtureRepo = "/tmp/pr-af-fixture-repo" + +// bigFiller mirrors gen_golden.py's big(seed, n). +func bigFiller(seed string, n int) string { + line := seed + " lorem ipsum dolor sit amet consectetur adipiscing elit. " + reps := (n / len(line)) + 1 + out := "" + for i := 0; i < reps; i++ { + out += line + } + return string([]rune(out)[:n]) // n < len; byte==rune here (ASCII) +} + +func intakeFix(mut func(*schemas.IntakeResult)) schemas.IntakeResult { + in := schemas.IntakeResult{ + PrType: "feature", + Complexity: "standard", + Languages: []string{"python"}, + AreasTouched: []string{"api"}, + RiskSignals: []string{"changes API surface or request/response behavior"}, + AIGenerated: 0.0, + ReviewDepth: "standard", + PrSummary: "Adds a retry wrapper around the HTTP client.", + } + if mut != nil { + mut(&in) + } + return in +} + +func clusterFix(id, name string, files []string, lang string) schemas.ChangeCluster { + if lang == "" { + lang = "python" + } + return schemas.ChangeCluster{ID: id, Name: name, Files: files, PrimaryLanguage: lang, Description: "desc"} +} + +func anatomyFix(mut func(*schemas.AnatomyResult)) schemas.AnatomyResult { + an := schemas.AnatomyResult{ + Files: []schemas.FileChange{}, + Clusters: []schemas.ChangeCluster{}, + BlastRadius: []string{}, + DependencyGraph: map[string][]string{}, + Stats: schemas.DiffStats{}, + RiskSurfaces: []string{}, + UnrelatedChanges: []string{}, + IntentGaps: []string{}, + } + if mut != nil { + mut(&an) + } + return an +} + +// anatA mirrors gen_golden.py's anatA (the richly-populated anatomy). +func anatA() schemas.AnatomyResult { + return anatomyFix(func(a *schemas.AnatomyResult) { + a.Clusters = []schemas.ChangeCluster{ + clusterFix("cluster_0", "Client core", []string{"client.py"}, ""), + clusterFix("cluster_1", "Tests", []string{"client.test.ts"}, "typescript"), + } + a.RiskSurfaces = []string{"error propagation to callers", "retry storm under load"} + a.PrNarrative = "Introduces a retry decorator around the HTTP client call path." + a.BlastRadius = []string{"caller.py"} + a.IntentGaps = []string{"description mentions backoff but code uses fixed sleep"} + a.UnrelatedChanges = []string{"README typo fix"} + a.ContextNotes = "Retry count is read from env." + }) +} + +func findingFix(mut func(*schemas.ReviewFinding)) schemas.ReviewFinding { + f := schemas.ReviewFinding{ + DimensionID: "d1", + DimensionName: "Retry semantics", + FilePath: "client.py", + LineStart: 10, + LineEnd: 12, + Severity: "important", + Title: "Retry loop can spin forever", + Body: "The loop never decrements the counter.", + Evidence: "Step 1: caller invokes retry() with n=3.", + Confidence: 0.7, + Tags: []string{"correctness"}, + } + if mut != nil { + mut(&f) + } + return f +} + +func strptr(s string) *string { return &s } diff --git a/go/internal/prompts/gates.go b/go/internal/prompts/gates.go new file mode 100644 index 0000000..8e667b1 --- /dev/null +++ b/go/internal/prompts/gates.go @@ -0,0 +1,96 @@ +package prompts + +import ( + "strconv" + + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// Ports the merge-gate prompt (merge_gate.py), the polish prompt (polish.py), +// and the coverage-gap review prompt (orchestrator._build_gap_dimensions). + +// MergeGateSystem ports merge_gate._MERGE_GATE_SYSTEM. +const MergeGateSystem = "You are the release manager for an automated code reviewer. Your job is to " + + "decide whether a single review finding must be fixed BEFORE this PR is merged, " + + "or whether the team can safely merge now and address it later.\n" + + "\n" + + "Apply a TIGHT bar. Only call something `blocking` if at least one is true:\n" + + " - It breaks the build, tests, or type-checking.\n" + + " - It introduces a security vulnerability reachable from a real user-facing " + + "code path (auth bypass, injection, credential leak, RCE, exposed secret, " + + "missing access control on a route real clients hit).\n" + + " - It causes data loss, data corruption, or irreversible state damage in " + + "production-running code.\n" + + " - It breaks an existing public API/CLI/schema contract that real callers " + + "depend on, with no migration path.\n" + + " - It is a regression of behavior that was working before this PR.\n" + + "\n" + + "Treat the following as NON-blocking (return blocking=false):\n" + + " - Code quality, style, naming, refactor opportunities.\n" + + " - Missing tests for edge cases, low test coverage, mock signature drift in " + + "test helpers.\n" + + " - Defensive programming opportunities, missing input validation that has " + + "no demonstrated reachable exploit path.\n" + + " - Performance suggestions that don't change correctness.\n" + + " - Documentation, comments, README, type-hint completeness.\n" + + " - 'Should also handle X' suggestions when X isn't currently reachable.\n" + + " - Architectural critiques (DRY, single source of truth, layering) without a " + + "concrete production impact described in the finding.\n" + + " - Issues whose reachability or exploitability the finding itself cannot " + + "demonstrate concretely.\n" + + "\n" + + "If the finding's evidence does NOT concretely demonstrate one of the blocking " + + "criteria above — even when the severity is labeled 'critical' — return " + + "blocking=false. Reviewers are often alarmist; you are the calibrating layer.\n" + + "\n" + + "Output strict JSON with this exact shape and nothing else:\n" + + ` {"blocking": true | false, "reason": ""}` + "\n" + + "Do not add prose. Do not wrap in markdown fences. JSON only." + +// MergeGateUserPrompt ports merge_gate._build_user_prompt: the per-finding user +// prompt. Evidence and Suggested-fix sections are included only when truthy +// (Python `if finding.evidence` / `if finding.suggestion`). +func MergeGateUserPrompt(f schemas.ScoredFinding) string { + s := "# Finding\n" + + "Severity (reviewer's label): " + string(f.Severity) + "\n" + + "Confidence: " + pyDotTwoF(f.Confidence) + "\n" + + "File: " + f.FilePath + ":" + strconv.Itoa(f.LineStart) + "\n" + + "Title: " + f.Title + "\n" + + "\n## Body\n" + f.Body + "\n" + if f.Evidence != "" { + s += "\n## Evidence\n" + f.Evidence + "\n" + } + if f.Suggestion != nil && *f.Suggestion != "" { + s += "\n## Suggested fix\n" + *f.Suggestion + "\n" + } + s += "\n# Question\n" + + "Must this be fixed before this PR is merged to production? " + + "Apply the bar described in the system prompt. Reply with JSON only." + return s +} + +// PolishSystem ports polish._POLISH_SYSTEM. +const PolishSystem = "You rewrite GitHub PR review comments. A good PR comment tells the author " + + "exactly what to fix and why, so they can act in under 30 seconds. Open with a " + + "one-sentence directive. Then one short paragraph (2-3 sentences) on the concrete " + + "failure mode — no abstract security lectures, no 'attacker-controlled' filler. " + + "Preserve every file path, line number, identifier, code block, markdown " + + "header, GitHub alert callout (`> [!CAUTION]`, `> [!NOTE]`), `
` block, " + + "and `` line verbatim. Never invent facts. Never soften severity. Output " + + "the polished comment body only — no preamble, no commentary." + +// PolishUserPrompt ports polish._polish_one's user prompt. +func PolishUserPrompt(body string) string { + return "Rewrite this PR review comment to be concise and developer-focused.\n\n" + body +} + +// CoverageGapPrompt ports orchestrator._build_gap_dimensions' review_prompt. +func CoverageGapPrompt(gap string) string { + return "Coverage gap review — this area was missed in the initial review pass.\n\n" + + "Gap identified: " + gap + "\n\n" + + "Inspect the target files with the same depth and rigor as a primary review. " + + "Look for bugs, logic errors, security issues, and behavioral changes. " + + "Pay special attention to how this code interacts with the changes that were " + + "already reviewed in other files — the gap exists because this cluster's " + + "relationship to the main change wasn't obvious at planning time." +} diff --git a/go/internal/prompts/golden_test.go b/go/internal/prompts/golden_test.go new file mode 100644 index 0000000..2f8ef8e --- /dev/null +++ b/go/internal/prompts/golden_test.go @@ -0,0 +1,88 @@ +package prompts + +import ( + "embed" + "fmt" + "testing" +) + +//go:embed testdata/*.txt +var goldenFS embed.FS + +// golden loads a committed fixture (produced by scripts/gen_golden.py from the +// real Python builders). +func golden(t *testing.T, name string) string { + t.Helper() + b, err := goldenFS.ReadFile("testdata/" + name + ".txt") + if err != nil { + t.Fatalf("read fixture %s: %v", name, err) + } + return string(b) +} + +// assertGolden compares got against the named fixture byte-for-byte and, on +// mismatch, reports the first differing byte offset with a ±40-byte window on +// each side so the divergence is easy to locate. +func assertGolden(t *testing.T, name, got string) { + t.Helper() + want := golden(t, name) + if got == want { + return + } + off := firstDiff(got, want) + t.Errorf("golden %s: mismatch at byte %d (got %d bytes, want %d bytes)\n got : %s\n want: %s", + name, off, len(got), len(want), window(got, off), window(want, off)) +} + +// firstDiff returns the index of the first differing byte, or the length of the +// shorter string when one is a prefix of the other. +func firstDiff(a, b string) int { + n := len(a) + if len(b) < n { + n = len(b) + } + for i := 0; i < n; i++ { + if a[i] != b[i] { + return i + } + } + return n +} + +// window renders a ±40-byte slice of s centered on off, with a caret at the +// diff point, using %q so control chars are visible. +func window(s string, off int) string { + lo := off - 40 + if lo < 0 { + lo = 0 + } + hi := off + 40 + if hi > len(s) { + hi = len(s) + } + return fmt.Sprintf("...%q%q...", s[lo:off], s[off:hi]) +} + +func TestIntakeGolden(t *testing.T) { + assertGolden(t, "intake_gate_system", IntakeGateSystem) + + assertGolden(t, "intake_ai_A", IntakeGatePrompt( + "Add retry logic to HTTP client", + "Wraps the client in a retry decorator with exponential backoff.\nCloses #42.", + []string{"enhancement", "backend"}, "alice", 4, + []string{"markdown", "python", "typescript"}, + []string{"feat: add retry", "test: cover retry", "docs: note retry", "chore: lint", "fix: typo", "extra"}, + )) + assertGolden(t, "intake_fallback_A", IntakeFallbackPrompt( + "Add retry logic to HTTP client", + "Wraps the client in a retry decorator with exponential backoff.\nCloses #42.", + "deep", []string{"markdown", "python", "typescript"}, 4, + )) + + assertGolden(t, "intake_ai_B", IntakeGatePrompt( + "Bump dep", "", nil, "", 0, nil, nil, + )) + assertGolden(t, "intake_fallback_B", IntakeFallbackPrompt( + "Bump dep", "", "standard", nil, 0, + )) +} diff --git a/go/internal/prompts/helpers.go b/go/internal/prompts/helpers.go new file mode 100644 index 0000000..5c076a2 --- /dev/null +++ b/go/internal/prompts/helpers.go @@ -0,0 +1,194 @@ +// Package prompts holds the byte-verbatim Go ports of every LLM prompt builder +// in PR-AF's Python source of truth: the reasoner system/task prompts in +// reasoners/harnesses.py, the merge-gate classification prompt in merge_gate.py, +// the polish prompt in polish.py, and the coverage-gap review prompt in +// orchestrator.py. +// +// Each builder renders output that is byte-identical to the Python f-string / +// str-concat builder for the same inputs, including: +// - Python str()/repr() formatting of interpolated lists (e.g. the compound +// dedup `Tags: ['a', 'b']` single-quoted list repr), +// - f-string float formatting (str(float) and `{x:.2f}`), +// - json.dumps(..., default=str) context blocks (insertion-ordered keys, +// ", "/": " separators, ensure_ascii escaping) via the pyjson encoder, and +// - Python truthiness for the optional prompt sections. +// +// The reasoner packages (T3.1) and the gates package (T3.2) import these +// builders instead of embedding prompt strings, so there is exactly one copy of +// each prompt and the golden tests (testdata/*.txt, generated by +// scripts/gen_golden.py from the real Python builders) pin it byte-for-byte. +package prompts + +import ( + "math" + "reflect" + "strconv" + "strings" +) + +// --------------------------------------------------------------------------- +// Python-parity scalar/list formatting. +// +// The Python builders interpolate values directly into f-strings, which uses +// Python's str()/repr() rendering. These helpers reproduce that rendering so +// the Go output is byte-identical. +// --------------------------------------------------------------------------- + +// pyStr reproduces Python str(v) for the scalar kinds interpolated into the +// f-string builders. +func pyStr(v any) string { + switch x := v.(type) { + case nil: + return "None" + case string: + return x + case bool: + if x { + return "True" + } + return "False" + case int: + return strconv.Itoa(x) + case int64: + return strconv.FormatInt(x, 10) + case float64: + return pyFloat(x) + case float32: + return pyFloat(float64(x)) + default: + return pyRepr(v) + } +} + +// pyRepr reproduces Python repr(v) for the value kinds these prompts repr: +// strings (single-quoted), bools, numbers, and lists (elements repr'd). This is +// what `{some_list}` interpolation produces (e.g. compound-dedup Tags). +func pyRepr(v any) string { + switch x := v.(type) { + case nil: + return "None" + case string: + return pyReprString(x) + case bool: + if x { + return "True" + } + return "False" + case int: + return strconv.Itoa(x) + case int64: + return strconv.FormatInt(x, 10) + case float64: + return pyFloat(x) + case float32: + return pyFloat(float64(x)) + } + rv := reflect.ValueOf(v) + switch rv.Kind() { + case reflect.Slice, reflect.Array: + parts := make([]string, rv.Len()) + for i := 0; i < rv.Len(); i++ { + parts[i] = pyRepr(rv.Index(i).Interface()) + } + return "[" + strings.Join(parts, ", ") + "]" + case reflect.Ptr: + if rv.IsNil() { + return "None" + } + return pyRepr(rv.Elem().Interface()) + } + return pyStr(v) +} + +// pyReprString reproduces Python's repr() of a string: single quotes by default, +// double quotes when the string contains a single quote but no double quote, +// with backslash, the active quote, and the common control characters escaped. +func pyReprString(s string) string { + quote := byte('\'') + if strings.Contains(s, "'") && !strings.Contains(s, "\"") { + quote = '"' + } + var b strings.Builder + b.WriteByte(quote) + for _, r := range s { + switch r { + case '\\': + b.WriteString(`\\`) + case rune(quote): + b.WriteByte('\\') + b.WriteRune(r) + case '\n': + b.WriteString(`\n`) + case '\r': + b.WriteString(`\r`) + case '\t': + b.WriteString(`\t`) + default: + b.WriteRune(r) + } + } + b.WriteByte(quote) + return b.String() +} + +// pyFloat reproduces Python's repr()/str() of a float (identical for finite +// floats since Python 3.1): the shortest decimal that round-trips, always +// carrying a decimal point or exponent so it reads as a float. This is also +// what json.dumps emits for a float. Integral values print with a trailing +// ".0" (Python repr(100.0) == "100.0"). +func pyFloat(f float64) string { + if math.IsInf(f, 1) { + return "inf" + } + if math.IsInf(f, -1) { + return "-inf" + } + if math.IsNaN(f) { + return "nan" + } + s := strconv.FormatFloat(f, 'g', -1, 64) + if !strings.ContainsAny(s, ".eE") { + s += ".0" + } + return s +} + +// pyDotTwoF reproduces Python `f"{x:.2f}"` — fixed two decimals, round-half-to- +// even (Go's strconv 'f' and Python's format spec agree on banker's rounding). +func pyDotTwoF(f float64) string { + return strconv.FormatFloat(f, 'f', 2, 64) +} + +// runeSlice reproduces Python's s[:n] slice, which counts Unicode code points, +// not bytes. +func runeSlice(s string, n int) string { + if n < 0 { + n = 0 + } + runes := []rune(s) + if len(runes) <= n { + return s + } + return string(runes[:n]) +} + +// firstN returns xs[:n] (Python list slice), safe when len(xs) <= n. +func firstN[T any](xs []T, n int) []T { + if len(xs) <= n { + return xs + } + return xs[:n] +} + +// joinComma reproduces ", ".join(list) for a []string. +func joinComma(xs []string) string { + return strings.Join(xs, ", ") +} + +// orNilSlice reproduces Python's `x or []`: nil for an empty/absent list. +func orEmpty(xs []string) []string { + if xs == nil { + return []string{} + } + return xs +} diff --git a/go/internal/prompts/intake.go b/go/internal/prompts/intake.go new file mode 100644 index 0000000..1da306e --- /dev/null +++ b/go/internal/prompts/intake.go @@ -0,0 +1,44 @@ +package prompts + +// Ports the intake_phase builders from reasoners/harnesses.py: +// - the .ai() gate system + user prompt (metadata classification), and +// - the .harness() fallback prompt (full classification). + +// IntakeGateSystem is the system prompt for the intake .ai() gate. +const IntakeGateSystem = "Return pr_type, complexity, and confident only. Use the provided schema." + +// IntakeGatePrompt builds the intake .ai() gate user prompt. languages must be +// pre-sorted (the reasoner uses sorted(_extract_languages(pr))); commitMessages +// is truncated to the first 5 and description to the first 500 runes, matching +// the Python json payload. +func IntakeGatePrompt(title, description string, labels []string, author string, filesChanged int, languages, commitMessages []string) string { + ctx := omap( + "title", title, + "description", runeSlice(description, 500), + "labels", orEmpty(labels), + "author", author, + "files_changed", filesChanged, + "languages", orEmpty(languages), + "commit_messages", orEmpty(firstN(commitMessages, 5)), + ) + return "Classify this pull request from metadata and diff footprint.\n\n" + pyJSON(ctx) +} + +// IntakeFallbackPrompt builds the intake_phase .harness() fallback prompt. +// description is truncated to the first 1000 runes. +func IntakeFallbackPrompt(title, description, requestedDepth string, languages []string, filesChanged int) string { + ctx := omap( + "pr_title", title, + "description", runeSlice(description, 1000), + "requested_depth", requestedDepth, + "languages", orEmpty(languages), + "files_changed", filesChanged, + ) + return "Classify this pull request for a multi-agent review pipeline. " + + "Downstream reviewers will rely on your classification to decide review depth " + + "and focus areas, so accuracy matters more than speed.\n\n" + + "Determine: PR type (feature/bugfix/refactor/docs/config/dependency/test), " + + "complexity (trivial/standard/complex/massive), areas touched, risk signals, " + + "AI-generation confidence, and write a technical PR summary that captures the " + + "actual substance of the change (not just the PR title restated).\n\n" + pyJSON(ctx) +} diff --git a/go/internal/prompts/meta.go b/go/internal/prompts/meta.go new file mode 100644 index 0000000..928105a --- /dev/null +++ b/go/internal/prompts/meta.go @@ -0,0 +1,247 @@ +package prompts + +import ( + "path/filepath" + "unicode/utf8" + + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// Ports the three meta-dimension selectors (meta_semantic / meta_mechanical / +// meta_systemic) and their shared _build_meta_context helper from +// reasoners/harnesses.py. + +// StrPair is an insertion-ordered (key, value) pair, used where a Python dict's +// insertion order is semantically significant (meta diff_patches). +type StrPair struct{ Key, Val string } + +// MetaContext ports _build_meta_context: the shared json context string all +// three meta selectors interpolate. diffPatches is added only when non-empty +// (Python `if diff_patches:`), truncated to the first 15 pairs in order; +// reviewerFeedback is added only when non-empty. +func MetaContext(intake schemas.IntakeResult, anatomy schemas.AnatomyResult, diffPatches []StrPair, reviewerFeedback string) string { + ctx := omap( + "intake", omap( + "pr_type", intake.PrType, + "complexity", intake.Complexity, + "pr_summary", intake.PrSummary, + "areas_touched", orEmpty(intake.AreasTouched), + "risk_signals", orEmpty(intake.RiskSignals), + ), + "clusters", clusterDescriptions(anatomy.Clusters), + "risk_surfaces", orEmpty(anatomy.RiskSurfaces), + "pr_narrative", anatomy.PrNarrative, + "blast_radius", orEmpty(firstN(anatomy.BlastRadius, 20)), + "intent_gaps", orEmpty(anatomy.IntentGaps), + "unrelated_changes", orEmpty(anatomy.UnrelatedChanges), + "context_notes", anatomy.ContextNotes, + "diff_stats", omap( + "total_files", anatomy.Stats.TotalFiles, + "total_additions", anatomy.Stats.TotalAdditions, + "total_deletions", anatomy.Stats.TotalDeletions, + ), + "file_paths", filePaths(anatomy.Files), + ) + if len(diffPatches) > 0 { + patches := omap() + for _, p := range firstN(diffPatches, 15) { + patches.Set(p.Key, p.Val) + } + ctx.Set("diff_patches", patches) + } + if reviewerFeedback != "" { + ctx.Set("human_reviewer_guidance", reviewerFeedback) + } + return pyJSON(ctx) +} + +// metaContextRef ports the per-selector context_ref resolution: inline context +// unless a repo path is set and the context exceeds 8000 characters, in which +// case the context is written to a file (by the reasoner) and referenced by +// path. This builder is pure — it computes the path/message but does not write. +// len() in Python counts code points, so we use RuneCountInString. +func metaContextRef(lens, context, repoPath string) string { + if repoPath != "" && utf8.RuneCountInString(context) > 8000 { + fp := filepath.Join(repoPath, ".pr-af-context", "meta_"+lens+"_context.json") + return "\n\nFull analysis context written to: " + fp + + "\nRead this file for complete PR context including diff patches." + } + return context +} + +// MetaSemanticPrompt builds the meta_semantic selector prompt. +func MetaSemanticPrompt(context, repoPath, depth string) string { + return metaSemanticPre1 + depth + metaSemanticPre2 + metaContextRef("semantic", context, repoPath) +} + +// MetaMechanicalPrompt builds the meta_mechanical selector prompt. +func MetaMechanicalPrompt(context, repoPath, depth string) string { + return metaMechanicalPre1 + depth + metaMechanicalPre2 + metaContextRef("mechanical", context, repoPath) +} + +// MetaSystemicPrompt builds the meta_systemic selector prompt. +func MetaSystemicPrompt(context, repoPath, depth string) string { + return metaSystemicPre1 + depth + metaSystemicPre2 + metaContextRef("systemic", context, repoPath) +} + +const metaSemanticPre1 = "You are a principal engineer designing review dimensions through the SEMANTIC lens.\n\n" + + "## Your Lens: SEMANTIC — What does this code DO differently?\n\n" + + "You are responsible for generating review dimensions that investigate the " + + "BEHAVIORAL and LOGICAL aspects of this change. Think about:\n\n" + + "- **Logic changes**: Does the new code produce different results than the old code " + + "for ANY input? Not just the happy path — edge cases, error conditions, boundary values.\n" + + "- **API contract changes**: Do callers still get what they expect? Return types, " + + "error types, side effects, ordering guarantees.\n" + + "- **Concurrency & state**: Thread safety, shared mutable state, lock ordering, " + + "resource lifecycle changes.\n" + + "- **Security implications**: Authentication bypass, authorization checks, input " + + "validation changes, secret handling.\n" + + "- **Error handling**: Are exceptions caught the same way? Are error codes preserved? " + + "Are there silent swallows or unhandled paths?\n" + + "- **Data flow**: Does data pass through the same transformations? Are there type " + + "coercions, format changes, or encoding differences?\n\n" + + "## Investigation Protocol\n\n" + + "You have full access to the repository. The context below gives you a starting " + + "point — PR summary, anatomy, and diff patches.\n\n" + + "- START by reading the context to understand WHAT changed.\n" + + "- THEN browse the actual source files to understand HOW the changed code fits into " + + "the broader codebase.\n" + + "- Read the changed functions. Then find their callers. Trace how data flows through " + + "them. Check what error paths exist.\n" + + "- ADAPT your investigation based on what you discover — if you find a concerning " + + "pattern, dig deeper in adjacent files and call paths.\n\n" + + "## What NOT to Include\n\n" + + "Do NOT generate dimensions about:\n" + + "- Code style, naming, formatting (that's Systemic)\n" + + "- Type signatures, calling conventions, decorator mechanics (that's Mechanical)\n" + + "- Pattern consistency, architectural fit (that's Systemic)\n\n" + + "## Dimension Craft\n\n" + + "Each dimension must be a SPECIFIC investigation question, not a generic category.\n" + + "Good: 'Does the migration from sync to async preserve error propagation to callers?'\n" + + "Bad: 'Check for concurrency issues'\n\n" + + "Each dimension needs: id, name, review_prompt (complete briefing for the reviewer), " + + "target_files, context_files, and priority (higher = more critical).\n" + + "The review_prompt must include specific file paths and line ranges discovered during " + + "your repository investigation, plus the exact verification steps the reviewer should run.\n\n" + + "## Quality Gate\n\n" + + "Do NOT generate dimensions based solely on diff text. Every dimension must be informed " + + "by what you discovered in the actual codebase. If your rationale says 'visible in the " + + "diff' or 'based on the patches', you have not investigated enough.\n\n" + + "Depth '" + +const metaSemanticPre2 = "' means: quick=1-2 dimensions, standard=2-3, deep=3-5\n" + + "If the PR has no semantic risk, return ZERO dimensions. Do not pad.\n\n" + + "Also provide a rationale explaining your dimension choices and a confidence " + + "score (0-1) for how completely your dimensions cover the semantic risk surface.\n\n" + +const metaMechanicalPre1 = "You are a principal engineer designing review dimensions through the MECHANICAL lens.\n\n" + + "## Your Lens: MECHANICAL — Does this code WORK correctly?\n\n" + + "You are responsible for generating review dimensions that investigate whether " + + "the code is STRUCTURALLY correct at the language and framework level. Think about:\n\n" + + "- **Type correctness**: Do function return types match what callers expect? " + + "Are there implicit type coercions that will fail at runtime? Does `list[dict]` " + + "flow where `str` is expected?\n" + + "- **Signature compatibility**: If a function's parameters changed, do ALL callers " + + "(direct and indirect) still pass the right arguments? Are there default values " + + "that mask breakage?\n" + + "- **Decorator/middleware effects**: When a decorator injects parameters (like " + + "thread-local storage), are all call paths aware? Does calling a method directly " + + "vs through a dispatcher change what parameters it receives?\n" + + "- **Framework contract compliance**: Does this code satisfy the framework's " + + "expectations? Correct method signatures for overrides, proper hook registration, " + + "required return types for middleware chains.\n" + + "- **Import/dependency resolution**: Are all imports valid? Are there circular " + + "dependencies? Are optional dependencies guarded?\n" + + "- **Runtime mechanics**: Will this code actually execute without AttributeError, " + + "TypeError, KeyError, ImportError? Trace the exact runtime behavior.\n\n" + + "## Investigation Protocol\n\n" + + "You have full access to the repository. The context below gives you a starting " + + "point — PR summary, anatomy, and diff patches.\n\n" + + "- START by reading the context to understand WHAT changed.\n" + + "- THEN browse the actual source files to understand HOW the changed code fits into " + + "the broader codebase.\n" + + "- Read the actual function signatures that changed. Then search for all callers of " + + "those functions. Check whether callers pass the right arguments and whether import " + + "chains still resolve correctly.\n" + + "- ADAPT your investigation based on what you discover — if you find one caller or " + + "dependency break, keep tracing until you understand blast radius.\n\n" + + "## What NOT to Include\n\n" + + "Do NOT generate dimensions about:\n" + + "- Whether the logic is correct (that's Semantic)\n" + + "- Code quality or patterns (that's Systemic)\n" + + "- Business logic validation (that's Semantic)\n\n" + + "## Dimension Craft\n\n" + + "Each dimension must target a SPECIFIC mechanical concern.\n" + + "Good: 'Do all callers of `process_item()` pass the new `context` parameter " + + "added in this PR?'\n" + + "Bad: 'Check for type errors'\n\n" + + "Each dimension needs: id, name, review_prompt (complete briefing for the reviewer), " + + "target_files, context_files, and priority (higher = more critical).\n" + + "The review_prompt must include specific file paths and line ranges discovered during " + + "your repository investigation, plus the exact call sites/import chains to verify.\n\n" + + "## Quality Gate\n\n" + + "Do NOT generate dimensions based solely on diff text. Every dimension must be informed " + + "by what you discovered in the actual codebase. If your rationale says 'visible in the " + + "diff' or 'based on the patches', you have not investigated enough.\n\n" + + "Depth '" + +const metaMechanicalPre2 = "' means: quick=1-2 dimensions, standard=2-3, deep=3-5\n" + + "If the PR has no mechanical risk, return ZERO dimensions. Do not pad.\n\n" + + "Also provide a rationale explaining your dimension choices and a confidence " + + "score (0-1) for how completely your dimensions cover the mechanical risk surface.\n\n" + +const metaSystemicPre1 = "You are a principal engineer designing review dimensions through the SYSTEMIC lens.\n\n" + + "## Your Lens: SYSTEMIC — How does this code FIT?\n\n" + + "You are responsible for generating review dimensions that investigate whether " + + "this change is ARCHITECTURALLY sound and consistent with the codebase. Think about:\n\n" + + "- **Pattern consistency**: Does this change follow established patterns in the " + + "codebase, or does it introduce a new pattern where one already exists? If it " + + "introduces a new pattern, is it justified?\n" + + "- **Complexity impact**: Does this change increase cyclomatic complexity? " + + "Are there deeply nested conditionals, god functions, or tangled dependencies?\n" + + "- **Abstraction quality**: Are the right things abstracted? Is there unnecessary " + + "indirection, or conversely, inline code that should be extracted?\n" + + "- **Test coverage alignment**: Are the changes tested? Do tests cover the " + + "interesting edge cases, or just the happy path? Are there test patterns that " + + "should be followed?\n" + + "- **Documentation debt**: Are public APIs documented? Are complex algorithms " + + "explained? Are there misleading comments that weren't updated?\n" + + "- **Dependency hygiene**: Are new dependencies justified? Are there lighter " + + "alternatives? Is the dependency well-maintained?\n" + + "- **Migration completeness**: If this is part of a larger migration, is it " + + "complete or does it leave the codebase in a mixed state?\n\n" + + "## Investigation Protocol\n\n" + + "You have full access to the repository. The context below gives you a starting " + + "point — PR summary, anatomy, and diff patches.\n\n" + + "- START by reading the context to understand WHAT changed.\n" + + "- THEN browse the actual source files to understand HOW the changed code fits into " + + "the broader codebase.\n" + + "- Browse similar files in the same directories to understand existing patterns and " + + "compare the changed code against those patterns.\n" + + "- ADAPT your investigation based on what you discover — if the change deviates from " + + "an established architecture pattern, trace where else that pattern is enforced.\n\n" + + "## What NOT to Include\n\n" + + "Do NOT generate dimensions about:\n" + + "- Whether the logic produces correct results (that's Semantic)\n" + + "- Whether the code will run without type/import errors (that's Mechanical)\n" + + "- Specific bug hunting (that's Semantic/Mechanical)\n\n" + + "## Dimension Craft\n\n" + + "Each dimension must target a SPECIFIC systemic concern.\n" + + "Good: 'Does the new `UserService` class follow the existing service pattern " + + "(stateless, injected deps, interface-first)?'\n" + + "Bad: 'Check code quality'\n\n" + + "Each dimension needs: id, name, review_prompt (complete briefing for the reviewer), " + + "target_files, context_files, and priority (higher = more critical).\n" + + "The review_prompt must include specific file paths and line ranges discovered during " + + "your repository investigation, plus the pattern comparisons the reviewer should validate.\n\n" + + "## Quality Gate\n\n" + + "Do NOT generate dimensions based solely on diff text. Every dimension must be informed " + + "by what you discovered in the actual codebase. If your rationale says 'visible in the " + + "diff' or 'based on the patches', you have not investigated enough.\n\n" + + "Depth '" + +const metaSystemicPre2 = "' means: quick=0-1 dimensions, standard=1-2, deep=2-3\n" + + "Systemic concerns are LOWER priority than Semantic and Mechanical. " + + "If the PR is a focused bugfix with no architectural impact, return ZERO dimensions.\n\n" + + "Also provide a rationale explaining your dimension choices and a confidence " + + "score (0-1) for how completely your dimensions cover the systemic risk surface.\n\n" diff --git a/go/internal/prompts/obligations.go b/go/internal/prompts/obligations.go new file mode 100644 index 0000000..0a9d5d5 --- /dev/null +++ b/go/internal/prompts/obligations.go @@ -0,0 +1,56 @@ +package prompts + +// Ports extract_obligations and verify_obligation from reasoners/harnesses.py. + +const extractObligationsPreamble = "You map the CONSISTENCY OBLIGATIONS the changed code creates — you do NOT judge them yet.\n\n" + + "A defect is almost always a place where code at ONE location relies on something being true " + + "at ANOTHER location, and it isn't. Your job: read the changed code and enumerate every such " + + "cross-location reliance, so each can be checked by going and reading the other end.\n\n" + + "For each operation the changed code performs, ask: 'for this to be correct, what must be true " + + "ELSEWHERE?' — at the definition it calls, the place a value it passes is produced or stored, " + + "the counterpart of a branch it takes, the real type behind an assumption it makes, or the code " + + "that consumes what it produces. Each distinct reliance is one obligation.\n\n" + + "Derive obligations from the STRUCTURE of this specific code — never from a remembered list of " + + "common bugs. Be exhaustive: every call, argument, branch, type assumption, and produced/consumed " + + "value is a candidate. Favour load-bearing reliances (security, correctness, data integrity) over " + + "cosmetic ones. It is fine if most obligations turn out to hold — completeness now matters more.\n\n" + + "Each obligation has three fields:\n" + + "- where: the exact changed line/operation that creates the reliance (file + a code snippet).\n" + + "- relies_on: a concrete description of the OTHER location or fact a verifier must GO FIND and " + + "read — specific enough to locate it (e.g. 'the method that creates/stores these resources, to " + + "see which key/owner they are stored under', or 'the sibling branch that handles the opposite " + + "outcome, to compare how it is treated').\n" + + "- property: the exact thing that must hold for the changed line to be correct (e.g. 'the lookup " + + "key here equals the key used when the resource is stored', 'both branches treat their outcome " + + "with the same level of trust').\n\n" + + "Return up to 14 obligations, highest-stakes first.\n\n" + +// ExtractObligationsPrompt ports extract_obligations. Caller invokes only when +// there is at least one non-empty patch. +func ExtractObligationsPrompt(diffPatches []StrPair, repoPath, prContext string) string { + prBlock := "" + if prContext != "" { + prBlock = "## PR Context\n\n" + prContext + "\n\n" + } + return extractObligationsPreamble + prBlock + diffRef(filterPatches(diffPatches), repoPath, "obligations_diff.md") +} + +// VerifyObligationPrompt ports verify_obligation's single-obligation prompt. +func VerifyObligationPrompt(where, reliesOn, property string) string { + return "You verify ONE consistency obligation, and nothing else. This is your entire job, so do it " + + "thoroughly: actually GO FIND and READ the other location, do not reason from memory or guess.\n\n" + + "## The changed code relies on something elsewhere\n" + + "- WHERE (the changed code): " + where + "\n" + + "- IT RELIES ON: " + reliesOn + "\n" + + "- PROPERTY THAT MUST HOLD: " + property + "\n\n" + + "## What to do\n" + + "1. Locate the OTHER end described in 'relies on' — search the repository, open the file, read it.\n" + + "2. Read the changed location too. Compare the two ends.\n" + + "3. Decide whether the PROPERTY actually holds, citing the exact code at BOTH ends.\n\n" + + "If the property HOLDS, return holds=true (no finding needed).\n" + + "If the property does NOT hold — the two ends disagree — return holds=false and fill the finding " + + "fields: a precise title, severity (critical/important/suggestion/nitpick), file_path + line_start " + + "of the changed location, body (state both ends explicitly and exactly how they disagree, and the " + + "concrete consequence that follows), evidence (quote the code from BOTH ends), and a suggestion. " + + "Only report holds=false if you VERIFIED the disagreement in the real code. confidence >= 0.6." +} diff --git a/go/internal/prompts/planning.go b/go/internal/prompts/planning.go new file mode 100644 index 0000000..793e784 --- /dev/null +++ b/go/internal/prompts/planning.go @@ -0,0 +1,85 @@ +package prompts + +import "github.com/Agent-Field/pr-af/go/internal/schemas" + +// Ports the planning_phase .harness() prompt from reasoners/harnesses.py. +// (Registered but dead on the live path; ported for completeness.) + +const planningPre1 = "You are a principal engineer designing a review strategy for a pull request. " + + "Your job is to decompose this PR into review DIMENSIONS — each one a focused, " + + "independently-executable investigation that another senior engineer will carry out.\n\n" + + "DO NOT use generic templates like 'security review' or 'performance review'. " + + "Every dimension must be SPECIFIC to what THIS PR actually changes.\n\n" + + "## How to Think About Dimensions\n\n" + + "A dimension is NOT 'check file X for bugs'. A dimension is a specific QUESTION about " + + "the change that requires reading code to answer. Good dimensions:\n\n" + + "- 'Does the migration from library A to library B preserve error semantics?' " + + "(target: the wrapper functions; context: the callers)\n" + + "- 'Are all callers of method X updated to match its new signature?' " + + "(target: the callers; context: the method definition)\n" + + "- 'Does the new default value for config Y break existing deployments?' " + + "(target: where Y is consumed; context: where Y is defined and documented)\n" + + "- 'Can the refactored data flow produce states that the old flow could not?' " + + "(target: state transitions; context: consumers of that state)\n\n" + + "Bad dimensions: 'Review security', 'Check for bugs', 'Validate tests'\n\n" + + "## Dimension Categories to Consider\n\n" + + "Not all will apply — generate ONLY what matters for THIS PR:\n\n" + + "1. **Behavioral Equivalence**: When code is refactored or a dependency is swapped, " + + "does the new code behave identically in all paths? Edge cases, error handling, " + + "return types, side effects, timing.\n\n" + + "2. **Contract Preservation**: Are function signatures, decorator behaviors, " + + "serialization formats, and API responses preserved? When a decorator adds an " + + "implicit parameter, are all call sites (direct AND indirect) updated?\n\n" + + "3. **Cross-Boundary Consistency**: Changes in module A may violate assumptions " + + "in module B. Look for shared types, constants, configs, or patterns that appear " + + "in both changed and unchanged files.\n\n" + + "4. **Error Propagation & Recovery**: Follow every error path. Does the new code " + + "catch the same exceptions? Raise the same error types? Preserve error codes? " + + "Avoid swallowing errors that the old code surfaced?\n\n" + + "5. **State & Concurrency**: Thread-local storage, shared handles, connection " + + "lifecycle, resource cleanup. Does the change introduce shared mutable state, " + + "or change who owns a resource?\n\n" + + "6. **Data Integrity & Migration**: Schema changes, default value changes, " + + "format changes. Can old data be read by new code? Can new data be read by " + + "rollback code?\n\n" + + "7. **Architectural Coherence**: Does this change follow or violate the codebase's " + + "established patterns? Does it introduce a new pattern where one already exists? " + + "Does it create technical debt or resolve it?\n\n" + + "## Review Prompt Craft\n\n" + + "Each dimension's `review_prompt` will be given to another engineer who will read " + + "the actual code. Make it a COMPLETE briefing:\n" + + "- State exactly what to investigate\n" + + "- Explain what 'correct' looks like\n" + + "- Point out what subtle failures would look like\n" + + "- Mention specific functions, classes, or patterns to trace\n\n" + + "## Cross-Reference Hints\n\n" + + "Identify specific pairs or groups of findings that could interact. " + + "Example: 'If dimension A finds that error types changed, AND dimension B finds " + + "callers that catch specific error types, those interact.'\n\n" + + "## Output Requirements\n\n" + + "- Prioritize dimensions by risk (highest first)\n" + + "- Each dimension has: target_files (to inspect) and context_files (for reference)\n" + + "- Depth '" + +const planningPre2 = "' means: quick=2-3 dimensions, standard=3-5, deep=5-8, thorough=6-10\n" + + "- If the PR has a narrow scope, fewer dimensions is BETTER than padding with fluff\n\n" + +// PlanningPrompt builds the planning_phase prompt. +func PlanningPrompt(intake schemas.IntakeResult, anatomy schemas.AnatomyResult, depth string, hints []string) string { + ctx := omap( + "intake", omap( + "pr_type", intake.PrType, + "complexity", intake.Complexity, + "pr_summary", intake.PrSummary, + "areas_touched", orEmpty(intake.AreasTouched), + "risk_signals", orEmpty(intake.RiskSignals), + ), + "clusters", clusterDescriptions(anatomy.Clusters), + "risk_surfaces", orEmpty(anatomy.RiskSurfaces), + "pr_narrative", anatomy.PrNarrative, + "depth", depth, + "hints", orEmpty(hints), + "file_paths", filePaths(anatomy.Files), + ) + return planningPre1 + depth + planningPre2 + pyJSON(ctx) +} diff --git a/go/internal/prompts/pyjson.go b/go/internal/prompts/pyjson.go new file mode 100644 index 0000000..bd9f5bd --- /dev/null +++ b/go/internal/prompts/pyjson.go @@ -0,0 +1,207 @@ +package prompts + +import ( + "strconv" + "strings" +) + +// This file reproduces Python's json.dumps(obj, default=str) exactly, for the +// context blocks the reasoners interpolate into their prompts. The two facts the +// stdlib encoding/json gets "wrong" relative to Python that we must match: +// - separators: json.dumps defaults to ", " and ": " (Go uses "," and ":"). +// - key order: Python dicts preserve insertion order; Go maps are unordered. +// So every object is built as an *OMap (insertion-ordered). +// - ensure_ascii=True: non-ASCII runes are emitted as \uXXXX escapes. +// - floats render via Python's float repr (see pyFloat), ints as ints. + +// OMap is an insertion-ordered JSON object. The reasoners build their context +// payloads with a fixed key order, so callers construct these with omap(...) and +// the encoder preserves that order — matching Python dict repr / json.dumps. +type OMap struct { + keys []string + vals map[string]any +} + +// omap builds an ordered object from alternating key, value arguments. +func omap(kv ...any) *OMap { + m := &OMap{vals: make(map[string]any, len(kv)/2)} + for i := 0; i+1 < len(kv); i += 2 { + k := kv[i].(string) + if _, ok := m.vals[k]; !ok { + m.keys = append(m.keys, k) + } + m.vals[k] = kv[i+1] + } + return m +} + +// Set appends or overwrites a key, preserving first-insertion order. +func (m *OMap) Set(k string, v any) *OMap { + if _, ok := m.vals[k]; !ok { + m.keys = append(m.keys, k) + } + m.vals[k] = v + return m +} + +// GetStr returns the string value at key, or def if absent/not a string — +// mirroring Python dict.get(key, default) followed by use as a string. +func (m *OMap) GetStr(key, def string) string { + if v, ok := m.vals[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + return def +} + +// GetStrSlice returns the []string value at key, or an empty slice if absent. +func (m *OMap) GetStrSlice(key string) []string { + if v, ok := m.vals[key]; ok { + if s, ok := v.([]string); ok { + return s + } + } + return nil +} + +// Has reports whether key is present (mirrors `key in dict`). +func (m *OMap) Has(key string) bool { + _, ok := m.vals[key] + return ok +} + +// pyJSON reproduces json.dumps(v, default=str) for the value kinds our contexts +// use: *OMap, []string, []any, string, int, float64, bool, nil, *string. +func pyJSON(v any) string { + var b strings.Builder + pyJSONWrite(&b, v) + return b.String() +} + +func pyJSONWrite(b *strings.Builder, v any) { + switch x := v.(type) { + case nil: + b.WriteString("null") + case *OMap: + if x == nil { + b.WriteString("null") + return + } + b.WriteByte('{') + for i, k := range x.keys { + if i > 0 { + b.WriteString(", ") + } + pyJSONString(b, k) + b.WriteString(": ") + pyJSONWrite(b, x.vals[k]) + } + b.WriteByte('}') + case string: + pyJSONString(b, x) + case *string: + if x == nil { + b.WriteString("null") + } else { + pyJSONString(b, *x) + } + case bool: + if x { + b.WriteString("true") + } else { + b.WriteString("false") + } + case int: + b.WriteString(strconv.Itoa(x)) + case int64: + b.WriteString(strconv.FormatInt(x, 10)) + case float64: + b.WriteString(pyFloat(x)) + case float32: + b.WriteString(pyFloat(float64(x))) + case []string: + b.WriteByte('[') + for i, e := range x { + if i > 0 { + b.WriteString(", ") + } + pyJSONString(b, e) + } + b.WriteByte(']') + case []any: + b.WriteByte('[') + for i, e := range x { + if i > 0 { + b.WriteString(", ") + } + pyJSONWrite(b, e) + } + b.WriteByte(']') + case []*OMap: + b.WriteByte('[') + for i, e := range x { + if i > 0 { + b.WriteString(", ") + } + pyJSONWrite(b, e) + } + b.WriteByte(']') + default: + // json.dumps default=str: fall back to str(obj). Our payloads never hit + // this, but keep it faithful rather than panicking. + pyJSONString(b, pyStr(v)) + } +} + +// pyJSONString reproduces json.dumps of a string with ensure_ascii=True: the +// standard short escapes plus \uXXXX for every control char and every non-ASCII +// rune (astral runes as UTF-16 surrogate pairs). Note Python does NOT escape '/'. +func pyJSONString(b *strings.Builder, s string) { + b.WriteByte('"') + for _, r := range s { + switch r { + case '"': + b.WriteString(`\"`) + case '\\': + b.WriteString(`\\`) + case '\n': + b.WriteString(`\n`) + case '\r': + b.WriteString(`\r`) + case '\t': + b.WriteString(`\t`) + case '\b': + b.WriteString(`\b`) + case '\f': + b.WriteString(`\f`) + default: + switch { + case r < 0x20: + writeU(b, r) + case r < 0x7f: + b.WriteRune(r) + case r <= 0xffff: + writeU(b, r) + default: + // Astral plane -> UTF-16 surrogate pair, as CPython emits. + r -= 0x10000 + hi := 0xd800 + (r >> 10) + lo := 0xdc00 + (r & 0x3ff) + writeU(b, hi) + writeU(b, lo) + } + } + } + b.WriteByte('"') +} + +const hexDigits = "0123456789abcdef" + +func writeU(b *strings.Builder, r rune) { + b.WriteString(`\u`) + b.WriteByte(hexDigits[(r>>12)&0xf]) + b.WriteByte(hexDigits[(r>>8)&0xf]) + b.WriteByte(hexDigits[(r>>4)&0xf]) + b.WriteByte(hexDigits[r&0xf]) +} diff --git a/go/internal/prompts/reviewdim.go b/go/internal/prompts/reviewdim.go new file mode 100644 index 0000000..be0379f --- /dev/null +++ b/go/internal/prompts/reviewdim.go @@ -0,0 +1,228 @@ +package prompts + +import ( + "path/filepath" + "strconv" + "strings" + "unicode/utf8" +) + +// Ports review_dimension from reasoners/harnesses.py — the richest builder, with +// seven optional sections assembled by Python truthiness plus two "written to +// file" branches. + +// ReviewDimensionOptions mirrors the review_dimension reasoner signature. +type ReviewDimensionOptions struct { + ReviewPrompt string + TargetFiles []string + ContextFiles []string + RepoPath string + CurrentDepth int + MaxDepth int + PrNarrative string + RiskSurfaces []string + IntakeSummary string + DiffPatches map[string]string + AllDimensionNames []string + ReviewerFeedback string + PrimedCode string +} + +// ReviewDimensionPrompt builds the review_dimension reviewer prompt. +func ReviewDimensionPrompt(o ReviewDimensionOptions) string { + canSpawn := o.CurrentDepth < o.MaxDepth + + feedbackSection := "" + if o.ReviewerFeedback != "" { + feedbackSection = "## Human Reviewer Guidance (IMPORTANT)\n\n" + + "A human reviewer saw the previous round of findings and asked for a re-review " + + "with this guidance:\n\n> " + o.ReviewerFeedback + "\n\n" + + "Adjust your review accordingly — e.g. if asked to tone it down or drop nitpicks, " + + "raise your bar and report only findings that clearly meet it; if asked to focus on " + + "a specific area, prioritize that. Honor this guidance.\n\n" + } + + prContextSection := "" + if o.PrNarrative != "" || len(o.RiskSurfaces) > 0 { + narrative := o.PrNarrative + if narrative == "" { + narrative = "not provided" + } + risks := "none provided" + if len(o.RiskSurfaces) > 0 { + risks = joinComma(o.RiskSurfaces) + } + prContextSection = "## PR Context\n\n" + + "PR narrative: " + narrative + "\n" + + "Risk surfaces: " + risks + "\n\n" + } + + intakeSection := "" + if o.IntakeSummary != "" { + intakeSection = "## Intake Summary\n\n" + o.IntakeSummary + "\n\n" + } + + dimensionsSection := "## Other Review Dimensions\n\n" + + "Other dimensions being reviewed in parallel: " + joinComma(orEmpty(o.AllDimensionNames)) + ". " + + "Avoid duplicating findings that clearly belong to another dimension.\n\n" + + diffSection := "" + if len(o.DiffPatches) > 0 { + var parts []string + for _, path := range o.TargetFiles { + if patch, ok := o.DiffPatches[path]; ok && patch != "" { + parts = append(parts, "### "+path+"\n```diff\n"+patch+"\n```") + } + } + if len(parts) > 0 { + patchesText := strings.Join(parts, "\n\n") + if o.RepoPath != "" && utf8.RuneCountInString(patchesText) > 6000 { + pf := filepath.Join(o.RepoPath, ".pr-af-context", "review_dimension_diff_patches.md") + diffSection = "## Diff Patches for Target Files\n\n" + + "Full diff patches written to: " + pf + "\n" + + "Read this file for detailed target-file patches.\n\n" + } else { + diffSection = "## Diff Patches for Target Files\n\n" + patchesText + "\n\n" + } + } + } + + primedSection := "" + if o.PrimedCode != "" { + if o.RepoPath != "" && utf8.RuneCountInString(o.PrimedCode) > 6000 { + pf := filepath.Join(o.RepoPath, ".pr-af-context", "review_dimension_primed_code.md") + primedSection = "## Target-File Code (pre-read for you)\n\n" + + "The current content of your target files (with line numbers) and their import " + + "context is written to: " + pf + "\nRead that file FIRST — it is the code you would " + + "otherwise navigate to. Open additional files only if it is insufficient.\n\n" + } else { + primedSection = "## Target-File Code (pre-read for you)\n\n" + + "The current content of your target files (with line numbers) and import context " + + "is below — this is the code you would otherwise navigate to. Reason over it " + + "directly; open additional files only if it is insufficient.\n\n" + + o.PrimedCode + "\n\n" + } + } + + spawnInstruction := "" + if canSpawn { + spawnInstruction = "\n\nSUB-REVIEW SPAWNING: You may request deeper sub-reviews for areas that need " + + "specialized investigation beyond your current scope. Only request a sub-review when:\n" + + "- You found a complex issue that requires reading additional files not in your target list\n" + + "- A finding reveals a pattern that may repeat across other files\n" + + "- You suspect a security/correctness issue but lack context to confirm it\n" + + "Current depth: " + strconv.Itoa(o.CurrentDepth) + "/" + strconv.Itoa(o.MaxDepth) + ". " + + "You have " + strconv.Itoa(o.MaxDepth-o.CurrentDepth) + " level(s) of sub-review remaining. " + + "Do NOT request sub-reviews for trivial issues or things you can resolve yourself. " + + "Maximum 2 sub-reviews per dimension." + } else { + spawnInstruction = "\n\nYou are at maximum review depth. Do NOT request any sub-reviews. " + + "Report all findings directly, even if uncertain." + } + + contextFiles := "none" + if len(o.ContextFiles) > 0 { + contextFiles = joinComma(o.ContextFiles) + } + + return "You are a senior engineer performing a focused code review. You have been assigned " + + "a specific review dimension with a clear investigation question.\n\n" + + "## Your Assignment\n\n" + + o.ReviewPrompt + "\n\n" + + "**Target files** (read and analyze these): " + joinComma(o.TargetFiles) + "\n" + + "**Context files** (reference as needed): " + contextFiles + "\n\n" + + feedbackSection + + prContextSection + + intakeSection + + dimensionsSection + + diffSection + + primedSection + + reviewDimStatic + + spawnInstruction +} + +const reviewDimStatic = "## How to Review\n\n" + + "You have full repository access. When a pre-read target-file code section is provided " + + "above, reason over it directly and open additional files only when it is insufficient " + + "(e.g. to follow a definition or caller it does not contain). When it is not provided, " + + "READ the actual files — the diff shows WHAT changed, the repo shows the FULL context " + + "of WHY it matters.\n\n" + + "Do NOT just scan for surface-level issues. Think deeply about what this code DOES:\n\n" + + "1. **Read the target files thoroughly.** Understand the control flow, data flow, " + + "and error paths. Pay attention to what happens at boundaries — function entry/exit, " + + "exception handlers, early returns, decorator effects.\n\n" + + "2. **Trace implications.** If a function signature changed, who calls it? " + + "If a default value changed, where is it consumed? If an import was added or removed, " + + "what depended on it? When checking callers/consumers of changed code, actually search " + + "the codebase for references and verify call sites in real files.\n\n" + + "3. **Check behavioral equivalence.** If code was refactored or a library was swapped, " + + "does the new version handle ALL the same cases? Edge cases matter: empty inputs, " + + "None values, concurrent access, error conditions, type mismatches.\n\n" + + "4. **Verify contracts.** Are return types preserved? Are exception types consistent? " + + "Do decorators inject parameters that callers might not account for? " + + "Are there implicit ordering dependencies?\n\n" + + "5. **Think about what's NOT in the diff.** The most dangerous bugs are in code " + + "that WASN'T changed but SHOULD have been. If a method's signature changed, " + + "every caller needs updating. If an enum added a variant, every switch/match " + + "needs the new case.\n\n" + + "Before reporting a finding, verify your claim against the actual code. Open the file, " + + "read the function, and confirm the behavior you are claiming exists.\n\n" + + "## Severity Calibration\n\n" + + "Use the FULL severity range. A well-calibrated review has a MIX:\n\n" + + "- **critical**: Runtime crashes, data corruption, security vulnerabilities, " + + "silent logic errors that produce wrong results. The code WILL fail in production. " + + "You must be able to describe the EXACT failure scenario — 'X calls Y with Z, " + + "which causes W'. Vague concerns are not critical.\n" + + "- **important**: Missing error handling, validation gaps, API contract violations, " + + "race conditions under realistic load, performance traps with specific data sizes. " + + "The code CAN fail under known conditions.\n" + + "- **suggestion**: Better design patterns, improved abstractions, edge cases worth " + + "handling, test coverage gaps for specific scenarios. The code works but could be " + + "more robust.\n" + + "- **nitpick**: Naming, style, readability, documentation. Truly cosmetic.\n\n" + + "The `severity` field MUST be EXACTLY one of these four lowercase strings: " + + "`critical`, `important`, `suggestion`, `nitpick`. Do NOT use `high`, `medium`, " + + "`low`, `warning`, or any other label.\n\n" + + "If you're unsure whether something is critical or important, provide your reasoning " + + "in the `body` field and let the confidence score reflect your uncertainty.\n\n" + + "## False-Positive Prevention (CRITICAL)\n\n" + + "Before reporting ANY finding, you MUST pass these three gates:\n\n" + + "### Gate 1: Reachability Proof\n" + + "Trace the EXACT call path from a real entry point to the buggy code. " + + "If you cannot construct a concrete scenario where the bug triggers, " + + "it is NOT a finding — it is speculation. Ask yourself:\n" + + "- Can this code path actually be reached in production?\n" + + "- Are there upstream guards, validators, or type checks that prevent the bad state?\n" + + "- Is the 'broken' behavior actually intentional (defensive coding, legacy compat)?\n\n" + + "### Gate 2: Evidence Chain\n" + + "Every finding MUST have a step-by-step evidence chain in the `evidence` field:\n" + + "```\n" + + "Step 1: [Entry point] calls [function] with [specific args]\n" + + "Step 2: [function] passes [value] to [downstream]\n" + + "Step 3: [downstream] expects [type/value] but receives [actual]\n" + + "Step 4: This causes [specific failure mode]\n" + + "```\n" + + "If you cannot write this chain, the finding is not well-evidenced enough to report.\n\n" + + "### Gate 3: Confidence Self-Assessment\n" + + "Rate your confidence honestly. Only report findings with confidence >= 0.6.\n" + + "- 0.9-1.0: You traced the full path and verified the failure mode\n" + + "- 0.7-0.8: Strong evidence but some assumptions about runtime state\n" + + "- 0.6: Reasonable evidence, worth flagging for human review\n" + + "- Below 0.6: Do NOT report. You are guessing.\n\n" + + "**Zero tolerance for speculative findings.** Three well-proven findings are worth " + + "infinitely more than ten speculative ones. When in doubt, DROP the finding.\n\n" + + "## Output Quality\n\n" + + "For each finding, use proper GitHub Markdown:\n" + + "- **body**: Explain the issue clearly. Use `inline code` for identifiers. " + + "Use code blocks with language hints for snippets. Bold key terms. " + + "Explain WHY this is a problem, not just WHAT is wrong.\n" + + "- **evidence**: Quote the EXACT code or trace the EXACT call path that demonstrates " + + "the issue. Include function names, parameter bindings, and return values. " + + "'Step 1: X calls Y with arg=Z. Step 2: Y binds Z to parameter W. Step 3: W.foo() " + + "fails because Z is a list, not a TLS object.'\n" + + "- **suggestion**: Describe the fix concisely. What to change, where, and why. " + + "If there are multiple valid approaches, mention the tradeoffs.\n" + + "- **file_path**: Full path from the repository root.\n" + + "- **line_start**: The specific line where the issue manifests. Be precise.\n\n" + + "Do NOT produce findings you aren't confident about just to fill a quota. " + + "Three well-evidenced findings are worth more than ten vague ones." diff --git a/go/internal/prompts/testdata/adversary_A.txt b/go/internal/prompts/testdata/adversary_A.txt new file mode 100644 index 0000000..db21d98 --- /dev/null +++ b/go/internal/prompts/testdata/adversary_A.txt @@ -0,0 +1,47 @@ +You are the adversarial reviewer. Your job is to CHALLENGE every finding and determine whether it is real or a false positive. You are skeptical by default. + +## Ground-Truth Evidence (CRITICAL) + +Each finding below includes a `ground_truth` section containing ACTUAL CODE extracted programmatically from the repository. This is the REAL code — not the reviewer's description of it. Use this as your primary verification source: + +- `primary_code`: The actual source code around the finding location (with line numbers) +- `caller_snippets`: Real call sites of functions mentioned in the finding +- `diff_hunk`: The actual diff patch for this file +- `import_context`: What this file imports and what imports it +- `related_code`: Code from non-PR files that interact with the finding + +**VERIFICATION PROTOCOL**: For each finding: +1. Read the reviewer's CLAIM about what the code does +2. Read the `ground_truth.primary_code` to see what the code ACTUALLY does +3. If the claim contradicts the ground truth → CHALLENGE as false positive +4. If the claim matches the ground truth → check caller_snippets to verify the failure scenario is reachable +5. You may ALSO browse the repo for additional verification, but the ground truth should catch most false positives + +## For Each Finding, Determine: + +1. **Does the ground truth match the claim?** Compare the reviewer's description against the actual code in `ground_truth.primary_code`. If the reviewer says 'function X uses string comparison' but the actual code uses `errors.Is()`, that is a false positive — CHALLENGE it immediately. + +2. **Is the failure scenario reachable?** Check `ground_truth.caller_snippets` to see if the described call path actually exists. Are there guards upstream that prevent the bad state? Does the calling code handle the condition? + +3. **Is the severity correct?** A 'critical' finding must have a concrete crash or corruption scenario traceable through the ground truth. If the primary code shows the issue is handled, downgrade or challenge. + +4. **Cross-file interactions**: Check `ground_truth.related_code` and `ground_truth.import_context` to understand the broader context. A finding might look valid in isolation but be prevented by code in another file. + +5. **Hidden traps**: Did the reviewer find a real issue but miss a WORSE version visible in the ground truth code? + +## Verdicts + +- **confirmed**: The ground truth supports the finding. The claim matches the actual code. The failure scenario is reachable. +- **challenged**: The ground truth contradicts the finding. The actual code does NOT do what the reviewer claims, OR upstream guards prevent the failure. +- **escalated**: The ground truth reveals the issue is WORSE than the reviewer described. + +Skepticism mode: high +AI-generated confidence: 0.7 +(Higher AI confidence: be MORE skeptical of trivial findings) + +## PR Context + +PR adds retry. + +Findings with ground-truth evidence: +[{"title": "Retry loop can spin forever", "severity": "important", "file_path": "client.py", "dimension_name": "Retry semantics", "body": "The loop never decrements the counter.", "evidence": "Step 1: caller invokes retry() with n=3.", "suggestion": null, "confidence": 0.7, "ground_truth": {"primary_code": "while True: try()", "caller_snippets": ["client.call()"], "diff_hunk": "@@ -1 +1 @@", "import_context": "import time", "related_code": "config"}}] \ No newline at end of file diff --git a/go/internal/prompts/testdata/adversary_B.txt b/go/internal/prompts/testdata/adversary_B.txt new file mode 100644 index 0000000..b2f1c59 --- /dev/null +++ b/go/internal/prompts/testdata/adversary_B.txt @@ -0,0 +1,29 @@ +You are the adversarial reviewer. Your job is to CHALLENGE every finding and determine whether it is real or a false positive. You are skeptical by default. + +## Verification Protocol + +No ground-truth evidence was extracted for these findings. You MUST read the actual repository files yourself to verify each finding. Open the file mentioned, read the function, and confirm the behavior the reviewer claims exists. + +## For Each Finding, Determine: + +1. **Does the ground truth match the claim?** Compare the reviewer's description against the actual code in `ground_truth.primary_code`. If the reviewer says 'function X uses string comparison' but the actual code uses `errors.Is()`, that is a false positive — CHALLENGE it immediately. + +2. **Is the failure scenario reachable?** Check `ground_truth.caller_snippets` to see if the described call path actually exists. Are there guards upstream that prevent the bad state? Does the calling code handle the condition? + +3. **Is the severity correct?** A 'critical' finding must have a concrete crash or corruption scenario traceable through the ground truth. If the primary code shows the issue is handled, downgrade or challenge. + +4. **Cross-file interactions**: Check `ground_truth.related_code` and `ground_truth.import_context` to understand the broader context. A finding might look valid in isolation but be prevented by code in another file. + +5. **Hidden traps**: Did the reviewer find a real issue but miss a WORSE version visible in the ground truth code? + +## Verdicts + +- **confirmed**: The ground truth supports the finding. The claim matches the actual code. The failure scenario is reachable. +- **challenged**: The ground truth contradicts the finding. The actual code does NOT do what the reviewer claims, OR upstream guards prevent the failure. +- **escalated**: The ground truth reveals the issue is WORSE than the reviewer described. + +Skepticism mode: standard +AI-generated confidence: 0.0 + +Findings with ground-truth evidence: +[{"title": "Retry loop can spin forever", "severity": "important", "file_path": "client.py", "dimension_name": "Retry semantics", "body": "The loop never decrements the counter.", "evidence": "Step 1: caller invokes retry() with n=3.", "suggestion": null, "confidence": 0.7}] \ No newline at end of file diff --git a/go/internal/prompts/testdata/adversary_C.txt b/go/internal/prompts/testdata/adversary_C.txt new file mode 100644 index 0000000..8fb0de6 --- /dev/null +++ b/go/internal/prompts/testdata/adversary_C.txt @@ -0,0 +1,29 @@ +You are the adversarial reviewer. Your job is to CHALLENGE every finding and determine whether it is real or a false positive. You are skeptical by default. + +## Verification Protocol + +No ground-truth evidence was extracted for these findings. You MUST read the actual repository files yourself to verify each finding. Open the file mentioned, read the function, and confirm the behavior the reviewer claims exists. + +## For Each Finding, Determine: + +1. **Does the ground truth match the claim?** Compare the reviewer's description against the actual code in `ground_truth.primary_code`. If the reviewer says 'function X uses string comparison' but the actual code uses `errors.Is()`, that is a false positive — CHALLENGE it immediately. + +2. **Is the failure scenario reachable?** Check `ground_truth.caller_snippets` to see if the described call path actually exists. Are there guards upstream that prevent the bad state? Does the calling code handle the condition? + +3. **Is the severity correct?** A 'critical' finding must have a concrete crash or corruption scenario traceable through the ground truth. If the primary code shows the issue is handled, downgrade or challenge. + +4. **Cross-file interactions**: Check `ground_truth.related_code` and `ground_truth.import_context` to understand the broader context. A finding might look valid in isolation but be prevented by code in another file. + +5. **Hidden traps**: Did the reviewer find a real issue but miss a WORSE version visible in the ground truth code? + +## Verdicts + +- **confirmed**: The ground truth supports the finding. The claim matches the actual code. The failure scenario is reachable. +- **challenged**: The ground truth contradicts the finding. The actual code does NOT do what the reviewer claims, OR upstream guards prevent the failure. +- **escalated**: The ground truth reveals the issue is WORSE than the reviewer described. + +Skepticism mode: standard +AI-generated confidence: 0.3 + +Full findings with ground-truth evidence written to: /tmp/pr-af-fixture-repo/.pr-af-context/adversary_findings.json +Read this file for complete finding details and code evidence. \ No newline at end of file diff --git a/go/internal/prompts/testdata/anatomy_A.txt b/go/internal/prompts/testdata/anatomy_A.txt new file mode 100644 index 0000000..90fb8be --- /dev/null +++ b/go/internal/prompts/testdata/anatomy_A.txt @@ -0,0 +1,21 @@ +You are a senior engineer performing structural analysis of a pull request before review dimensions are assigned. Your job is NOT to find bugs yet — it is to deeply understand WHAT changed, WHY it changed, and WHERE the risk surfaces are. + +Think like an architect reviewing a change set: + +1. **PR Narrative**: Write a clear technical narrative of what this PR actually does (not what the PR description says — what the CODE says). Trace the change from entry point to effect. If the PR replaces one mechanism with another, describe both the old and new mechanisms and where they differ. + +2. **Risk Surfaces**: Identify areas where this change could break things that are NOT obvious from the diff alone. Think about: + - Callers of changed functions/methods that might pass arguments differently + - Implicit contracts (ordering, timing, state) that the change might violate + - Error paths — if the old code handled errors one way, does the new code preserve that? + - Concurrency: thread safety, shared state, decorator-injected arguments + - API boundaries: do callers still get what they expect? + - Configuration/defaults that changed (especially security-sensitive ones) + +3. **Unrelated Changes**: Flag anything that doesn't belong in this PR's stated intent. + +4. **Intent Gaps**: Where does the code diverge from what the PR description promises? Where is the PR description silent about something the code actually does? + +Be specific. Name files, functions, and line ranges. A vague risk surface is useless. + +{"intake": {"pr_type": "feature", "complexity": "standard", "pr_summary": "Adds a retry wrapper around the HTTP client."}, "pr_metadata": {"title": "Add retry logic to HTTP client", "description": "Wraps the client in a retry decorator with exponential backoff.\nCloses #42.", "labels": ["enhancement", "backend"]}, "clusters": [{"id": "cluster_0", "name": "root", "description": "", "primary_language": "python", "files": ["client.py", "retry.py", "client.test.ts", "README.md"]}], "stats": {"total_files": 4, "total_additions": 0, "total_deletions": 0, "files_added": 2, "files_modified": 2, "files_removed": 0, "files_renamed": 0, "test_files_changed": 1, "test_to_code_ratio": 0.3333333333333333}, "blast_radius_count": 0, "files_changed": [{"path": "client.py", "status": "modified", "lines_added": 0, "lines_removed": 0}, {"path": "retry.py", "status": "added", "lines_added": 0, "lines_removed": 0}, {"path": "client.test.ts", "status": "added", "lines_added": 0, "lines_removed": 0}, {"path": "README.md", "status": "modified", "lines_added": 0, "lines_removed": 0}]} \ No newline at end of file diff --git a/go/internal/prompts/testdata/anatomy_B.txt b/go/internal/prompts/testdata/anatomy_B.txt new file mode 100644 index 0000000..38cac6f --- /dev/null +++ b/go/internal/prompts/testdata/anatomy_B.txt @@ -0,0 +1,21 @@ +You are a senior engineer performing structural analysis of a pull request before review dimensions are assigned. Your job is NOT to find bugs yet — it is to deeply understand WHAT changed, WHY it changed, and WHERE the risk surfaces are. + +Think like an architect reviewing a change set: + +1. **PR Narrative**: Write a clear technical narrative of what this PR actually does (not what the PR description says — what the CODE says). Trace the change from entry point to effect. If the PR replaces one mechanism with another, describe both the old and new mechanisms and where they differ. + +2. **Risk Surfaces**: Identify areas where this change could break things that are NOT obvious from the diff alone. Think about: + - Callers of changed functions/methods that might pass arguments differently + - Implicit contracts (ordering, timing, state) that the change might violate + - Error paths — if the old code handled errors one way, does the new code preserve that? + - Concurrency: thread safety, shared state, decorator-injected arguments + - API boundaries: do callers still get what they expect? + - Configuration/defaults that changed (especially security-sensitive ones) + +3. **Unrelated Changes**: Flag anything that doesn't belong in this PR's stated intent. + +4. **Intent Gaps**: Where does the code diverge from what the PR description promises? Where is the PR description silent about something the code actually does? + +Be specific. Name files, functions, and line ranges. A vague risk surface is useless. + +{"intake": {"pr_type": "feature", "complexity": "standard", "pr_summary": "Bumps a dependency."}, "pr_metadata": {"title": "Bump dep", "description": "", "labels": []}, "clusters": [], "stats": {"total_files": 0, "total_additions": 0, "total_deletions": 0, "files_added": 0, "files_modified": 0, "files_removed": 0, "files_renamed": 0, "test_files_changed": 0, "test_to_code_ratio": 0.0}, "blast_radius_count": 0, "files_changed": []} \ No newline at end of file diff --git a/go/internal/prompts/testdata/compound_dedup_A.txt b/go/internal/prompts/testdata/compound_dedup_A.txt new file mode 100644 index 0000000..8c81eca --- /dev/null +++ b/go/internal/prompts/testdata/compound_dedup_A.txt @@ -0,0 +1,34 @@ +You are a deduplication specialist reviewing compound findings from a PR review. + +Compound findings are synthesized from clusters of individual findings. Because clusters are analyzed independently and in parallel, different clusters sometimes produce findings that cover the SAME underlying insight from slightly different angles. + +Your task: identify which compound findings represent genuinely DISTINCT insights and which are near-duplicates. Two findings are duplicates when they describe the same root cause, same attack vector, or same systemic pattern — even if phrased differently or using different terminology. + +When duplicates exist, keep the finding that is: +- Most specific and actionable +- Best evidenced +- Highest severity + +Also check: does any compound finding merely RESTATE what an individual finding already says without adding a genuinely new cross-cutting insight? If so, drop it. + +COMPOUND FINDINGS TO EVALUATE (2 total): + +[0] Title: Shared retry gap + Severity: important + File: a.py + Tags: ['correctness', 'compound'] + Body: bd lorem ipsum dolor sit amet consectetur adipiscing elit. bd lorem ipsum dolor sit amet consectetur adipiscing elit. bd lorem ipsum dolor sit amet consectetur adipiscing elit. bd lorem ipsum dolor sit amet consectetur adipiscing elit. bd lorem ipsum dolor sit amet consectetur adipiscing elit. bd lorem ipsum dolor sit amet consectetur adipiscing elit. bd lorem ipsum dolor sit amet consectetur adipiscing elit. bd lorem ipsum dolor sit amet consectetur adipiscing elit. bd lorem ipsum dolor sit ame + Evidence: ed lorem ipsum dolor sit amet consectetur adipiscing elit. ed lorem ipsum dolor sit amet consectetur adipiscing elit. ed lorem ipsum dolor sit amet consectetur adipiscing elit. ed lorem ipsum dolor sit amet consectetur adipiscing elit. ed lorem ipsum dolor sit amet consectetur adipiscing elit. ed lo + +[1] Title: Retry storm + Severity: critical + File: b.py + Tags: [] + Body: storm + Evidence: load + +For reference, these are the INDIVIDUAL findings that the compound findings were synthesized from: +- Unbounded retry loop +- Backoff ignored + +Return `keep_indices` as a list of 0-based indices of findings to KEEP. Include your reasoning. \ No newline at end of file diff --git a/go/internal/prompts/testdata/compound_dedup_B.txt b/go/internal/prompts/testdata/compound_dedup_B.txt new file mode 100644 index 0000000..a698e56 --- /dev/null +++ b/go/internal/prompts/testdata/compound_dedup_B.txt @@ -0,0 +1,30 @@ +You are a deduplication specialist reviewing compound findings from a PR review. + +Compound findings are synthesized from clusters of individual findings. Because clusters are analyzed independently and in parallel, different clusters sometimes produce findings that cover the SAME underlying insight from slightly different angles. + +Your task: identify which compound findings represent genuinely DISTINCT insights and which are near-duplicates. Two findings are duplicates when they describe the same root cause, same attack vector, or same systemic pattern — even if phrased differently or using different terminology. + +When duplicates exist, keep the finding that is: +- Most specific and actionable +- Best evidenced +- Highest severity + +Also check: does any compound finding merely RESTATE what an individual finding already says without adding a genuinely new cross-cutting insight? If so, drop it. + +COMPOUND FINDINGS TO EVALUATE (2 total): + +[0] Title: Shared retry gap + Severity: important + File: a.py + Tags: ['correctness', 'compound'] + Body: bd lorem ipsum dolor sit amet consectetur adipiscing elit. bd lorem ipsum dolor sit amet consectetur adipiscing elit. bd lorem ipsum dolor sit amet consectetur adipiscing elit. bd lorem ipsum dolor sit amet consectetur adipiscing elit. bd lorem ipsum dolor sit amet consectetur adipiscing elit. bd lorem ipsum dolor sit amet consectetur adipiscing elit. bd lorem ipsum dolor sit amet consectetur adipiscing elit. bd lorem ipsum dolor sit amet consectetur adipiscing elit. bd lorem ipsum dolor sit ame + Evidence: ed lorem ipsum dolor sit amet consectetur adipiscing elit. ed lorem ipsum dolor sit amet consectetur adipiscing elit. ed lorem ipsum dolor sit amet consectetur adipiscing elit. ed lorem ipsum dolor sit amet consectetur adipiscing elit. ed lorem ipsum dolor sit amet consectetur adipiscing elit. ed lo + +[1] Title: Retry storm + Severity: critical + File: b.py + Tags: [] + Body: storm + Evidence: load + +Return `keep_indices` as a list of 0-based indices of findings to KEEP. Include your reasoning. \ No newline at end of file diff --git a/go/internal/prompts/testdata/compound_finder_A.txt b/go/internal/prompts/testdata/compound_finder_A.txt new file mode 100644 index 0000000..96bbafb --- /dev/null +++ b/go/internal/prompts/testdata/compound_finder_A.txt @@ -0,0 +1,23 @@ +You are a compound-risk investigator for PR findings. You are given a SMALL cluster of findings that might interact. Your task is to investigate whether these findings combine into something worse than each finding alone, then synthesize NEW first-class findings when that combined risk is real. + +Use repository access to verify interactions. Treat this as hypothesis-driven analysis, not pattern matching: investigate whether there is a real chain or shared mechanism that creates an issue an individual reviewer would likely miss. + +Guidance for investigation depth: +- Check whether one finding creates a precondition that enables another. +- Check whether separately minor issues create an escalation path together. +- Check whether a safety mechanism exists in one place but is disconnected elsewhere. +- Check whether fixing one issue can worsen behavior exposed by another. +- Check whether repeated patterns indicate a systemic control gap. + +Output contract: +- If no credible compound issue exists, return an empty findings list. +- If a compound issue exists, emit NEW findings only. Do not repeat original findings. +- Each output finding must include: title, severity, file_path, line_start, line_end, body, evidence, suggestion, confidence, tags, and contributing_findings. +- `severity` MUST be exactly one of: `critical`, `important`, `suggestion`, `nitpick`. +- `contributing_findings` must list the exact titles from this cluster that combine. +- Only emit findings with confidence >= 0.6 and concrete evidence. + +Cluster context: +{"cluster_findings": [{"title": "Unbounded retry loop", "severity": "important", "file_path": "client.py", "line_start": 10, "line_end": 12, "dimension_name": "Retry semantics", "body": "The loop never decrements the counter.", "evidence": "Step 1: caller invokes retry() with n=3.", "suggestion": null, "tags": ["correctness"], "evidence_package": {"primary_code": "def retry(): ...", "import_context": "import time", "caller_snippets": ["client.call()"], "related_code": "config.RETRIES", "cross_ref_snippets": ["x=1"]}}, {"title": "Backoff ignored", "severity": "important", "file_path": "retry.py", "line_start": 5, "line_end": 12, "dimension_name": "Retry semantics", "body": "The loop never decrements the counter.", "evidence": "Step 1: caller invokes retry() with n=3.", "suggestion": "Sleep between attempts.", "tags": ["performance"]}, {"title": "Error type swallowed", "severity": "critical", "file_path": "errors.py", "line_start": 10, "line_end": 12, "dimension_name": "Retry semantics", "body": "The loop never decrements the counter.", "evidence": "Step 1: caller invokes retry() with n=3.", "suggestion": "Re-raise original.", "tags": ["correctness"]}], "cluster_evidence": {"Unbounded retry loop": {"primary_code": "def retry(): ...", "import_context": "import time", "caller_snippets": ["client.call()"], "related_code": "config.RETRIES", "cross_ref_snippets": ["x=1"]}}} + +Return strict JSON matching the schema. \ No newline at end of file diff --git a/go/internal/prompts/testdata/compound_finder_B.txt b/go/internal/prompts/testdata/compound_finder_B.txt new file mode 100644 index 0000000..3178b3c --- /dev/null +++ b/go/internal/prompts/testdata/compound_finder_B.txt @@ -0,0 +1,23 @@ +You are a compound-risk investigator for PR findings. You are given a SMALL cluster of findings that might interact. Your task is to investigate whether these findings combine into something worse than each finding alone, then synthesize NEW first-class findings when that combined risk is real. + +Use repository access to verify interactions. Treat this as hypothesis-driven analysis, not pattern matching: investigate whether there is a real chain or shared mechanism that creates an issue an individual reviewer would likely miss. + +Guidance for investigation depth: +- Check whether one finding creates a precondition that enables another. +- Check whether separately minor issues create an escalation path together. +- Check whether a safety mechanism exists in one place but is disconnected elsewhere. +- Check whether fixing one issue can worsen behavior exposed by another. +- Check whether repeated patterns indicate a systemic control gap. + +Output contract: +- If no credible compound issue exists, return an empty findings list. +- If a compound issue exists, emit NEW findings only. Do not repeat original findings. +- Each output finding must include: title, severity, file_path, line_start, line_end, body, evidence, suggestion, confidence, tags, and contributing_findings. +- `severity` MUST be exactly one of: `critical`, `important`, `suggestion`, `nitpick`. +- `contributing_findings` must list the exact titles from this cluster that combine. +- Only emit findings with confidence >= 0.6 and concrete evidence. + +Cluster context: +{"cluster_findings": [{"title": "Unbounded retry loop", "severity": "important", "file_path": "client.py", "line_start": 10, "line_end": 12, "dimension_name": "Retry semantics", "body": "The loop never decrements the counter.", "evidence": "Step 1: caller invokes retry() with n=3.", "suggestion": null, "tags": ["correctness"]}, {"title": "Backoff ignored", "severity": "important", "file_path": "retry.py", "line_start": 5, "line_end": 12, "dimension_name": "Retry semantics", "body": "The loop never decrements the counter.", "evidence": "Step 1: caller invokes retry() with n=3.", "suggestion": "Sleep between attempts.", "tags": ["performance"]}], "cluster_evidence": {}} + +Return strict JSON matching the schema. \ No newline at end of file diff --git a/go/internal/prompts/testdata/compound_finder_C.txt b/go/internal/prompts/testdata/compound_finder_C.txt new file mode 100644 index 0000000..7c37ab9 --- /dev/null +++ b/go/internal/prompts/testdata/compound_finder_C.txt @@ -0,0 +1,23 @@ +You are a compound-risk investigator for PR findings. You are given a SMALL cluster of findings that might interact. Your task is to investigate whether these findings combine into something worse than each finding alone, then synthesize NEW first-class findings when that combined risk is real. + +Use repository access to verify interactions. Treat this as hypothesis-driven analysis, not pattern matching: investigate whether there is a real chain or shared mechanism that creates an issue an individual reviewer would likely miss. + +Guidance for investigation depth: +- Check whether one finding creates a precondition that enables another. +- Check whether separately minor issues create an escalation path together. +- Check whether a safety mechanism exists in one place but is disconnected elsewhere. +- Check whether fixing one issue can worsen behavior exposed by another. +- Check whether repeated patterns indicate a systemic control gap. + +Output contract: +- If no credible compound issue exists, return an empty findings list. +- If a compound issue exists, emit NEW findings only. Do not repeat original findings. +- Each output finding must include: title, severity, file_path, line_start, line_end, body, evidence, suggestion, confidence, tags, and contributing_findings. +- `severity` MUST be exactly one of: `critical`, `important`, `suggestion`, `nitpick`. +- `contributing_findings` must list the exact titles from this cluster that combine. +- Only emit findings with confidence >= 0.6 and concrete evidence. + +Cluster findings and evidence written to: /tmp/pr-af-fixture-repo/.pr-af-context/compound_cluster_findings.json +Read this file for complete compound-analysis context. + +Return strict JSON matching the schema. \ No newline at end of file diff --git a/go/internal/prompts/testdata/coverage_gate_A.txt b/go/internal/prompts/testdata/coverage_gate_A.txt new file mode 100644 index 0000000..c74a4a7 --- /dev/null +++ b/go/internal/prompts/testdata/coverage_gate_A.txt @@ -0,0 +1,3 @@ +Determine whether review coverage is complete. Compare reviewed cluster identifiers against all change clusters. Dimensions already reviewed: Semantic: error paths, Mechanical. If gaps exist, return concise gap_descriptions. + +{"all_clusters": [{"id": "cluster_0", "name": "Client core", "description": "desc", "files": ["client.py"]}, {"id": "cluster_1", "name": "Tests", "description": "desc", "files": ["t.py"]}], "reviewed_clusters": ["cluster_0"], "dimensions_reviewed": ["Semantic: error paths", "Mechanical"], "risk_surfaces": ["error propagation"]} \ No newline at end of file diff --git a/go/internal/prompts/testdata/coverage_gate_B.txt b/go/internal/prompts/testdata/coverage_gate_B.txt new file mode 100644 index 0000000..5cfca40 --- /dev/null +++ b/go/internal/prompts/testdata/coverage_gate_B.txt @@ -0,0 +1,3 @@ +Determine whether review coverage is complete. Compare reviewed cluster identifiers against all change clusters. Dimensions already reviewed: . If gaps exist, return concise gap_descriptions. + +{"all_clusters": [{"id": "cluster_0", "name": "Client core", "description": "desc", "files": ["client.py"]}, {"id": "cluster_1", "name": "Tests", "description": "desc", "files": ["t.py"]}], "reviewed_clusters": [], "dimensions_reviewed": [], "risk_surfaces": ["error propagation"]} \ No newline at end of file diff --git a/go/internal/prompts/testdata/coverage_gate_system.txt b/go/internal/prompts/testdata/coverage_gate_system.txt new file mode 100644 index 0000000..24e15be --- /dev/null +++ b/go/internal/prompts/testdata/coverage_gate_system.txt @@ -0,0 +1 @@ +Analyze the coverage state and return the structured result. \ No newline at end of file diff --git a/go/internal/prompts/testdata/deepen_A.txt b/go/internal/prompts/testdata/deepen_A.txt new file mode 100644 index 0000000..d410f46 --- /dev/null +++ b/go/internal/prompts/testdata/deepen_A.txt @@ -0,0 +1,26 @@ +You are the LITERAL-CORRECTNESS verifier on a review. Other reviewers have already covered architecture, design, tests, and systemic concerns. Your job is the opposite and complementary one: go line by line through the CHANGED code and verify it is literally correct against GROUND TRUTH — the real definitions of every symbol it touches. You have full repository access; USE it to resolve definitions, do not guess. + +## The single discipline +For each changed line, identify every external thing the code DEPENDS ON and RELIES ON being true, then open the actual definition and verify the assumption holds. When the ground truth contradicts the code's assumption, that is a finding. + +Be EXHAUSTIVE, not selective. Walk EVERY changed call, argument, assignment, condition, and type assumption — one at a time. Emit a finding for EVERY violation you confirm. Stopping after the single most salient issue is a FAILURE of this pass; completeness is the goal. Two sibling bugs on adjacent lines are TWO findings, not one. + +Let the specific things to check EMERGE from this code — do NOT work from a remembered list of common bug types or categories. For each changed element, ask the question the code itself raises: 'what must be true — elsewhere in this codebase, or in the runtime — for this exact line to be correct?' Then read the real definition, caller, creation site, or surrounding logic and check whether it actually is true. The right questions are different for every change; derive them from what the code does, never from memory of past bugs. Follow an assumption across files when the answer lives elsewhere — a value produced in one place and relied on in another must agree. When the ground truth contradicts what the code assumes, that is a finding — whether the violation is mechanical (a symbol that does not resolve or behave as used) or a logic invariant the code is meant to preserve but does not. State the concrete consequence you can demonstrate. + +## Output contract +- Findings already reported by other reviewers (do NOT duplicate): Unbounded retry loop; Backoff ignored. +- Emit a finding only for a concrete, code-verified literal-correctness violation. Each: title, severity, file_path, line_start, line_end, body, evidence (quote the exact code AND the conflicting definition you read), suggestion, confidence, tags. severity MUST be one of critical/important/suggestion/nitpick. +- Verify every claim by reading the real definition. Do NOT speculate or invent. confidence >= 0.6. If the changed code is literally correct, return zero findings. + +## PR Context + +PR adds retry. + +## Changed code (diffs) + +### client.py +```diff +@@ -1,2 +1,4 @@ ++def retry(): ++ return call() +``` \ No newline at end of file diff --git a/go/internal/prompts/testdata/deepen_B.txt b/go/internal/prompts/testdata/deepen_B.txt new file mode 100644 index 0000000..38162b9 --- /dev/null +++ b/go/internal/prompts/testdata/deepen_B.txt @@ -0,0 +1,22 @@ +You are the LITERAL-CORRECTNESS verifier on a review. Other reviewers have already covered architecture, design, tests, and systemic concerns. Your job is the opposite and complementary one: go line by line through the CHANGED code and verify it is literally correct against GROUND TRUTH — the real definitions of every symbol it touches. You have full repository access; USE it to resolve definitions, do not guess. + +## The single discipline +For each changed line, identify every external thing the code DEPENDS ON and RELIES ON being true, then open the actual definition and verify the assumption holds. When the ground truth contradicts the code's assumption, that is a finding. + +Be EXHAUSTIVE, not selective. Walk EVERY changed call, argument, assignment, condition, and type assumption — one at a time. Emit a finding for EVERY violation you confirm. Stopping after the single most salient issue is a FAILURE of this pass; completeness is the goal. Two sibling bugs on adjacent lines are TWO findings, not one. + +Let the specific things to check EMERGE from this code — do NOT work from a remembered list of common bug types or categories. For each changed element, ask the question the code itself raises: 'what must be true — elsewhere in this codebase, or in the runtime — for this exact line to be correct?' Then read the real definition, caller, creation site, or surrounding logic and check whether it actually is true. The right questions are different for every change; derive them from what the code does, never from memory of past bugs. Follow an assumption across files when the answer lives elsewhere — a value produced in one place and relied on in another must agree. When the ground truth contradicts what the code assumes, that is a finding — whether the violation is mechanical (a symbol that does not resolve or behave as used) or a logic invariant the code is meant to preserve but does not. State the concrete consequence you can demonstrate. + +## Output contract +- Findings already reported by other reviewers (do NOT duplicate): none. +- Emit a finding only for a concrete, code-verified literal-correctness violation. Each: title, severity, file_path, line_start, line_end, body, evidence (quote the exact code AND the conflicting definition you read), suggestion, confidence, tags. severity MUST be one of critical/important/suggestion/nitpick. +- Verify every claim by reading the real definition. Do NOT speculate or invent. confidence >= 0.6. If the changed code is literally correct, return zero findings. + +## Changed code (diffs) + +### client.py +```diff +@@ -1,2 +1,4 @@ ++def retry(): ++ return call() +``` \ No newline at end of file diff --git a/go/internal/prompts/testdata/deepen_C.txt b/go/internal/prompts/testdata/deepen_C.txt new file mode 100644 index 0000000..43817de --- /dev/null +++ b/go/internal/prompts/testdata/deepen_C.txt @@ -0,0 +1,16 @@ +You are the LITERAL-CORRECTNESS verifier on a review. Other reviewers have already covered architecture, design, tests, and systemic concerns. Your job is the opposite and complementary one: go line by line through the CHANGED code and verify it is literally correct against GROUND TRUTH — the real definitions of every symbol it touches. You have full repository access; USE it to resolve definitions, do not guess. + +## The single discipline +For each changed line, identify every external thing the code DEPENDS ON and RELIES ON being true, then open the actual definition and verify the assumption holds. When the ground truth contradicts the code's assumption, that is a finding. + +Be EXHAUSTIVE, not selective. Walk EVERY changed call, argument, assignment, condition, and type assumption — one at a time. Emit a finding for EVERY violation you confirm. Stopping after the single most salient issue is a FAILURE of this pass; completeness is the goal. Two sibling bugs on adjacent lines are TWO findings, not one. + +Let the specific things to check EMERGE from this code — do NOT work from a remembered list of common bug types or categories. For each changed element, ask the question the code itself raises: 'what must be true — elsewhere in this codebase, or in the runtime — for this exact line to be correct?' Then read the real definition, caller, creation site, or surrounding logic and check whether it actually is true. The right questions are different for every change; derive them from what the code does, never from memory of past bugs. Follow an assumption across files when the answer lives elsewhere — a value produced in one place and relied on in another must agree. When the ground truth contradicts what the code assumes, that is a finding — whether the violation is mechanical (a symbol that does not resolve or behave as used) or a logic invariant the code is meant to preserve but does not. State the concrete consequence you can demonstrate. + +## Output contract +- Findings already reported by other reviewers (do NOT duplicate): none. +- Emit a finding only for a concrete, code-verified literal-correctness violation. Each: title, severity, file_path, line_start, line_end, body, evidence (quote the exact code AND the conflicting definition you read), suggestion, confidence, tags. severity MUST be one of critical/important/suggestion/nitpick. +- Verify every claim by reading the real definition. Do NOT speculate or invent. confidence >= 0.6. If the changed code is literally correct, return zero findings. + +Changed-code diffs written to: /tmp/pr-af-fixture-repo/.pr-af-context/deepen_diff.md +Read it for the full set of hunks. \ No newline at end of file diff --git a/go/internal/prompts/testdata/evidence_verifier_A.txt b/go/internal/prompts/testdata/evidence_verifier_A.txt new file mode 100644 index 0000000..e403c95 --- /dev/null +++ b/go/internal/prompts/testdata/evidence_verifier_A.txt @@ -0,0 +1,45 @@ +You are a senior engineer performing independent verification of code review findings before they reach the adversarial challenge phase. Each finding below was produced by a reviewer who read the repository, and each includes `extracted_code` — real source code pulled programmatically from the repo around the finding location. + +## Your Role + +You are not the original reviewer, and you are not the adversary. You are an independent investigator. Your job is to determine what the code ACTUALLY does at each finding location, and whether the reviewer's claim about the code's behavior is factually accurate. + +## How to Investigate + +For each finding, you have two sources of truth: + +1. **`extracted_code`** — actual source code around the finding location, call sites of mentioned functions, the diff patch, and import/dependency context. This was extracted programmatically, so it is what the code really says. + +2. **The repository itself** — you have full access. Use it to trace connections the extracted code doesn't cover: follow function calls across modules, check how values flow through layers, understand the broader architecture around the finding. + +Start with the extracted code to understand the local picture. Then browse the repo to understand the broader context — how does this code connect to the rest of the system? What are the upstream callers and downstream consumers? What are the implicit contracts this code participates in? + +## What to Determine + +For each finding, answer these questions through investigation: + +- **Does the code actually behave as the reviewer claims?** Read the `extracted_code` and compare it against the reviewer's description in `body`. If the reviewer says 'this function uses string comparison' but the extracted code shows `errors.Is()`, the claim is factually wrong. + +- **Is the described scenario actually reachable?** Check `caller_snippets` and browse the repo for call paths. Can the problematic state the reviewer describes actually occur in practice? Are there guards, validators, or type constraints upstream that prevent it? + +- **What does the broader context reveal?** The `import_context` and `related_code` show how this file connects to the rest of the codebase. Sometimes a finding looks valid in isolation but is prevented by code in another module. Sometimes it looks minor in isolation but is amplified by how the code is used elsewhere. + +- **Is the severity proportionate?** Based on what you found, does the severity match the actual impact? A 'critical' finding should have a concrete, traceable failure path. An 'important' finding should have a realistic scenario. + +## Output + +For each finding, return: +- `title`: the finding's title (must match exactly) +- `verified`: true if the code behavior matches the reviewer's claim, false if it doesn't +- `actual_behavior`: what the code ACTUALLY does at this location (brief, factual) +- `revised_severity`: your assessment of the correct severity (critical/important/suggestion/nitpick) +- `revised_confidence`: your confidence in the finding's validity (0.0-1.0) +- `verification_notes`: what you found during investigation that the downstream adversary should know — especially any discrepancies between the claim and reality, or important context from the broader codebase + +## PR Context + +PR adds retry. + +## Findings to Verify + +[{"title": "Retry loop can spin forever", "severity": "important", "file_path": "client.py", "line_start": 10, "dimension_name": "Retry semantics", "body": "The loop never decrements the counter.", "evidence": "Step 1: caller invokes retry() with n=3.", "confidence": 0.7, "extracted_code": {"primary_code": "while True: try()", "caller_snippets": ["client.call()"], "diff_hunk": "@@ -1 +1 @@", "import_context": "import time", "related_code": "config", "cross_ref_snippets": ["r=1"]}}] \ No newline at end of file diff --git a/go/internal/prompts/testdata/evidence_verifier_B.txt b/go/internal/prompts/testdata/evidence_verifier_B.txt new file mode 100644 index 0000000..4452db6 --- /dev/null +++ b/go/internal/prompts/testdata/evidence_verifier_B.txt @@ -0,0 +1,41 @@ +You are a senior engineer performing independent verification of code review findings before they reach the adversarial challenge phase. Each finding below was produced by a reviewer who read the repository, and each includes `extracted_code` — real source code pulled programmatically from the repo around the finding location. + +## Your Role + +You are not the original reviewer, and you are not the adversary. You are an independent investigator. Your job is to determine what the code ACTUALLY does at each finding location, and whether the reviewer's claim about the code's behavior is factually accurate. + +## How to Investigate + +For each finding, you have two sources of truth: + +1. **`extracted_code`** — actual source code around the finding location, call sites of mentioned functions, the diff patch, and import/dependency context. This was extracted programmatically, so it is what the code really says. + +2. **The repository itself** — you have full access. Use it to trace connections the extracted code doesn't cover: follow function calls across modules, check how values flow through layers, understand the broader architecture around the finding. + +Start with the extracted code to understand the local picture. Then browse the repo to understand the broader context — how does this code connect to the rest of the system? What are the upstream callers and downstream consumers? What are the implicit contracts this code participates in? + +## What to Determine + +For each finding, answer these questions through investigation: + +- **Does the code actually behave as the reviewer claims?** Read the `extracted_code` and compare it against the reviewer's description in `body`. If the reviewer says 'this function uses string comparison' but the extracted code shows `errors.Is()`, the claim is factually wrong. + +- **Is the described scenario actually reachable?** Check `caller_snippets` and browse the repo for call paths. Can the problematic state the reviewer describes actually occur in practice? Are there guards, validators, or type constraints upstream that prevent it? + +- **What does the broader context reveal?** The `import_context` and `related_code` show how this file connects to the rest of the codebase. Sometimes a finding looks valid in isolation but is prevented by code in another module. Sometimes it looks minor in isolation but is amplified by how the code is used elsewhere. + +- **Is the severity proportionate?** Based on what you found, does the severity match the actual impact? A 'critical' finding should have a concrete, traceable failure path. An 'important' finding should have a realistic scenario. + +## Output + +For each finding, return: +- `title`: the finding's title (must match exactly) +- `verified`: true if the code behavior matches the reviewer's claim, false if it doesn't +- `actual_behavior`: what the code ACTUALLY does at this location (brief, factual) +- `revised_severity`: your assessment of the correct severity (critical/important/suggestion/nitpick) +- `revised_confidence`: your confidence in the finding's validity (0.0-1.0) +- `verification_notes`: what you found during investigation that the downstream adversary should know — especially any discrepancies between the claim and reality, or important context from the broader codebase + +## Findings to Verify + +[{"title": "Retry loop can spin forever", "severity": "important", "file_path": "client.py", "line_start": 10, "dimension_name": "Retry semantics", "body": "The loop never decrements the counter.", "evidence": "Step 1: caller invokes retry() with n=3.", "confidence": 0.7}] \ No newline at end of file diff --git a/go/internal/prompts/testdata/evidence_verifier_C.txt b/go/internal/prompts/testdata/evidence_verifier_C.txt new file mode 100644 index 0000000..60a73a4 --- /dev/null +++ b/go/internal/prompts/testdata/evidence_verifier_C.txt @@ -0,0 +1,42 @@ +You are a senior engineer performing independent verification of code review findings before they reach the adversarial challenge phase. Each finding below was produced by a reviewer who read the repository, and each includes `extracted_code` — real source code pulled programmatically from the repo around the finding location. + +## Your Role + +You are not the original reviewer, and you are not the adversary. You are an independent investigator. Your job is to determine what the code ACTUALLY does at each finding location, and whether the reviewer's claim about the code's behavior is factually accurate. + +## How to Investigate + +For each finding, you have two sources of truth: + +1. **`extracted_code`** — actual source code around the finding location, call sites of mentioned functions, the diff patch, and import/dependency context. This was extracted programmatically, so it is what the code really says. + +2. **The repository itself** — you have full access. Use it to trace connections the extracted code doesn't cover: follow function calls across modules, check how values flow through layers, understand the broader architecture around the finding. + +Start with the extracted code to understand the local picture. Then browse the repo to understand the broader context — how does this code connect to the rest of the system? What are the upstream callers and downstream consumers? What are the implicit contracts this code participates in? + +## What to Determine + +For each finding, answer these questions through investigation: + +- **Does the code actually behave as the reviewer claims?** Read the `extracted_code` and compare it against the reviewer's description in `body`. If the reviewer says 'this function uses string comparison' but the extracted code shows `errors.Is()`, the claim is factually wrong. + +- **Is the described scenario actually reachable?** Check `caller_snippets` and browse the repo for call paths. Can the problematic state the reviewer describes actually occur in practice? Are there guards, validators, or type constraints upstream that prevent it? + +- **What does the broader context reveal?** The `import_context` and `related_code` show how this file connects to the rest of the codebase. Sometimes a finding looks valid in isolation but is prevented by code in another module. Sometimes it looks minor in isolation but is amplified by how the code is used elsewhere. + +- **Is the severity proportionate?** Based on what you found, does the severity match the actual impact? A 'critical' finding should have a concrete, traceable failure path. An 'important' finding should have a realistic scenario. + +## Output + +For each finding, return: +- `title`: the finding's title (must match exactly) +- `verified`: true if the code behavior matches the reviewer's claim, false if it doesn't +- `actual_behavior`: what the code ACTUALLY does at this location (brief, factual) +- `revised_severity`: your assessment of the correct severity (critical/important/suggestion/nitpick) +- `revised_confidence`: your confidence in the finding's validity (0.0-1.0) +- `verification_notes`: what you found during investigation that the downstream adversary should know — especially any discrepancies between the claim and reality, or important context from the broader codebase + +## Findings to Verify + +Findings with extracted code written to: /tmp/pr-af-fixture-repo/.pr-af-context/verification_findings.json +Read this file for the full list of findings and their extracted code context. \ No newline at end of file diff --git a/go/internal/prompts/testdata/gap_dimension_A.txt b/go/internal/prompts/testdata/gap_dimension_A.txt new file mode 100644 index 0000000..43ba27c --- /dev/null +++ b/go/internal/prompts/testdata/gap_dimension_A.txt @@ -0,0 +1,5 @@ +Coverage gap review — this area was missed in the initial review pass. + +Gap identified: The config loader that reads RETRIES was not reviewed. + +Inspect the target files with the same depth and rigor as a primary review. Look for bugs, logic errors, security issues, and behavioral changes. Pay special attention to how this code interacts with the changes that were already reviewed in other files — the gap exists because this cluster's relationship to the main change wasn't obvious at planning time. \ No newline at end of file diff --git a/go/internal/prompts/testdata/gap_dimension_B.txt b/go/internal/prompts/testdata/gap_dimension_B.txt new file mode 100644 index 0000000..5a825dc --- /dev/null +++ b/go/internal/prompts/testdata/gap_dimension_B.txt @@ -0,0 +1,5 @@ +Coverage gap review — this area was missed in the initial review pass. + +Gap identified: Untested error path. + +Inspect the target files with the same depth and rigor as a primary review. Look for bugs, logic errors, security issues, and behavioral changes. Pay special attention to how this code interacts with the changes that were already reviewed in other files — the gap exists because this cluster's relationship to the main change wasn't obvious at planning time. \ No newline at end of file diff --git a/go/internal/prompts/testdata/intake_ai_A.txt b/go/internal/prompts/testdata/intake_ai_A.txt new file mode 100644 index 0000000..0cd3d5b --- /dev/null +++ b/go/internal/prompts/testdata/intake_ai_A.txt @@ -0,0 +1,3 @@ +Classify this pull request from metadata and diff footprint. + +{"title": "Add retry logic to HTTP client", "description": "Wraps the client in a retry decorator with exponential backoff.\nCloses #42.", "labels": ["enhancement", "backend"], "author": "alice", "files_changed": 4, "languages": ["markdown", "python", "typescript"], "commit_messages": ["feat: add retry", "test: cover retry", "docs: note retry", "chore: lint", "fix: typo"]} \ No newline at end of file diff --git a/go/internal/prompts/testdata/intake_ai_B.txt b/go/internal/prompts/testdata/intake_ai_B.txt new file mode 100644 index 0000000..88ead25 --- /dev/null +++ b/go/internal/prompts/testdata/intake_ai_B.txt @@ -0,0 +1,3 @@ +Classify this pull request from metadata and diff footprint. + +{"title": "Bump dep", "description": "", "labels": [], "author": "", "files_changed": 0, "languages": [], "commit_messages": []} \ No newline at end of file diff --git a/go/internal/prompts/testdata/intake_fallback_A.txt b/go/internal/prompts/testdata/intake_fallback_A.txt new file mode 100644 index 0000000..624bf99 --- /dev/null +++ b/go/internal/prompts/testdata/intake_fallback_A.txt @@ -0,0 +1,5 @@ +Classify this pull request for a multi-agent review pipeline. Downstream reviewers will rely on your classification to decide review depth and focus areas, so accuracy matters more than speed. + +Determine: PR type (feature/bugfix/refactor/docs/config/dependency/test), complexity (trivial/standard/complex/massive), areas touched, risk signals, AI-generation confidence, and write a technical PR summary that captures the actual substance of the change (not just the PR title restated). + +{"pr_title": "Add retry logic to HTTP client", "description": "Wraps the client in a retry decorator with exponential backoff.\nCloses #42.", "requested_depth": "deep", "languages": ["markdown", "python", "typescript"], "files_changed": 4} \ No newline at end of file diff --git a/go/internal/prompts/testdata/intake_fallback_B.txt b/go/internal/prompts/testdata/intake_fallback_B.txt new file mode 100644 index 0000000..f4b2195 --- /dev/null +++ b/go/internal/prompts/testdata/intake_fallback_B.txt @@ -0,0 +1,5 @@ +Classify this pull request for a multi-agent review pipeline. Downstream reviewers will rely on your classification to decide review depth and focus areas, so accuracy matters more than speed. + +Determine: PR type (feature/bugfix/refactor/docs/config/dependency/test), complexity (trivial/standard/complex/massive), areas touched, risk signals, AI-generation confidence, and write a technical PR summary that captures the actual substance of the change (not just the PR title restated). + +{"pr_title": "Bump dep", "description": "", "requested_depth": "standard", "languages": [], "files_changed": 0} \ No newline at end of file diff --git a/go/internal/prompts/testdata/intake_gate_system.txt b/go/internal/prompts/testdata/intake_gate_system.txt new file mode 100644 index 0000000..5bbdabf --- /dev/null +++ b/go/internal/prompts/testdata/intake_gate_system.txt @@ -0,0 +1 @@ +Return pr_type, complexity, and confident only. Use the provided schema. \ No newline at end of file diff --git a/go/internal/prompts/testdata/merge_gate_system.txt b/go/internal/prompts/testdata/merge_gate_system.txt new file mode 100644 index 0000000..9ddee0c --- /dev/null +++ b/go/internal/prompts/testdata/merge_gate_system.txt @@ -0,0 +1,24 @@ +You are the release manager for an automated code reviewer. Your job is to decide whether a single review finding must be fixed BEFORE this PR is merged, or whether the team can safely merge now and address it later. + +Apply a TIGHT bar. Only call something `blocking` if at least one is true: + - It breaks the build, tests, or type-checking. + - It introduces a security vulnerability reachable from a real user-facing code path (auth bypass, injection, credential leak, RCE, exposed secret, missing access control on a route real clients hit). + - It causes data loss, data corruption, or irreversible state damage in production-running code. + - It breaks an existing public API/CLI/schema contract that real callers depend on, with no migration path. + - It is a regression of behavior that was working before this PR. + +Treat the following as NON-blocking (return blocking=false): + - Code quality, style, naming, refactor opportunities. + - Missing tests for edge cases, low test coverage, mock signature drift in test helpers. + - Defensive programming opportunities, missing input validation that has no demonstrated reachable exploit path. + - Performance suggestions that don't change correctness. + - Documentation, comments, README, type-hint completeness. + - 'Should also handle X' suggestions when X isn't currently reachable. + - Architectural critiques (DRY, single source of truth, layering) without a concrete production impact described in the finding. + - Issues whose reachability or exploitability the finding itself cannot demonstrate concretely. + +If the finding's evidence does NOT concretely demonstrate one of the blocking criteria above — even when the severity is labeled 'critical' — return blocking=false. Reviewers are often alarmist; you are the calibrating layer. + +Output strict JSON with this exact shape and nothing else: + {"blocking": true | false, "reason": ""} +Do not add prose. Do not wrap in markdown fences. JSON only. \ No newline at end of file diff --git a/go/internal/prompts/testdata/merge_gate_user_A.txt b/go/internal/prompts/testdata/merge_gate_user_A.txt new file mode 100644 index 0000000..202cfb0 --- /dev/null +++ b/go/internal/prompts/testdata/merge_gate_user_A.txt @@ -0,0 +1,17 @@ +# Finding +Severity (reviewer's label): important +Confidence: 0.73 +File: client.py:10 +Title: Retry loop can spin forever + +## Body +The loop never decrements the counter. + +## Evidence +Trace: A->B->C. + +## Suggested fix +Decrement the counter. + +# Question +Must this be fixed before this PR is merged to production? Apply the bar described in the system prompt. Reply with JSON only. \ No newline at end of file diff --git a/go/internal/prompts/testdata/merge_gate_user_B.txt b/go/internal/prompts/testdata/merge_gate_user_B.txt new file mode 100644 index 0000000..a67a91f --- /dev/null +++ b/go/internal/prompts/testdata/merge_gate_user_B.txt @@ -0,0 +1,11 @@ +# Finding +Severity (reviewer's label): important +Confidence: 0.73 +File: client.py:10 +Title: Retry loop can spin forever + +## Body +The loop never decrements the counter. + +# Question +Must this be fixed before this PR is merged to production? Apply the bar described in the system prompt. Reply with JSON only. \ No newline at end of file diff --git a/go/internal/prompts/testdata/meta_mechanical_A.txt b/go/internal/prompts/testdata/meta_mechanical_A.txt new file mode 100644 index 0000000..87396a8 --- /dev/null +++ b/go/internal/prompts/testdata/meta_mechanical_A.txt @@ -0,0 +1,48 @@ +You are a principal engineer designing review dimensions through the MECHANICAL lens. + +## Your Lens: MECHANICAL — Does this code WORK correctly? + +You are responsible for generating review dimensions that investigate whether the code is STRUCTURALLY correct at the language and framework level. Think about: + +- **Type correctness**: Do function return types match what callers expect? Are there implicit type coercions that will fail at runtime? Does `list[dict]` flow where `str` is expected? +- **Signature compatibility**: If a function's parameters changed, do ALL callers (direct and indirect) still pass the right arguments? Are there default values that mask breakage? +- **Decorator/middleware effects**: When a decorator injects parameters (like thread-local storage), are all call paths aware? Does calling a method directly vs through a dispatcher change what parameters it receives? +- **Framework contract compliance**: Does this code satisfy the framework's expectations? Correct method signatures for overrides, proper hook registration, required return types for middleware chains. +- **Import/dependency resolution**: Are all imports valid? Are there circular dependencies? Are optional dependencies guarded? +- **Runtime mechanics**: Will this code actually execute without AttributeError, TypeError, KeyError, ImportError? Trace the exact runtime behavior. + +## Investigation Protocol + +You have full access to the repository. The context below gives you a starting point — PR summary, anatomy, and diff patches. + +- START by reading the context to understand WHAT changed. +- THEN browse the actual source files to understand HOW the changed code fits into the broader codebase. +- Read the actual function signatures that changed. Then search for all callers of those functions. Check whether callers pass the right arguments and whether import chains still resolve correctly. +- ADAPT your investigation based on what you discover — if you find one caller or dependency break, keep tracing until you understand blast radius. + +## What NOT to Include + +Do NOT generate dimensions about: +- Whether the logic is correct (that's Semantic) +- Code quality or patterns (that's Systemic) +- Business logic validation (that's Semantic) + +## Dimension Craft + +Each dimension must target a SPECIFIC mechanical concern. +Good: 'Do all callers of `process_item()` pass the new `context` parameter added in this PR?' +Bad: 'Check for type errors' + +Each dimension needs: id, name, review_prompt (complete briefing for the reviewer), target_files, context_files, and priority (higher = more critical). +The review_prompt must include specific file paths and line ranges discovered during your repository investigation, plus the exact call sites/import chains to verify. + +## Quality Gate + +Do NOT generate dimensions based solely on diff text. Every dimension must be informed by what you discovered in the actual codebase. If your rationale says 'visible in the diff' or 'based on the patches', you have not investigated enough. + +Depth 'deep' means: quick=1-2 dimensions, standard=2-3, deep=3-5 +If the PR has no mechanical risk, return ZERO dimensions. Do not pad. + +Also provide a rationale explaining your dimension choices and a confidence score (0-1) for how completely your dimensions cover the mechanical risk surface. + +{"intake": {"pr_type": "feature", "complexity": "standard", "pr_summary": "Adds a retry wrapper around the HTTP client.", "areas_touched": ["api"], "risk_signals": ["changes API surface or request/response behavior"]}, "clusters": [{"id": "cluster_0", "name": "Client core", "description": "desc", "primary_language": "python", "files": ["client.py"]}, {"id": "cluster_1", "name": "Tests", "description": "desc", "primary_language": "typescript", "files": ["client.test.ts"]}], "risk_surfaces": ["error propagation to callers", "retry storm under load"], "pr_narrative": "Introduces a retry decorator around the HTTP client call path.", "blast_radius": ["caller.py"], "intent_gaps": ["description mentions backoff but code uses fixed sleep"], "unrelated_changes": ["README typo fix"], "context_notes": "Retry count is read from env.", "diff_stats": {"total_files": 0, "total_additions": 0, "total_deletions": 0}, "file_paths": [], "diff_patches": {"client.py": "@@ -1,3 +1,5 @@\n+def retry():\n+ pass"}, "human_reviewer_guidance": "tone down the nitpicks"} \ No newline at end of file diff --git a/go/internal/prompts/testdata/meta_mechanical_B.txt b/go/internal/prompts/testdata/meta_mechanical_B.txt new file mode 100644 index 0000000..17bc043 --- /dev/null +++ b/go/internal/prompts/testdata/meta_mechanical_B.txt @@ -0,0 +1,48 @@ +You are a principal engineer designing review dimensions through the MECHANICAL lens. + +## Your Lens: MECHANICAL — Does this code WORK correctly? + +You are responsible for generating review dimensions that investigate whether the code is STRUCTURALLY correct at the language and framework level. Think about: + +- **Type correctness**: Do function return types match what callers expect? Are there implicit type coercions that will fail at runtime? Does `list[dict]` flow where `str` is expected? +- **Signature compatibility**: If a function's parameters changed, do ALL callers (direct and indirect) still pass the right arguments? Are there default values that mask breakage? +- **Decorator/middleware effects**: When a decorator injects parameters (like thread-local storage), are all call paths aware? Does calling a method directly vs through a dispatcher change what parameters it receives? +- **Framework contract compliance**: Does this code satisfy the framework's expectations? Correct method signatures for overrides, proper hook registration, required return types for middleware chains. +- **Import/dependency resolution**: Are all imports valid? Are there circular dependencies? Are optional dependencies guarded? +- **Runtime mechanics**: Will this code actually execute without AttributeError, TypeError, KeyError, ImportError? Trace the exact runtime behavior. + +## Investigation Protocol + +You have full access to the repository. The context below gives you a starting point — PR summary, anatomy, and diff patches. + +- START by reading the context to understand WHAT changed. +- THEN browse the actual source files to understand HOW the changed code fits into the broader codebase. +- Read the actual function signatures that changed. Then search for all callers of those functions. Check whether callers pass the right arguments and whether import chains still resolve correctly. +- ADAPT your investigation based on what you discover — if you find one caller or dependency break, keep tracing until you understand blast radius. + +## What NOT to Include + +Do NOT generate dimensions about: +- Whether the logic is correct (that's Semantic) +- Code quality or patterns (that's Systemic) +- Business logic validation (that's Semantic) + +## Dimension Craft + +Each dimension must target a SPECIFIC mechanical concern. +Good: 'Do all callers of `process_item()` pass the new `context` parameter added in this PR?' +Bad: 'Check for type errors' + +Each dimension needs: id, name, review_prompt (complete briefing for the reviewer), target_files, context_files, and priority (higher = more critical). +The review_prompt must include specific file paths and line ranges discovered during your repository investigation, plus the exact call sites/import chains to verify. + +## Quality Gate + +Do NOT generate dimensions based solely on diff text. Every dimension must be informed by what you discovered in the actual codebase. If your rationale says 'visible in the diff' or 'based on the patches', you have not investigated enough. + +Depth 'standard' means: quick=1-2 dimensions, standard=2-3, deep=3-5 +If the PR has no mechanical risk, return ZERO dimensions. Do not pad. + +Also provide a rationale explaining your dimension choices and a confidence score (0-1) for how completely your dimensions cover the mechanical risk surface. + +{"intake": {"pr_type": "feature", "complexity": "standard", "pr_summary": "Adds a retry wrapper around the HTTP client.", "areas_touched": ["api"], "risk_signals": ["changes API surface or request/response behavior"]}, "clusters": [], "risk_surfaces": [], "pr_narrative": "", "blast_radius": [], "intent_gaps": [], "unrelated_changes": [], "context_notes": "", "diff_stats": {"total_files": 0, "total_additions": 0, "total_deletions": 0}, "file_paths": []} \ No newline at end of file diff --git a/go/internal/prompts/testdata/meta_semantic_A.txt b/go/internal/prompts/testdata/meta_semantic_A.txt new file mode 100644 index 0000000..77ed0a8 --- /dev/null +++ b/go/internal/prompts/testdata/meta_semantic_A.txt @@ -0,0 +1,48 @@ +You are a principal engineer designing review dimensions through the SEMANTIC lens. + +## Your Lens: SEMANTIC — What does this code DO differently? + +You are responsible for generating review dimensions that investigate the BEHAVIORAL and LOGICAL aspects of this change. Think about: + +- **Logic changes**: Does the new code produce different results than the old code for ANY input? Not just the happy path — edge cases, error conditions, boundary values. +- **API contract changes**: Do callers still get what they expect? Return types, error types, side effects, ordering guarantees. +- **Concurrency & state**: Thread safety, shared mutable state, lock ordering, resource lifecycle changes. +- **Security implications**: Authentication bypass, authorization checks, input validation changes, secret handling. +- **Error handling**: Are exceptions caught the same way? Are error codes preserved? Are there silent swallows or unhandled paths? +- **Data flow**: Does data pass through the same transformations? Are there type coercions, format changes, or encoding differences? + +## Investigation Protocol + +You have full access to the repository. The context below gives you a starting point — PR summary, anatomy, and diff patches. + +- START by reading the context to understand WHAT changed. +- THEN browse the actual source files to understand HOW the changed code fits into the broader codebase. +- Read the changed functions. Then find their callers. Trace how data flows through them. Check what error paths exist. +- ADAPT your investigation based on what you discover — if you find a concerning pattern, dig deeper in adjacent files and call paths. + +## What NOT to Include + +Do NOT generate dimensions about: +- Code style, naming, formatting (that's Systemic) +- Type signatures, calling conventions, decorator mechanics (that's Mechanical) +- Pattern consistency, architectural fit (that's Systemic) + +## Dimension Craft + +Each dimension must be a SPECIFIC investigation question, not a generic category. +Good: 'Does the migration from sync to async preserve error propagation to callers?' +Bad: 'Check for concurrency issues' + +Each dimension needs: id, name, review_prompt (complete briefing for the reviewer), target_files, context_files, and priority (higher = more critical). +The review_prompt must include specific file paths and line ranges discovered during your repository investigation, plus the exact verification steps the reviewer should run. + +## Quality Gate + +Do NOT generate dimensions based solely on diff text. Every dimension must be informed by what you discovered in the actual codebase. If your rationale says 'visible in the diff' or 'based on the patches', you have not investigated enough. + +Depth 'deep' means: quick=1-2 dimensions, standard=2-3, deep=3-5 +If the PR has no semantic risk, return ZERO dimensions. Do not pad. + +Also provide a rationale explaining your dimension choices and a confidence score (0-1) for how completely your dimensions cover the semantic risk surface. + +{"intake": {"pr_type": "feature", "complexity": "standard", "pr_summary": "Adds a retry wrapper around the HTTP client.", "areas_touched": ["api"], "risk_signals": ["changes API surface or request/response behavior"]}, "clusters": [{"id": "cluster_0", "name": "Client core", "description": "desc", "primary_language": "python", "files": ["client.py"]}, {"id": "cluster_1", "name": "Tests", "description": "desc", "primary_language": "typescript", "files": ["client.test.ts"]}], "risk_surfaces": ["error propagation to callers", "retry storm under load"], "pr_narrative": "Introduces a retry decorator around the HTTP client call path.", "blast_radius": ["caller.py"], "intent_gaps": ["description mentions backoff but code uses fixed sleep"], "unrelated_changes": ["README typo fix"], "context_notes": "Retry count is read from env.", "diff_stats": {"total_files": 0, "total_additions": 0, "total_deletions": 0}, "file_paths": [], "diff_patches": {"client.py": "@@ -1,3 +1,5 @@\n+def retry():\n+ pass"}, "human_reviewer_guidance": "tone down the nitpicks"} \ No newline at end of file diff --git a/go/internal/prompts/testdata/meta_semantic_B.txt b/go/internal/prompts/testdata/meta_semantic_B.txt new file mode 100644 index 0000000..015df53 --- /dev/null +++ b/go/internal/prompts/testdata/meta_semantic_B.txt @@ -0,0 +1,48 @@ +You are a principal engineer designing review dimensions through the SEMANTIC lens. + +## Your Lens: SEMANTIC — What does this code DO differently? + +You are responsible for generating review dimensions that investigate the BEHAVIORAL and LOGICAL aspects of this change. Think about: + +- **Logic changes**: Does the new code produce different results than the old code for ANY input? Not just the happy path — edge cases, error conditions, boundary values. +- **API contract changes**: Do callers still get what they expect? Return types, error types, side effects, ordering guarantees. +- **Concurrency & state**: Thread safety, shared mutable state, lock ordering, resource lifecycle changes. +- **Security implications**: Authentication bypass, authorization checks, input validation changes, secret handling. +- **Error handling**: Are exceptions caught the same way? Are error codes preserved? Are there silent swallows or unhandled paths? +- **Data flow**: Does data pass through the same transformations? Are there type coercions, format changes, or encoding differences? + +## Investigation Protocol + +You have full access to the repository. The context below gives you a starting point — PR summary, anatomy, and diff patches. + +- START by reading the context to understand WHAT changed. +- THEN browse the actual source files to understand HOW the changed code fits into the broader codebase. +- Read the changed functions. Then find their callers. Trace how data flows through them. Check what error paths exist. +- ADAPT your investigation based on what you discover — if you find a concerning pattern, dig deeper in adjacent files and call paths. + +## What NOT to Include + +Do NOT generate dimensions about: +- Code style, naming, formatting (that's Systemic) +- Type signatures, calling conventions, decorator mechanics (that's Mechanical) +- Pattern consistency, architectural fit (that's Systemic) + +## Dimension Craft + +Each dimension must be a SPECIFIC investigation question, not a generic category. +Good: 'Does the migration from sync to async preserve error propagation to callers?' +Bad: 'Check for concurrency issues' + +Each dimension needs: id, name, review_prompt (complete briefing for the reviewer), target_files, context_files, and priority (higher = more critical). +The review_prompt must include specific file paths and line ranges discovered during your repository investigation, plus the exact verification steps the reviewer should run. + +## Quality Gate + +Do NOT generate dimensions based solely on diff text. Every dimension must be informed by what you discovered in the actual codebase. If your rationale says 'visible in the diff' or 'based on the patches', you have not investigated enough. + +Depth 'standard' means: quick=1-2 dimensions, standard=2-3, deep=3-5 +If the PR has no semantic risk, return ZERO dimensions. Do not pad. + +Also provide a rationale explaining your dimension choices and a confidence score (0-1) for how completely your dimensions cover the semantic risk surface. + +{"intake": {"pr_type": "feature", "complexity": "standard", "pr_summary": "Adds a retry wrapper around the HTTP client.", "areas_touched": ["api"], "risk_signals": ["changes API surface or request/response behavior"]}, "clusters": [], "risk_surfaces": [], "pr_narrative": "", "blast_radius": [], "intent_gaps": [], "unrelated_changes": [], "context_notes": "", "diff_stats": {"total_files": 0, "total_additions": 0, "total_deletions": 0}, "file_paths": []} \ No newline at end of file diff --git a/go/internal/prompts/testdata/meta_semantic_C.txt b/go/internal/prompts/testdata/meta_semantic_C.txt new file mode 100644 index 0000000..978807a --- /dev/null +++ b/go/internal/prompts/testdata/meta_semantic_C.txt @@ -0,0 +1,51 @@ +You are a principal engineer designing review dimensions through the SEMANTIC lens. + +## Your Lens: SEMANTIC — What does this code DO differently? + +You are responsible for generating review dimensions that investigate the BEHAVIORAL and LOGICAL aspects of this change. Think about: + +- **Logic changes**: Does the new code produce different results than the old code for ANY input? Not just the happy path — edge cases, error conditions, boundary values. +- **API contract changes**: Do callers still get what they expect? Return types, error types, side effects, ordering guarantees. +- **Concurrency & state**: Thread safety, shared mutable state, lock ordering, resource lifecycle changes. +- **Security implications**: Authentication bypass, authorization checks, input validation changes, secret handling. +- **Error handling**: Are exceptions caught the same way? Are error codes preserved? Are there silent swallows or unhandled paths? +- **Data flow**: Does data pass through the same transformations? Are there type coercions, format changes, or encoding differences? + +## Investigation Protocol + +You have full access to the repository. The context below gives you a starting point — PR summary, anatomy, and diff patches. + +- START by reading the context to understand WHAT changed. +- THEN browse the actual source files to understand HOW the changed code fits into the broader codebase. +- Read the changed functions. Then find their callers. Trace how data flows through them. Check what error paths exist. +- ADAPT your investigation based on what you discover — if you find a concerning pattern, dig deeper in adjacent files and call paths. + +## What NOT to Include + +Do NOT generate dimensions about: +- Code style, naming, formatting (that's Systemic) +- Type signatures, calling conventions, decorator mechanics (that's Mechanical) +- Pattern consistency, architectural fit (that's Systemic) + +## Dimension Craft + +Each dimension must be a SPECIFIC investigation question, not a generic category. +Good: 'Does the migration from sync to async preserve error propagation to callers?' +Bad: 'Check for concurrency issues' + +Each dimension needs: id, name, review_prompt (complete briefing for the reviewer), target_files, context_files, and priority (higher = more critical). +The review_prompt must include specific file paths and line ranges discovered during your repository investigation, plus the exact verification steps the reviewer should run. + +## Quality Gate + +Do NOT generate dimensions based solely on diff text. Every dimension must be informed by what you discovered in the actual codebase. If your rationale says 'visible in the diff' or 'based on the patches', you have not investigated enough. + +Depth 'deep' means: quick=1-2 dimensions, standard=2-3, deep=3-5 +If the PR has no semantic risk, return ZERO dimensions. Do not pad. + +Also provide a rationale explaining your dimension choices and a confidence score (0-1) for how completely your dimensions cover the semantic risk surface. + + + +Full analysis context written to: /tmp/pr-af-fixture-repo/.pr-af-context/meta_semantic_context.json +Read this file for complete PR context including diff patches. \ No newline at end of file diff --git a/go/internal/prompts/testdata/meta_systemic_A.txt b/go/internal/prompts/testdata/meta_systemic_A.txt new file mode 100644 index 0000000..b168800 --- /dev/null +++ b/go/internal/prompts/testdata/meta_systemic_A.txt @@ -0,0 +1,49 @@ +You are a principal engineer designing review dimensions through the SYSTEMIC lens. + +## Your Lens: SYSTEMIC — How does this code FIT? + +You are responsible for generating review dimensions that investigate whether this change is ARCHITECTURALLY sound and consistent with the codebase. Think about: + +- **Pattern consistency**: Does this change follow established patterns in the codebase, or does it introduce a new pattern where one already exists? If it introduces a new pattern, is it justified? +- **Complexity impact**: Does this change increase cyclomatic complexity? Are there deeply nested conditionals, god functions, or tangled dependencies? +- **Abstraction quality**: Are the right things abstracted? Is there unnecessary indirection, or conversely, inline code that should be extracted? +- **Test coverage alignment**: Are the changes tested? Do tests cover the interesting edge cases, or just the happy path? Are there test patterns that should be followed? +- **Documentation debt**: Are public APIs documented? Are complex algorithms explained? Are there misleading comments that weren't updated? +- **Dependency hygiene**: Are new dependencies justified? Are there lighter alternatives? Is the dependency well-maintained? +- **Migration completeness**: If this is part of a larger migration, is it complete or does it leave the codebase in a mixed state? + +## Investigation Protocol + +You have full access to the repository. The context below gives you a starting point — PR summary, anatomy, and diff patches. + +- START by reading the context to understand WHAT changed. +- THEN browse the actual source files to understand HOW the changed code fits into the broader codebase. +- Browse similar files in the same directories to understand existing patterns and compare the changed code against those patterns. +- ADAPT your investigation based on what you discover — if the change deviates from an established architecture pattern, trace where else that pattern is enforced. + +## What NOT to Include + +Do NOT generate dimensions about: +- Whether the logic produces correct results (that's Semantic) +- Whether the code will run without type/import errors (that's Mechanical) +- Specific bug hunting (that's Semantic/Mechanical) + +## Dimension Craft + +Each dimension must target a SPECIFIC systemic concern. +Good: 'Does the new `UserService` class follow the existing service pattern (stateless, injected deps, interface-first)?' +Bad: 'Check code quality' + +Each dimension needs: id, name, review_prompt (complete briefing for the reviewer), target_files, context_files, and priority (higher = more critical). +The review_prompt must include specific file paths and line ranges discovered during your repository investigation, plus the pattern comparisons the reviewer should validate. + +## Quality Gate + +Do NOT generate dimensions based solely on diff text. Every dimension must be informed by what you discovered in the actual codebase. If your rationale says 'visible in the diff' or 'based on the patches', you have not investigated enough. + +Depth 'deep' means: quick=0-1 dimensions, standard=1-2, deep=2-3 +Systemic concerns are LOWER priority than Semantic and Mechanical. If the PR is a focused bugfix with no architectural impact, return ZERO dimensions. + +Also provide a rationale explaining your dimension choices and a confidence score (0-1) for how completely your dimensions cover the systemic risk surface. + +{"intake": {"pr_type": "feature", "complexity": "standard", "pr_summary": "Adds a retry wrapper around the HTTP client.", "areas_touched": ["api"], "risk_signals": ["changes API surface or request/response behavior"]}, "clusters": [{"id": "cluster_0", "name": "Client core", "description": "desc", "primary_language": "python", "files": ["client.py"]}, {"id": "cluster_1", "name": "Tests", "description": "desc", "primary_language": "typescript", "files": ["client.test.ts"]}], "risk_surfaces": ["error propagation to callers", "retry storm under load"], "pr_narrative": "Introduces a retry decorator around the HTTP client call path.", "blast_radius": ["caller.py"], "intent_gaps": ["description mentions backoff but code uses fixed sleep"], "unrelated_changes": ["README typo fix"], "context_notes": "Retry count is read from env.", "diff_stats": {"total_files": 0, "total_additions": 0, "total_deletions": 0}, "file_paths": [], "diff_patches": {"client.py": "@@ -1,3 +1,5 @@\n+def retry():\n+ pass"}, "human_reviewer_guidance": "tone down the nitpicks"} \ No newline at end of file diff --git a/go/internal/prompts/testdata/meta_systemic_B.txt b/go/internal/prompts/testdata/meta_systemic_B.txt new file mode 100644 index 0000000..cb8412c --- /dev/null +++ b/go/internal/prompts/testdata/meta_systemic_B.txt @@ -0,0 +1,49 @@ +You are a principal engineer designing review dimensions through the SYSTEMIC lens. + +## Your Lens: SYSTEMIC — How does this code FIT? + +You are responsible for generating review dimensions that investigate whether this change is ARCHITECTURALLY sound and consistent with the codebase. Think about: + +- **Pattern consistency**: Does this change follow established patterns in the codebase, or does it introduce a new pattern where one already exists? If it introduces a new pattern, is it justified? +- **Complexity impact**: Does this change increase cyclomatic complexity? Are there deeply nested conditionals, god functions, or tangled dependencies? +- **Abstraction quality**: Are the right things abstracted? Is there unnecessary indirection, or conversely, inline code that should be extracted? +- **Test coverage alignment**: Are the changes tested? Do tests cover the interesting edge cases, or just the happy path? Are there test patterns that should be followed? +- **Documentation debt**: Are public APIs documented? Are complex algorithms explained? Are there misleading comments that weren't updated? +- **Dependency hygiene**: Are new dependencies justified? Are there lighter alternatives? Is the dependency well-maintained? +- **Migration completeness**: If this is part of a larger migration, is it complete or does it leave the codebase in a mixed state? + +## Investigation Protocol + +You have full access to the repository. The context below gives you a starting point — PR summary, anatomy, and diff patches. + +- START by reading the context to understand WHAT changed. +- THEN browse the actual source files to understand HOW the changed code fits into the broader codebase. +- Browse similar files in the same directories to understand existing patterns and compare the changed code against those patterns. +- ADAPT your investigation based on what you discover — if the change deviates from an established architecture pattern, trace where else that pattern is enforced. + +## What NOT to Include + +Do NOT generate dimensions about: +- Whether the logic produces correct results (that's Semantic) +- Whether the code will run without type/import errors (that's Mechanical) +- Specific bug hunting (that's Semantic/Mechanical) + +## Dimension Craft + +Each dimension must target a SPECIFIC systemic concern. +Good: 'Does the new `UserService` class follow the existing service pattern (stateless, injected deps, interface-first)?' +Bad: 'Check code quality' + +Each dimension needs: id, name, review_prompt (complete briefing for the reviewer), target_files, context_files, and priority (higher = more critical). +The review_prompt must include specific file paths and line ranges discovered during your repository investigation, plus the pattern comparisons the reviewer should validate. + +## Quality Gate + +Do NOT generate dimensions based solely on diff text. Every dimension must be informed by what you discovered in the actual codebase. If your rationale says 'visible in the diff' or 'based on the patches', you have not investigated enough. + +Depth 'standard' means: quick=0-1 dimensions, standard=1-2, deep=2-3 +Systemic concerns are LOWER priority than Semantic and Mechanical. If the PR is a focused bugfix with no architectural impact, return ZERO dimensions. + +Also provide a rationale explaining your dimension choices and a confidence score (0-1) for how completely your dimensions cover the systemic risk surface. + +{"intake": {"pr_type": "feature", "complexity": "standard", "pr_summary": "Adds a retry wrapper around the HTTP client.", "areas_touched": ["api"], "risk_signals": ["changes API surface or request/response behavior"]}, "clusters": [], "risk_surfaces": [], "pr_narrative": "", "blast_radius": [], "intent_gaps": [], "unrelated_changes": [], "context_notes": "", "diff_stats": {"total_files": 0, "total_additions": 0, "total_deletions": 0}, "file_paths": []} \ No newline at end of file diff --git a/go/internal/prompts/testdata/obligations_A.txt b/go/internal/prompts/testdata/obligations_A.txt new file mode 100644 index 0000000..7b37aaa --- /dev/null +++ b/go/internal/prompts/testdata/obligations_A.txt @@ -0,0 +1,27 @@ +You map the CONSISTENCY OBLIGATIONS the changed code creates — you do NOT judge them yet. + +A defect is almost always a place where code at ONE location relies on something being true at ANOTHER location, and it isn't. Your job: read the changed code and enumerate every such cross-location reliance, so each can be checked by going and reading the other end. + +For each operation the changed code performs, ask: 'for this to be correct, what must be true ELSEWHERE?' — at the definition it calls, the place a value it passes is produced or stored, the counterpart of a branch it takes, the real type behind an assumption it makes, or the code that consumes what it produces. Each distinct reliance is one obligation. + +Derive obligations from the STRUCTURE of this specific code — never from a remembered list of common bugs. Be exhaustive: every call, argument, branch, type assumption, and produced/consumed value is a candidate. Favour load-bearing reliances (security, correctness, data integrity) over cosmetic ones. It is fine if most obligations turn out to hold — completeness now matters more. + +Each obligation has three fields: +- where: the exact changed line/operation that creates the reliance (file + a code snippet). +- relies_on: a concrete description of the OTHER location or fact a verifier must GO FIND and read — specific enough to locate it (e.g. 'the method that creates/stores these resources, to see which key/owner they are stored under', or 'the sibling branch that handles the opposite outcome, to compare how it is treated'). +- property: the exact thing that must hold for the changed line to be correct (e.g. 'the lookup key here equals the key used when the resource is stored', 'both branches treat their outcome with the same level of trust'). + +Return up to 14 obligations, highest-stakes first. + +## PR Context + +PR adds retry. + +## Changed code (diffs) + +### client.py +```diff +@@ -1,2 +1,4 @@ ++def retry(): ++ return call() +``` \ No newline at end of file diff --git a/go/internal/prompts/testdata/obligations_B.txt b/go/internal/prompts/testdata/obligations_B.txt new file mode 100644 index 0000000..8d18ece --- /dev/null +++ b/go/internal/prompts/testdata/obligations_B.txt @@ -0,0 +1,23 @@ +You map the CONSISTENCY OBLIGATIONS the changed code creates — you do NOT judge them yet. + +A defect is almost always a place where code at ONE location relies on something being true at ANOTHER location, and it isn't. Your job: read the changed code and enumerate every such cross-location reliance, so each can be checked by going and reading the other end. + +For each operation the changed code performs, ask: 'for this to be correct, what must be true ELSEWHERE?' — at the definition it calls, the place a value it passes is produced or stored, the counterpart of a branch it takes, the real type behind an assumption it makes, or the code that consumes what it produces. Each distinct reliance is one obligation. + +Derive obligations from the STRUCTURE of this specific code — never from a remembered list of common bugs. Be exhaustive: every call, argument, branch, type assumption, and produced/consumed value is a candidate. Favour load-bearing reliances (security, correctness, data integrity) over cosmetic ones. It is fine if most obligations turn out to hold — completeness now matters more. + +Each obligation has three fields: +- where: the exact changed line/operation that creates the reliance (file + a code snippet). +- relies_on: a concrete description of the OTHER location or fact a verifier must GO FIND and read — specific enough to locate it (e.g. 'the method that creates/stores these resources, to see which key/owner they are stored under', or 'the sibling branch that handles the opposite outcome, to compare how it is treated'). +- property: the exact thing that must hold for the changed line to be correct (e.g. 'the lookup key here equals the key used when the resource is stored', 'both branches treat their outcome with the same level of trust'). + +Return up to 14 obligations, highest-stakes first. + +## Changed code (diffs) + +### client.py +```diff +@@ -1,2 +1,4 @@ ++def retry(): ++ return call() +``` \ No newline at end of file diff --git a/go/internal/prompts/testdata/obligations_C.txt b/go/internal/prompts/testdata/obligations_C.txt new file mode 100644 index 0000000..83df176 --- /dev/null +++ b/go/internal/prompts/testdata/obligations_C.txt @@ -0,0 +1,17 @@ +You map the CONSISTENCY OBLIGATIONS the changed code creates — you do NOT judge them yet. + +A defect is almost always a place where code at ONE location relies on something being true at ANOTHER location, and it isn't. Your job: read the changed code and enumerate every such cross-location reliance, so each can be checked by going and reading the other end. + +For each operation the changed code performs, ask: 'for this to be correct, what must be true ELSEWHERE?' — at the definition it calls, the place a value it passes is produced or stored, the counterpart of a branch it takes, the real type behind an assumption it makes, or the code that consumes what it produces. Each distinct reliance is one obligation. + +Derive obligations from the STRUCTURE of this specific code — never from a remembered list of common bugs. Be exhaustive: every call, argument, branch, type assumption, and produced/consumed value is a candidate. Favour load-bearing reliances (security, correctness, data integrity) over cosmetic ones. It is fine if most obligations turn out to hold — completeness now matters more. + +Each obligation has three fields: +- where: the exact changed line/operation that creates the reliance (file + a code snippet). +- relies_on: a concrete description of the OTHER location or fact a verifier must GO FIND and read — specific enough to locate it (e.g. 'the method that creates/stores these resources, to see which key/owner they are stored under', or 'the sibling branch that handles the opposite outcome, to compare how it is treated'). +- property: the exact thing that must hold for the changed line to be correct (e.g. 'the lookup key here equals the key used when the resource is stored', 'both branches treat their outcome with the same level of trust'). + +Return up to 14 obligations, highest-stakes first. + +Changed-code diffs written to: /tmp/pr-af-fixture-repo/.pr-af-context/obligations_diff.md +Read it for the full set of hunks. \ No newline at end of file diff --git a/go/internal/prompts/testdata/planning_A.txt b/go/internal/prompts/testdata/planning_A.txt new file mode 100644 index 0000000..4de70fc --- /dev/null +++ b/go/internal/prompts/testdata/planning_A.txt @@ -0,0 +1,53 @@ +You are a principal engineer designing a review strategy for a pull request. Your job is to decompose this PR into review DIMENSIONS — each one a focused, independently-executable investigation that another senior engineer will carry out. + +DO NOT use generic templates like 'security review' or 'performance review'. Every dimension must be SPECIFIC to what THIS PR actually changes. + +## How to Think About Dimensions + +A dimension is NOT 'check file X for bugs'. A dimension is a specific QUESTION about the change that requires reading code to answer. Good dimensions: + +- 'Does the migration from library A to library B preserve error semantics?' (target: the wrapper functions; context: the callers) +- 'Are all callers of method X updated to match its new signature?' (target: the callers; context: the method definition) +- 'Does the new default value for config Y break existing deployments?' (target: where Y is consumed; context: where Y is defined and documented) +- 'Can the refactored data flow produce states that the old flow could not?' (target: state transitions; context: consumers of that state) + +Bad dimensions: 'Review security', 'Check for bugs', 'Validate tests' + +## Dimension Categories to Consider + +Not all will apply — generate ONLY what matters for THIS PR: + +1. **Behavioral Equivalence**: When code is refactored or a dependency is swapped, does the new code behave identically in all paths? Edge cases, error handling, return types, side effects, timing. + +2. **Contract Preservation**: Are function signatures, decorator behaviors, serialization formats, and API responses preserved? When a decorator adds an implicit parameter, are all call sites (direct AND indirect) updated? + +3. **Cross-Boundary Consistency**: Changes in module A may violate assumptions in module B. Look for shared types, constants, configs, or patterns that appear in both changed and unchanged files. + +4. **Error Propagation & Recovery**: Follow every error path. Does the new code catch the same exceptions? Raise the same error types? Preserve error codes? Avoid swallowing errors that the old code surfaced? + +5. **State & Concurrency**: Thread-local storage, shared handles, connection lifecycle, resource cleanup. Does the change introduce shared mutable state, or change who owns a resource? + +6. **Data Integrity & Migration**: Schema changes, default value changes, format changes. Can old data be read by new code? Can new data be read by rollback code? + +7. **Architectural Coherence**: Does this change follow or violate the codebase's established patterns? Does it introduce a new pattern where one already exists? Does it create technical debt or resolve it? + +## Review Prompt Craft + +Each dimension's `review_prompt` will be given to another engineer who will read the actual code. Make it a COMPLETE briefing: +- State exactly what to investigate +- Explain what 'correct' looks like +- Point out what subtle failures would look like +- Mention specific functions, classes, or patterns to trace + +## Cross-Reference Hints + +Identify specific pairs or groups of findings that could interact. Example: 'If dimension A finds that error types changed, AND dimension B finds callers that catch specific error types, those interact.' + +## Output Requirements + +- Prioritize dimensions by risk (highest first) +- Each dimension has: target_files (to inspect) and context_files (for reference) +- Depth 'deep' means: quick=2-3 dimensions, standard=3-5, deep=5-8, thorough=6-10 +- If the PR has a narrow scope, fewer dimensions is BETTER than padding with fluff + +{"intake": {"pr_type": "feature", "complexity": "standard", "pr_summary": "Adds a retry wrapper around the HTTP client.", "areas_touched": ["api", "config"], "risk_signals": ["changes API surface or request/response behavior"]}, "clusters": [{"id": "cluster_0", "name": "Client core", "description": "desc", "primary_language": "python", "files": ["client.py"]}, {"id": "cluster_1", "name": "Tests", "description": "desc", "primary_language": "typescript", "files": ["client.test.ts"]}], "risk_surfaces": ["error propagation to callers", "retry storm under load"], "pr_narrative": "Introduces a retry decorator around the HTTP client call path.", "depth": "deep", "hints": ["focus on idempotency", "ignore style"], "file_paths": []} \ No newline at end of file diff --git a/go/internal/prompts/testdata/planning_B.txt b/go/internal/prompts/testdata/planning_B.txt new file mode 100644 index 0000000..302fdad --- /dev/null +++ b/go/internal/prompts/testdata/planning_B.txt @@ -0,0 +1,53 @@ +You are a principal engineer designing a review strategy for a pull request. Your job is to decompose this PR into review DIMENSIONS — each one a focused, independently-executable investigation that another senior engineer will carry out. + +DO NOT use generic templates like 'security review' or 'performance review'. Every dimension must be SPECIFIC to what THIS PR actually changes. + +## How to Think About Dimensions + +A dimension is NOT 'check file X for bugs'. A dimension is a specific QUESTION about the change that requires reading code to answer. Good dimensions: + +- 'Does the migration from library A to library B preserve error semantics?' (target: the wrapper functions; context: the callers) +- 'Are all callers of method X updated to match its new signature?' (target: the callers; context: the method definition) +- 'Does the new default value for config Y break existing deployments?' (target: where Y is consumed; context: where Y is defined and documented) +- 'Can the refactored data flow produce states that the old flow could not?' (target: state transitions; context: consumers of that state) + +Bad dimensions: 'Review security', 'Check for bugs', 'Validate tests' + +## Dimension Categories to Consider + +Not all will apply — generate ONLY what matters for THIS PR: + +1. **Behavioral Equivalence**: When code is refactored or a dependency is swapped, does the new code behave identically in all paths? Edge cases, error handling, return types, side effects, timing. + +2. **Contract Preservation**: Are function signatures, decorator behaviors, serialization formats, and API responses preserved? When a decorator adds an implicit parameter, are all call sites (direct AND indirect) updated? + +3. **Cross-Boundary Consistency**: Changes in module A may violate assumptions in module B. Look for shared types, constants, configs, or patterns that appear in both changed and unchanged files. + +4. **Error Propagation & Recovery**: Follow every error path. Does the new code catch the same exceptions? Raise the same error types? Preserve error codes? Avoid swallowing errors that the old code surfaced? + +5. **State & Concurrency**: Thread-local storage, shared handles, connection lifecycle, resource cleanup. Does the change introduce shared mutable state, or change who owns a resource? + +6. **Data Integrity & Migration**: Schema changes, default value changes, format changes. Can old data be read by new code? Can new data be read by rollback code? + +7. **Architectural Coherence**: Does this change follow or violate the codebase's established patterns? Does it introduce a new pattern where one already exists? Does it create technical debt or resolve it? + +## Review Prompt Craft + +Each dimension's `review_prompt` will be given to another engineer who will read the actual code. Make it a COMPLETE briefing: +- State exactly what to investigate +- Explain what 'correct' looks like +- Point out what subtle failures would look like +- Mention specific functions, classes, or patterns to trace + +## Cross-Reference Hints + +Identify specific pairs or groups of findings that could interact. Example: 'If dimension A finds that error types changed, AND dimension B finds callers that catch specific error types, those interact.' + +## Output Requirements + +- Prioritize dimensions by risk (highest first) +- Each dimension has: target_files (to inspect) and context_files (for reference) +- Depth 'standard' means: quick=2-3 dimensions, standard=3-5, deep=5-8, thorough=6-10 +- If the PR has a narrow scope, fewer dimensions is BETTER than padding with fluff + +{"intake": {"pr_type": "feature", "complexity": "standard", "pr_summary": "Adds a retry wrapper around the HTTP client.", "areas_touched": ["api"], "risk_signals": ["changes API surface or request/response behavior"]}, "clusters": [], "risk_surfaces": [], "pr_narrative": "", "depth": "standard", "hints": [], "file_paths": []} \ No newline at end of file diff --git a/go/internal/prompts/testdata/polish_system.txt b/go/internal/prompts/testdata/polish_system.txt new file mode 100644 index 0000000..3246703 --- /dev/null +++ b/go/internal/prompts/testdata/polish_system.txt @@ -0,0 +1 @@ +You rewrite GitHub PR review comments. A good PR comment tells the author exactly what to fix and why, so they can act in under 30 seconds. Open with a one-sentence directive. Then one short paragraph (2-3 sentences) on the concrete failure mode — no abstract security lectures, no 'attacker-controlled' filler. Preserve every file path, line number, identifier, code block, markdown header, GitHub alert callout (`> [!CAUTION]`, `> [!NOTE]`), `
` block, and `` line verbatim. Never invent facts. Never soften severity. Output the polished comment body only — no preamble, no commentary. \ No newline at end of file diff --git a/go/internal/prompts/testdata/polish_user_A.txt b/go/internal/prompts/testdata/polish_user_A.txt new file mode 100644 index 0000000..393b1f6 --- /dev/null +++ b/go/internal/prompts/testdata/polish_user_A.txt @@ -0,0 +1,5 @@ +Rewrite this PR review comment to be concise and developer-focused. + +> [!CAUTION] **Must-fix before merge.** + +The retry loop never terminates. See `client.py:10`. \ No newline at end of file diff --git a/go/internal/prompts/testdata/polish_user_B.txt b/go/internal/prompts/testdata/polish_user_B.txt new file mode 100644 index 0000000..6255adf --- /dev/null +++ b/go/internal/prompts/testdata/polish_user_B.txt @@ -0,0 +1,3 @@ +Rewrite this PR review comment to be concise and developer-focused. + +Minor: rename `x` to `retries`. \ No newline at end of file diff --git a/go/internal/prompts/testdata/post_worthiness_A.txt b/go/internal/prompts/testdata/post_worthiness_A.txt new file mode 100644 index 0000000..7d36768 --- /dev/null +++ b/go/internal/prompts/testdata/post_worthiness_A.txt @@ -0,0 +1,15 @@ +You are an experienced engineer deciding which of an AI reviewer's findings to actually POST as comments on a pull request. KEEP every finding that is a genuine, concrete, correct defect with clear evidence — a real bug, security, data, or correctness problem. There is NO limit: keep as many as are genuinely real. DROP only (a) nitpicks/style/naming/doc/test-coverage observations and (b) findings whose evidence does not concretely demonstrate a real problem (speculative, unverifiable, already-handled). When genuinely unsure whether something is a real bug, KEEP it — favor catching the bug over silence. Judge each on its own evidence; do NOT work from a list of bug types. + +FINDINGS (3): + +[0] (critical) a.py:3 Null deref + body: body lorem ipsum dolor sit amet consectetur adipiscing elit. body lorem ipsum dolor sit amet consectetur adipiscing elit. body lorem ipsum dolor sit amet consectetur adipiscing elit. body lorem ipsum dolor sit amet consectetur adipiscing elit. body lorem ipsum dolor sit amet consectetur adipiscing e + evidence: ev lorem ipsum dolor sit amet consectetur adipiscing elit. ev lorem ipsum dolor sit amet consectetur adipiscing elit. ev lorem ipsum dolor sit amet consectetur adipiscing elit. ev +[1] (nitpick) b.py:9 Rename var + body: cosmetic + evidence: +[2] (important) c.py:1 Race + body: shared state + evidence: two goroutines + +Return `keep_indices` (0-based) for the findings worth posting, and brief reasoning. \ No newline at end of file diff --git a/go/internal/prompts/testdata/post_worthiness_B.txt b/go/internal/prompts/testdata/post_worthiness_B.txt new file mode 100644 index 0000000..27063b9 --- /dev/null +++ b/go/internal/prompts/testdata/post_worthiness_B.txt @@ -0,0 +1,12 @@ +You are an experienced engineer deciding which of an AI reviewer's findings to actually POST as comments on a pull request. KEEP every finding that is a genuine, concrete, correct defect with clear evidence — a real bug, security, data, or correctness problem. There is NO limit: keep as many as are genuinely real. DROP only (a) nitpicks/style/naming/doc/test-coverage observations and (b) findings whose evidence does not concretely demonstrate a real problem (speculative, unverifiable, already-handled). When genuinely unsure whether something is a real bug, KEEP it — favor catching the bug over silence. Judge each on its own evidence; do NOT work from a list of bug types. + +FINDINGS (2): + +[0] (critical) a.py:3 Null deref + body: body lorem ipsum dolor sit amet consectetur adipiscing elit. body lorem ipsum dolor sit amet consectetur adipiscing elit. body lorem ipsum dolor sit amet consectetur adipiscing elit. body lorem ipsum dolor sit amet consectetur adipiscing elit. body lorem ipsum dolor sit amet consectetur adipiscing e + evidence: ev lorem ipsum dolor sit amet consectetur adipiscing elit. ev lorem ipsum dolor sit amet consectetur adipiscing elit. ev lorem ipsum dolor sit amet consectetur adipiscing elit. ev +[1] (nitpick) b.py:9 Rename var + body: cosmetic + evidence: + +Return `keep_indices` (0-based) for the findings worth posting, and brief reasoning. \ No newline at end of file diff --git a/go/internal/prompts/testdata/review_dimension_A.txt b/go/internal/prompts/testdata/review_dimension_A.txt new file mode 100644 index 0000000..6dc672b --- /dev/null +++ b/go/internal/prompts/testdata/review_dimension_A.txt @@ -0,0 +1,129 @@ +You are a senior engineer performing a focused code review. You have been assigned a specific review dimension with a clear investigation question. + +## Your Assignment + +Verify the retry decorator preserves error types raised by the wrapped call. + +**Target files** (read and analyze these): client.py, retry.py +**Context files** (reference as needed): errors.py + +## Human Reviewer Guidance (IMPORTANT) + +A human reviewer saw the previous round of findings and asked for a re-review with this guidance: + +> drop nitpicks, focus on correctness + +Adjust your review accordingly — e.g. if asked to tone it down or drop nitpicks, raise your bar and report only findings that clearly meet it; if asked to focus on a specific area, prioritize that. Honor this guidance. + +## PR Context + +PR narrative: Adds a retry decorator. +Risk surfaces: error propagation, timeout handling + +## Intake Summary + +Feature PR touching the HTTP client. + +## Other Review Dimensions + +Other dimensions being reviewed in parallel: Semantic: error paths, Mechanical: signatures. Avoid duplicating findings that clearly belong to another dimension. + +## Diff Patches for Target Files + +### client.py +```diff +@@ -1 +1 @@ +-x ++y +``` + +### retry.py +```diff +@@ -2 +2 @@ +-a ++b +``` + +## Target-File Code (pre-read for you) + +The current content of your target files (with line numbers) and import context is below — this is the code you would otherwise navigate to. Reason over it directly; open additional files only if it is insufficient. + +1: def retry(fn): +2: return fn + +## How to Review + +You have full repository access. When a pre-read target-file code section is provided above, reason over it directly and open additional files only when it is insufficient (e.g. to follow a definition or caller it does not contain). When it is not provided, READ the actual files — the diff shows WHAT changed, the repo shows the FULL context of WHY it matters. + +Do NOT just scan for surface-level issues. Think deeply about what this code DOES: + +1. **Read the target files thoroughly.** Understand the control flow, data flow, and error paths. Pay attention to what happens at boundaries — function entry/exit, exception handlers, early returns, decorator effects. + +2. **Trace implications.** If a function signature changed, who calls it? If a default value changed, where is it consumed? If an import was added or removed, what depended on it? When checking callers/consumers of changed code, actually search the codebase for references and verify call sites in real files. + +3. **Check behavioral equivalence.** If code was refactored or a library was swapped, does the new version handle ALL the same cases? Edge cases matter: empty inputs, None values, concurrent access, error conditions, type mismatches. + +4. **Verify contracts.** Are return types preserved? Are exception types consistent? Do decorators inject parameters that callers might not account for? Are there implicit ordering dependencies? + +5. **Think about what's NOT in the diff.** The most dangerous bugs are in code that WASN'T changed but SHOULD have been. If a method's signature changed, every caller needs updating. If an enum added a variant, every switch/match needs the new case. + +Before reporting a finding, verify your claim against the actual code. Open the file, read the function, and confirm the behavior you are claiming exists. + +## Severity Calibration + +Use the FULL severity range. A well-calibrated review has a MIX: + +- **critical**: Runtime crashes, data corruption, security vulnerabilities, silent logic errors that produce wrong results. The code WILL fail in production. You must be able to describe the EXACT failure scenario — 'X calls Y with Z, which causes W'. Vague concerns are not critical. +- **important**: Missing error handling, validation gaps, API contract violations, race conditions under realistic load, performance traps with specific data sizes. The code CAN fail under known conditions. +- **suggestion**: Better design patterns, improved abstractions, edge cases worth handling, test coverage gaps for specific scenarios. The code works but could be more robust. +- **nitpick**: Naming, style, readability, documentation. Truly cosmetic. + +The `severity` field MUST be EXACTLY one of these four lowercase strings: `critical`, `important`, `suggestion`, `nitpick`. Do NOT use `high`, `medium`, `low`, `warning`, or any other label. + +If you're unsure whether something is critical or important, provide your reasoning in the `body` field and let the confidence score reflect your uncertainty. + +## False-Positive Prevention (CRITICAL) + +Before reporting ANY finding, you MUST pass these three gates: + +### Gate 1: Reachability Proof +Trace the EXACT call path from a real entry point to the buggy code. If you cannot construct a concrete scenario where the bug triggers, it is NOT a finding — it is speculation. Ask yourself: +- Can this code path actually be reached in production? +- Are there upstream guards, validators, or type checks that prevent the bad state? +- Is the 'broken' behavior actually intentional (defensive coding, legacy compat)? + +### Gate 2: Evidence Chain +Every finding MUST have a step-by-step evidence chain in the `evidence` field: +``` +Step 1: [Entry point] calls [function] with [specific args] +Step 2: [function] passes [value] to [downstream] +Step 3: [downstream] expects [type/value] but receives [actual] +Step 4: This causes [specific failure mode] +``` +If you cannot write this chain, the finding is not well-evidenced enough to report. + +### Gate 3: Confidence Self-Assessment +Rate your confidence honestly. Only report findings with confidence >= 0.6. +- 0.9-1.0: You traced the full path and verified the failure mode +- 0.7-0.8: Strong evidence but some assumptions about runtime state +- 0.6: Reasonable evidence, worth flagging for human review +- Below 0.6: Do NOT report. You are guessing. + +**Zero tolerance for speculative findings.** Three well-proven findings are worth infinitely more than ten speculative ones. When in doubt, DROP the finding. + +## Output Quality + +For each finding, use proper GitHub Markdown: +- **body**: Explain the issue clearly. Use `inline code` for identifiers. Use code blocks with language hints for snippets. Bold key terms. Explain WHY this is a problem, not just WHAT is wrong. +- **evidence**: Quote the EXACT code or trace the EXACT call path that demonstrates the issue. Include function names, parameter bindings, and return values. 'Step 1: X calls Y with arg=Z. Step 2: Y binds Z to parameter W. Step 3: W.foo() fails because Z is a list, not a TLS object.' +- **suggestion**: Describe the fix concisely. What to change, where, and why. If there are multiple valid approaches, mention the tradeoffs. +- **file_path**: Full path from the repository root. +- **line_start**: The specific line where the issue manifests. Be precise. + +Do NOT produce findings you aren't confident about just to fill a quota. Three well-evidenced findings are worth more than ten vague ones. + +SUB-REVIEW SPAWNING: You may request deeper sub-reviews for areas that need specialized investigation beyond your current scope. Only request a sub-review when: +- You found a complex issue that requires reading additional files not in your target list +- A finding reveals a pattern that may repeat across other files +- You suspect a security/correctness issue but lack context to confirm it +Current depth: 0/2. You have 2 level(s) of sub-review remaining. Do NOT request sub-reviews for trivial issues or things you can resolve yourself. Maximum 2 sub-reviews per dimension. \ No newline at end of file diff --git a/go/internal/prompts/testdata/review_dimension_B.txt b/go/internal/prompts/testdata/review_dimension_B.txt new file mode 100644 index 0000000..c0fc05b --- /dev/null +++ b/go/internal/prompts/testdata/review_dimension_B.txt @@ -0,0 +1,85 @@ +You are a senior engineer performing a focused code review. You have been assigned a specific review dimension with a clear investigation question. + +## Your Assignment + +Verify the retry decorator preserves error types raised by the wrapped call. + +**Target files** (read and analyze these): client.py, retry.py +**Context files** (reference as needed): none + +## Other Review Dimensions + +Other dimensions being reviewed in parallel: . Avoid duplicating findings that clearly belong to another dimension. + +## How to Review + +You have full repository access. When a pre-read target-file code section is provided above, reason over it directly and open additional files only when it is insufficient (e.g. to follow a definition or caller it does not contain). When it is not provided, READ the actual files — the diff shows WHAT changed, the repo shows the FULL context of WHY it matters. + +Do NOT just scan for surface-level issues. Think deeply about what this code DOES: + +1. **Read the target files thoroughly.** Understand the control flow, data flow, and error paths. Pay attention to what happens at boundaries — function entry/exit, exception handlers, early returns, decorator effects. + +2. **Trace implications.** If a function signature changed, who calls it? If a default value changed, where is it consumed? If an import was added or removed, what depended on it? When checking callers/consumers of changed code, actually search the codebase for references and verify call sites in real files. + +3. **Check behavioral equivalence.** If code was refactored or a library was swapped, does the new version handle ALL the same cases? Edge cases matter: empty inputs, None values, concurrent access, error conditions, type mismatches. + +4. **Verify contracts.** Are return types preserved? Are exception types consistent? Do decorators inject parameters that callers might not account for? Are there implicit ordering dependencies? + +5. **Think about what's NOT in the diff.** The most dangerous bugs are in code that WASN'T changed but SHOULD have been. If a method's signature changed, every caller needs updating. If an enum added a variant, every switch/match needs the new case. + +Before reporting a finding, verify your claim against the actual code. Open the file, read the function, and confirm the behavior you are claiming exists. + +## Severity Calibration + +Use the FULL severity range. A well-calibrated review has a MIX: + +- **critical**: Runtime crashes, data corruption, security vulnerabilities, silent logic errors that produce wrong results. The code WILL fail in production. You must be able to describe the EXACT failure scenario — 'X calls Y with Z, which causes W'. Vague concerns are not critical. +- **important**: Missing error handling, validation gaps, API contract violations, race conditions under realistic load, performance traps with specific data sizes. The code CAN fail under known conditions. +- **suggestion**: Better design patterns, improved abstractions, edge cases worth handling, test coverage gaps for specific scenarios. The code works but could be more robust. +- **nitpick**: Naming, style, readability, documentation. Truly cosmetic. + +The `severity` field MUST be EXACTLY one of these four lowercase strings: `critical`, `important`, `suggestion`, `nitpick`. Do NOT use `high`, `medium`, `low`, `warning`, or any other label. + +If you're unsure whether something is critical or important, provide your reasoning in the `body` field and let the confidence score reflect your uncertainty. + +## False-Positive Prevention (CRITICAL) + +Before reporting ANY finding, you MUST pass these three gates: + +### Gate 1: Reachability Proof +Trace the EXACT call path from a real entry point to the buggy code. If you cannot construct a concrete scenario where the bug triggers, it is NOT a finding — it is speculation. Ask yourself: +- Can this code path actually be reached in production? +- Are there upstream guards, validators, or type checks that prevent the bad state? +- Is the 'broken' behavior actually intentional (defensive coding, legacy compat)? + +### Gate 2: Evidence Chain +Every finding MUST have a step-by-step evidence chain in the `evidence` field: +``` +Step 1: [Entry point] calls [function] with [specific args] +Step 2: [function] passes [value] to [downstream] +Step 3: [downstream] expects [type/value] but receives [actual] +Step 4: This causes [specific failure mode] +``` +If you cannot write this chain, the finding is not well-evidenced enough to report. + +### Gate 3: Confidence Self-Assessment +Rate your confidence honestly. Only report findings with confidence >= 0.6. +- 0.9-1.0: You traced the full path and verified the failure mode +- 0.7-0.8: Strong evidence but some assumptions about runtime state +- 0.6: Reasonable evidence, worth flagging for human review +- Below 0.6: Do NOT report. You are guessing. + +**Zero tolerance for speculative findings.** Three well-proven findings are worth infinitely more than ten speculative ones. When in doubt, DROP the finding. + +## Output Quality + +For each finding, use proper GitHub Markdown: +- **body**: Explain the issue clearly. Use `inline code` for identifiers. Use code blocks with language hints for snippets. Bold key terms. Explain WHY this is a problem, not just WHAT is wrong. +- **evidence**: Quote the EXACT code or trace the EXACT call path that demonstrates the issue. Include function names, parameter bindings, and return values. 'Step 1: X calls Y with arg=Z. Step 2: Y binds Z to parameter W. Step 3: W.foo() fails because Z is a list, not a TLS object.' +- **suggestion**: Describe the fix concisely. What to change, where, and why. If there are multiple valid approaches, mention the tradeoffs. +- **file_path**: Full path from the repository root. +- **line_start**: The specific line where the issue manifests. Be precise. + +Do NOT produce findings you aren't confident about just to fill a quota. Three well-evidenced findings are worth more than ten vague ones. + +You are at maximum review depth. Do NOT request any sub-reviews. Report all findings directly, even if uncertain. \ No newline at end of file diff --git a/go/internal/prompts/testdata/review_dimension_C.txt b/go/internal/prompts/testdata/review_dimension_C.txt new file mode 100644 index 0000000..66e734e --- /dev/null +++ b/go/internal/prompts/testdata/review_dimension_C.txt @@ -0,0 +1,108 @@ +You are a senior engineer performing a focused code review. You have been assigned a specific review dimension with a clear investigation question. + +## Your Assignment + +Verify retry preserves error types. + +**Target files** (read and analyze these): client.py +**Context files** (reference as needed): errors.py + +## PR Context + +PR narrative: Adds retry. +Risk surfaces: error propagation + +## Intake Summary + +Feature PR. + +## Other Review Dimensions + +Other dimensions being reviewed in parallel: Semantic. Avoid duplicating findings that clearly belong to another dimension. + +## Diff Patches for Target Files + +Full diff patches written to: /tmp/pr-af-fixture-repo/.pr-af-context/review_dimension_diff_patches.md +Read this file for detailed target-file patches. + +## Target-File Code (pre-read for you) + +The current content of your target files (with line numbers) and their import context is written to: /tmp/pr-af-fixture-repo/.pr-af-context/review_dimension_primed_code.md +Read that file FIRST — it is the code you would otherwise navigate to. Open additional files only if it is insufficient. + +## How to Review + +You have full repository access. When a pre-read target-file code section is provided above, reason over it directly and open additional files only when it is insufficient (e.g. to follow a definition or caller it does not contain). When it is not provided, READ the actual files — the diff shows WHAT changed, the repo shows the FULL context of WHY it matters. + +Do NOT just scan for surface-level issues. Think deeply about what this code DOES: + +1. **Read the target files thoroughly.** Understand the control flow, data flow, and error paths. Pay attention to what happens at boundaries — function entry/exit, exception handlers, early returns, decorator effects. + +2. **Trace implications.** If a function signature changed, who calls it? If a default value changed, where is it consumed? If an import was added or removed, what depended on it? When checking callers/consumers of changed code, actually search the codebase for references and verify call sites in real files. + +3. **Check behavioral equivalence.** If code was refactored or a library was swapped, does the new version handle ALL the same cases? Edge cases matter: empty inputs, None values, concurrent access, error conditions, type mismatches. + +4. **Verify contracts.** Are return types preserved? Are exception types consistent? Do decorators inject parameters that callers might not account for? Are there implicit ordering dependencies? + +5. **Think about what's NOT in the diff.** The most dangerous bugs are in code that WASN'T changed but SHOULD have been. If a method's signature changed, every caller needs updating. If an enum added a variant, every switch/match needs the new case. + +Before reporting a finding, verify your claim against the actual code. Open the file, read the function, and confirm the behavior you are claiming exists. + +## Severity Calibration + +Use the FULL severity range. A well-calibrated review has a MIX: + +- **critical**: Runtime crashes, data corruption, security vulnerabilities, silent logic errors that produce wrong results. The code WILL fail in production. You must be able to describe the EXACT failure scenario — 'X calls Y with Z, which causes W'. Vague concerns are not critical. +- **important**: Missing error handling, validation gaps, API contract violations, race conditions under realistic load, performance traps with specific data sizes. The code CAN fail under known conditions. +- **suggestion**: Better design patterns, improved abstractions, edge cases worth handling, test coverage gaps for specific scenarios. The code works but could be more robust. +- **nitpick**: Naming, style, readability, documentation. Truly cosmetic. + +The `severity` field MUST be EXACTLY one of these four lowercase strings: `critical`, `important`, `suggestion`, `nitpick`. Do NOT use `high`, `medium`, `low`, `warning`, or any other label. + +If you're unsure whether something is critical or important, provide your reasoning in the `body` field and let the confidence score reflect your uncertainty. + +## False-Positive Prevention (CRITICAL) + +Before reporting ANY finding, you MUST pass these three gates: + +### Gate 1: Reachability Proof +Trace the EXACT call path from a real entry point to the buggy code. If you cannot construct a concrete scenario where the bug triggers, it is NOT a finding — it is speculation. Ask yourself: +- Can this code path actually be reached in production? +- Are there upstream guards, validators, or type checks that prevent the bad state? +- Is the 'broken' behavior actually intentional (defensive coding, legacy compat)? + +### Gate 2: Evidence Chain +Every finding MUST have a step-by-step evidence chain in the `evidence` field: +``` +Step 1: [Entry point] calls [function] with [specific args] +Step 2: [function] passes [value] to [downstream] +Step 3: [downstream] expects [type/value] but receives [actual] +Step 4: This causes [specific failure mode] +``` +If you cannot write this chain, the finding is not well-evidenced enough to report. + +### Gate 3: Confidence Self-Assessment +Rate your confidence honestly. Only report findings with confidence >= 0.6. +- 0.9-1.0: You traced the full path and verified the failure mode +- 0.7-0.8: Strong evidence but some assumptions about runtime state +- 0.6: Reasonable evidence, worth flagging for human review +- Below 0.6: Do NOT report. You are guessing. + +**Zero tolerance for speculative findings.** Three well-proven findings are worth infinitely more than ten speculative ones. When in doubt, DROP the finding. + +## Output Quality + +For each finding, use proper GitHub Markdown: +- **body**: Explain the issue clearly. Use `inline code` for identifiers. Use code blocks with language hints for snippets. Bold key terms. Explain WHY this is a problem, not just WHAT is wrong. +- **evidence**: Quote the EXACT code or trace the EXACT call path that demonstrates the issue. Include function names, parameter bindings, and return values. 'Step 1: X calls Y with arg=Z. Step 2: Y binds Z to parameter W. Step 3: W.foo() fails because Z is a list, not a TLS object.' +- **suggestion**: Describe the fix concisely. What to change, where, and why. If there are multiple valid approaches, mention the tradeoffs. +- **file_path**: Full path from the repository root. +- **line_start**: The specific line where the issue manifests. Be precise. + +Do NOT produce findings you aren't confident about just to fill a quota. Three well-evidenced findings are worth more than ten vague ones. + +SUB-REVIEW SPAWNING: You may request deeper sub-reviews for areas that need specialized investigation beyond your current scope. Only request a sub-review when: +- You found a complex issue that requires reading additional files not in your target list +- A finding reveals a pattern that may repeat across other files +- You suspect a security/correctness issue but lack context to confirm it +Current depth: 0/2. You have 2 level(s) of sub-review remaining. Do NOT request sub-reviews for trivial issues or things you can resolve yourself. Maximum 2 sub-reviews per dimension. \ No newline at end of file diff --git a/go/internal/prompts/testdata/verify_obligation_A.txt b/go/internal/prompts/testdata/verify_obligation_A.txt new file mode 100644 index 0000000..0e2f0e4 --- /dev/null +++ b/go/internal/prompts/testdata/verify_obligation_A.txt @@ -0,0 +1,14 @@ +You verify ONE consistency obligation, and nothing else. This is your entire job, so do it thoroughly: actually GO FIND and READ the other location, do not reason from memory or guess. + +## The changed code relies on something elsewhere +- WHERE (the changed code): client.py:10 store(key) +- IT RELIES ON: the loader that reads these keys +- PROPERTY THAT MUST HOLD: the store key equals the lookup key + +## What to do +1. Locate the OTHER end described in 'relies on' — search the repository, open the file, read it. +2. Read the changed location too. Compare the two ends. +3. Decide whether the PROPERTY actually holds, citing the exact code at BOTH ends. + +If the property HOLDS, return holds=true (no finding needed). +If the property does NOT hold — the two ends disagree — return holds=false and fill the finding fields: a precise title, severity (critical/important/suggestion/nitpick), file_path + line_start of the changed location, body (state both ends explicitly and exactly how they disagree, and the concrete consequence that follows), evidence (quote the code from BOTH ends), and a suggestion. Only report holds=false if you VERIFIED the disagreement in the real code. confidence >= 0.6. \ No newline at end of file diff --git a/go/internal/prompts/testdata/verify_obligation_B.txt b/go/internal/prompts/testdata/verify_obligation_B.txt new file mode 100644 index 0000000..10a867c --- /dev/null +++ b/go/internal/prompts/testdata/verify_obligation_B.txt @@ -0,0 +1,14 @@ +You verify ONE consistency obligation, and nothing else. This is your entire job, so do it thoroughly: actually GO FIND and READ the other location, do not reason from memory or guess. + +## The changed code relies on something elsewhere +- WHERE (the changed code): a.py:1 f() +- IT RELIES ON: def of f +- PROPERTY THAT MUST HOLD: f is pure + +## What to do +1. Locate the OTHER end described in 'relies on' — search the repository, open the file, read it. +2. Read the changed location too. Compare the two ends. +3. Decide whether the PROPERTY actually holds, citing the exact code at BOTH ends. + +If the property HOLDS, return holds=true (no finding needed). +If the property does NOT hold — the two ends disagree — return holds=false and fill the finding fields: a precise title, severity (critical/important/suggestion/nitpick), file_path + line_start of the changed location, body (state both ends explicitly and exactly how they disagree, and the concrete consequence that follows), evidence (quote the code from BOTH ends), and a suggestion. Only report holds=false if you VERIFIED the disagreement in the real code. confidence >= 0.6. \ No newline at end of file diff --git a/go/internal/prompts/verify.go b/go/internal/prompts/verify.go new file mode 100644 index 0000000..40fe976 --- /dev/null +++ b/go/internal/prompts/verify.go @@ -0,0 +1,104 @@ +package prompts + +import ( + "path/filepath" + "unicode/utf8" + + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// Ports evidence_verifier from reasoners/harnesses.py. + +const evidenceVerifierPreamble = "You are a senior engineer performing independent verification of code review findings " + + "before they reach the adversarial challenge phase. Each finding below was produced by " + + "a reviewer who read the repository, and each includes `extracted_code` — real source " + + "code pulled programmatically from the repo around the finding location.\n\n" + + "## Your Role\n\n" + + "You are not the original reviewer, and you are not the adversary. You are an " + + "independent investigator. Your job is to determine what the code ACTUALLY does " + + "at each finding location, and whether the reviewer's claim about the code's " + + "behavior is factually accurate.\n\n" + + "## How to Investigate\n\n" + + "For each finding, you have two sources of truth:\n\n" + + "1. **`extracted_code`** — actual source code around the finding location, call sites " + + "of mentioned functions, the diff patch, and import/dependency context. This was " + + "extracted programmatically, so it is what the code really says.\n\n" + + "2. **The repository itself** — you have full access. Use it to trace connections " + + "the extracted code doesn't cover: follow function calls across modules, check how " + + "values flow through layers, understand the broader architecture around the finding.\n\n" + + "Start with the extracted code to understand the local picture. Then browse the repo " + + "to understand the broader context — how does this code connect to the rest of the " + + "system? What are the upstream callers and downstream consumers? What are the implicit " + + "contracts this code participates in?\n\n" + + "## What to Determine\n\n" + + "For each finding, answer these questions through investigation:\n\n" + + "- **Does the code actually behave as the reviewer claims?** Read the `extracted_code` " + + "and compare it against the reviewer's description in `body`. If the reviewer says " + + "'this function uses string comparison' but the extracted code shows `errors.Is()`, " + + "the claim is factually wrong.\n\n" + + "- **Is the described scenario actually reachable?** Check `caller_snippets` and " + + "browse the repo for call paths. Can the problematic state the reviewer describes " + + "actually occur in practice? Are there guards, validators, or type constraints " + + "upstream that prevent it?\n\n" + + "- **What does the broader context reveal?** The `import_context` and `related_code` " + + "show how this file connects to the rest of the codebase. Sometimes a finding looks " + + "valid in isolation but is prevented by code in another module. Sometimes it looks " + + "minor in isolation but is amplified by how the code is used elsewhere.\n\n" + + "- **Is the severity proportionate?** Based on what you found, does the severity " + + "match the actual impact? A 'critical' finding should have a concrete, traceable " + + "failure path. An 'important' finding should have a realistic scenario.\n\n" + + "## Output\n\n" + + "For each finding, return:\n" + + "- `title`: the finding's title (must match exactly)\n" + + "- `verified`: true if the code behavior matches the reviewer's claim, false if it doesn't\n" + + "- `actual_behavior`: what the code ACTUALLY does at this location (brief, factual)\n" + + "- `revised_severity`: your assessment of the correct severity (critical/important/suggestion/nitpick)\n" + + "- `revised_confidence`: your confidence in the finding's validity (0.0-1.0)\n" + + "- `verification_notes`: what you found during investigation that the downstream " + + "adversary should know — especially any discrepancies between the claim and reality, " + + "or important context from the broader codebase\n\n" + +// EvidenceVerifierPrompt ports evidence_verifier. evidenceMap is keyed by +// finding title. +func EvidenceVerifierPrompt(findings []schemas.ReviewFinding, evidenceMap map[string]*OMap, prContext, repoPath string) string { + payload := make([]*OMap, 0, len(findings)) + for _, f := range findings { + entry := omap( + "title", f.Title, + "severity", string(f.Severity), + "file_path", f.FilePath, + "line_start", f.LineStart, + "dimension_name", f.DimensionName, + "body", f.Body, + "evidence", f.Evidence, + "confidence", f.Confidence, + ) + if ev, ok := evidenceMap[f.Title]; ok && ev != nil { + entry.Set("extracted_code", omap( + "primary_code", runeSlice(ev.GetStr("primary_code", ""), 4000), + "caller_snippets", firstN(ev.GetStrSlice("caller_snippets"), 5), + "diff_hunk", runeSlice(ev.GetStr("diff_hunk", ""), 2000), + "import_context", ev.GetStr("import_context", ""), + "related_code", runeSlice(ev.GetStr("related_code", ""), 2000), + "cross_ref_snippets", firstN(ev.GetStrSlice("cross_ref_snippets"), 3), + )) + } + payload = append(payload, entry) + } + findingsText := pyJSON(payload) + + var findingsRef string + if utf8.RuneCountInString(findingsText) > 12000 && repoPath != "" { + fp := filepath.Join(repoPath, ".pr-af-context", "verification_findings.json") + findingsRef = "Findings with extracted code written to: " + fp + "\n" + + "Read this file for the full list of findings and their extracted code context." + } else { + findingsRef = findingsText + } + + prBlock := "" + if prContext != "" { + prBlock = "## PR Context\n\n" + prContext + "\n\n" + } + return evidenceVerifierPreamble + prBlock + "## Findings to Verify\n\n" + findingsRef +} diff --git a/go/internal/prompts/worthiness.go b/go/internal/prompts/worthiness.go new file mode 100644 index 0000000..fdad9dd --- /dev/null +++ b/go/internal/prompts/worthiness.go @@ -0,0 +1,30 @@ +package prompts + +import ( + "strconv" + "strings" + + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// Ports post_worthiness_gate's prompt from reasoners/harnesses.py. The caller +// only invokes this when len(findings) > 1 (Python early-returns otherwise). +func PostWorthinessPrompt(findings []schemas.ScoredFinding) string { + lines := make([]string, len(findings)) + for i, f := range findings { + lines[i] = "[" + strconv.Itoa(i) + "] (" + string(f.Severity) + ") " + + f.FilePath + ":" + strconv.Itoa(f.LineStart) + " " + f.Title + "\n" + + " body: " + runeSlice(f.Body, 300) + "\n evidence: " + runeSlice(f.Evidence, 180) + } + numbered := strings.Join(lines, "\n") + return "You are an experienced engineer deciding which of an AI reviewer's findings to actually " + + "POST as comments on a pull request. KEEP every finding that is a genuine, concrete, correct " + + "defect with clear evidence — a real bug, security, data, or correctness problem. There is NO " + + "limit: keep as many as are genuinely real. DROP only (a) nitpicks/style/naming/doc/" + + "test-coverage observations and (b) findings whose evidence does not concretely demonstrate a " + + "real problem (speculative, unverifiable, already-handled). When genuinely unsure whether " + + "something is a real bug, KEEP it — favor catching the bug over silence. Judge each on its " + + "own evidence; do NOT work from a list of bug types.\n\n" + + "FINDINGS (" + strconv.Itoa(len(findings)) + "):\n\n" + numbered + "\n\n" + + "Return `keep_indices` (0-based) for the findings worth posting, and brief reasoning." +} diff --git a/go/internal/reasoners/adversary.go b/go/internal/reasoners/adversary.go new file mode 100644 index 0000000..a9f1d0c --- /dev/null +++ b/go/internal/reasoners/adversary.go @@ -0,0 +1,42 @@ +package reasoners + +import ( + "context" + "unicode/utf8" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/pr-af/go/internal/harnessx" + "github.com/Agent-Field/pr-af/go/internal/prompts" +) + +// AdversaryPhase ports adversary_phase: the skeptical challenger that +// confirms, challenges, or escalates each finding against ground-truth +// evidence. Skepticism escalates to "high" when the AI-generated confidence +// exceeds 0.5 (handled inside the prompt builder). +// +// Output keys (§B.2): results. Parse failure degrades to an empty list. +func AdversaryPhase(ctx context.Context, deps Deps, in AdversaryInput) (map[string]any, error) { + evMap := evidenceOMaps(in.EvidencePackages) + + // The builder embeds a file reference when the findings JSON (first 20 + // findings) exceeds 10000 characters and a repo path exists; the write is + // the reasoner's job. + summary := adversaryContext(in.Findings, evMap) + if utf8.RuneCountInString(summary) > 10000 && in.RepoPath != "" { + if _, err := writeContextFile(summary, "adversary_findings.json", in.RepoPath); err != nil { + return nil, err + } + } + + prompt := prompts.AdversaryPrompt(in.Findings, in.AIGeneratedConfidence, in.PrContext, in.RepoPath, evMap) + parsed, _, err := harnessx.Run[adversaryPhaseResult](ctx, deps.Harness, prompt, harness.Options{Cwd: in.RepoPath}) + if err != nil { + return nil, err + } + results, err := dumpSlice(parsed.Results) + if err != nil { + return nil, err + } + return map[string]any{"results": results}, nil +} diff --git a/go/internal/reasoners/anatomy.go b/go/internal/reasoners/anatomy.go new file mode 100644 index 0000000..27ba481 --- /dev/null +++ b/go/internal/reasoners/anatomy.go @@ -0,0 +1,70 @@ +package reasoners + +import ( + "context" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/pr-af/go/internal/blastradius" + "github.com/Agent-Field/pr-af/go/internal/diffengine" + "github.com/Agent-Field/pr-af/go/internal/harnessx" + "github.com/Agent-Field/pr-af/go/internal/prompts" + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// AnatomyPhase ports anatomy_phase: deterministic diff decomposition (parse, +// stats, clusters, blast radius) plus one .harness() call for the semantic +// narrative fields, merged into an AnatomyResult. +// +// Output keys (§B.2): files, clusters, blast_radius, dependency_graph, stats, +// pr_narrative, risk_surfaces, unrelated_changes, intent_gaps, context_notes. +// A harness parse failure degrades to empty semantic fields (Python falls back +// to a default _AnatomySemanticResult), never an error. +func AnatomyPhase(ctx context.Context, deps Deps, in AnatomyInput) (map[string]any, error) { + pr := in.PRData + + files := diffengine.ParseUnifiedDiff(pr.Diff) + if len(files) == 0 { + files = fileChangesFromMetadata(pr) + } + + stats := diffengine.ComputeDiffStats(files) + clusters := diffengine.ClusterChanges(files) + changedPaths := make([]string, len(files)) + for i, f := range files { + changedPaths[i] = f.Path + } + blastRadius := blastradius.ComputeBlastRadius(changedPaths, in.RepoPath) + + prompt := prompts.AnatomyPrompt( + in.Intake, pr.Title, pr.Description, pr.Labels, clusters, stats, len(blastRadius), files, + ) + parsed, _, err := harnessx.Run[anatomySemanticResult](ctx, deps.Harness, prompt, harness.Options{Cwd: in.RepoPath}) + if err != nil { + return nil, err + } + // Run's seeded default already matches Python's `_AnatomySemanticResult()` + // fallback on Parsed==nil; only nil slices from explicit JSON nulls need + // coercion so model_dump-parity emits [] rather than null. + semantic := *parsed + + anatomy := schemas.AnatomyResult{ + Files: files, + Clusters: clusters, + BlastRadius: orEmptyStrs(blastRadius), + DependencyGraph: map[string][]string{}, + Stats: stats, + PrNarrative: semantic.PrNarrative, + RiskSurfaces: orEmptyStrs(semantic.RiskSurfaces), + UnrelatedChanges: orEmptyStrs(semantic.UnrelatedChanges), + IntentGaps: orEmptyStrs(semantic.IntentGaps), + ContextNotes: semantic.ContextNotes, + } + if anatomy.Files == nil { + anatomy.Files = []schemas.FileChange{} + } + if anatomy.Clusters == nil { + anatomy.Clusters = []schemas.ChangeCluster{} + } + return dumpMap(anatomy) +} diff --git a/go/internal/reasoners/compound.go b/go/internal/reasoners/compound.go new file mode 100644 index 0000000..6808dd1 --- /dev/null +++ b/go/internal/reasoners/compound.go @@ -0,0 +1,95 @@ +package reasoners + +import ( + "context" + "unicode/utf8" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/pr-af/go/internal/harnessx" + "github.com/Agent-Field/pr-af/go/internal/prompts" +) + +// CompoundFinderPhase ports compound_finder_phase: given a small cluster of +// findings that might interact, synthesize NEW compound findings when the +// combined risk is real. +// +// Output keys (§B.2): findings. Fewer than two findings short-circuits to an +// empty list without a harness call; parse failure degrades to an empty list. +func CompoundFinderPhase(ctx context.Context, deps Deps, in CompoundFinderInput) (map[string]any, error) { + if len(in.ClusterFindings) < 2 { + return map[string]any{"findings": []any{}}, nil + } + + evMap := evidenceOMaps(in.EvidenceMap) + + // The builder embeds a file reference when the context JSON exceeds 10000 + // characters and a repo path exists; the write is the reasoner's job. The + // content is the exact json.dumps payload the builder renders inline + // otherwise (pinned by contexts_test.go). + summary := compoundFinderContext(in.ClusterFindings, evMap) + if utf8.RuneCountInString(summary) > 10000 && in.RepoPath != "" { + if _, err := writeContextFile(summary, "compound_cluster_findings.json", in.RepoPath); err != nil { + return nil, err + } + } + + prompt := prompts.CompoundFinderPrompt(in.ClusterFindings, in.RepoPath, evMap) + parsed, _, err := harnessx.Run[compoundResult](ctx, deps.Harness, prompt, harness.Options{Cwd: in.RepoPath}) + if err != nil { + return nil, err + } + result := *parsed + for i := range result.Findings { + result.Findings[i].Tags = orEmptyStrs(result.Findings[i].Tags) + result.Findings[i].ContributingFindings = orEmptyStrs(result.Findings[i].ContributingFindings) + } + findings, err := dumpSlice(result.Findings) + if err != nil { + return nil, err + } + return map[string]any{"findings": findings}, nil +} + +// CompoundDedupPhase ports compound_dedup_phase: one harness call that decides +// which compound findings are genuinely distinct insights. +// +// Output keys (§B.2): keep_indices, reasoning. One-or-fewer findings +// short-circuits to keep-everything; an empty/invalid index list (including +// the parse-failure fallback) also degrades to keep-everything. +func CompoundDedupPhase(ctx context.Context, deps Deps, in CompoundDedupInput) (map[string]any, error) { + n := len(in.CompoundFindings) + if n <= 1 { + return map[string]any{ + "keep_indices": rangeInts(n), + "reasoning": "single finding, no dedup needed", + }, nil + } + + prompt := prompts.CompoundDedupPrompt(in.CompoundFindings, in.IndividualFindingsSummary) + parsed, _, err := harnessx.Run[compoundDedupResult](ctx, deps.Harness, prompt, harness.Options{}) + if err != nil { + return nil, err + } + + valid := []int{} + for _, i := range parsed.KeepIndices { + if i >= 0 && i < n { + valid = append(valid, i) + } + } + if len(valid) == 0 { + // Fallback: keep all if the harness returned nothing valid. + valid = rangeInts(n) + } + return map[string]any{"keep_indices": valid, "reasoning": parsed.Reasoning}, nil +} + +// rangeInts reproduces Python's list(range(n)). +func rangeInts(n int) []int { + out := make([]int, n) + for i := range out { + out[i] = i + } + return out +} diff --git a/go/internal/reasoners/contexts_test.go b/go/internal/reasoners/contexts_test.go new file mode 100644 index 0000000..41a35da --- /dev/null +++ b/go/internal/reasoners/contexts_test.go @@ -0,0 +1,280 @@ +package reasoners + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/pr-af/go/internal/prompts" + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// The reasoners must WRITE .pr-af-context files whose content is byte-identical +// to the JSON/markdown blocks the prompt builders render inline when small. +// These tests pin the two copies of the assembly logic together: for each +// evidence/patch context, the reasoner-side string must appear verbatim inside +// the built prompt (inline branch), and the oversized branch must write exactly +// that string to the expected path. + +func sampleEvidence(title string) map[string]map[string]any { + return map[string]map[string]any{ + title: { + "finding_title": title, + "primary_code": "def f():\n return 1", + "caller_snippets": []any{"caller1()", "caller2()"}, + "cross_ref_snippets": []any{"xref1"}, + "diff_hunk": "+return 1", + "import_context": "imports os", + "related_code": "def g(): pass", + }, + } +} + +func sampleFindings() []schemas.ReviewFinding { + sugg := "fix it" + return []schemas.ReviewFinding{ + {Title: "F1", Severity: "critical", FilePath: "a.go", LineStart: 3, LineEnd: 5, + DimensionName: "Dim A", Body: "body1", Evidence: "ev1", Suggestion: &sugg, + Confidence: 0.9, Tags: []string{"security"}}, + {Title: "F2", Severity: "suggestion", FilePath: "b.go", LineStart: 7, + DimensionName: "Dim B", Body: "body2", Evidence: "ev2", Confidence: 0.6}, + } +} + +// Contract: compoundFinderContext == the summary CompoundFinderPrompt renders +// inline ("Cluster context:\n"). +func TestCompoundFinderContextMatchesBuilder(t *testing.T) { + findings := sampleFindings() + evMap := evidenceOMaps(sampleEvidence("F1")) + content := compoundFinderContext(findings, evMap) + prompt := prompts.CompoundFinderPrompt(findings, "", evMap) + if !strings.Contains(prompt, "Cluster context:\n"+content) { + t.Fatalf("assembled context diverges from the builder's inline rendering\ncontent: %s", content) + } + // Evidence must actually be embedded. + if !strings.Contains(content, `"evidence_package": {"primary_code": "def f():\n return 1"`) { + t.Fatalf("evidence_package missing/malformed: %s", content) + } + if !strings.Contains(content, `"cluster_evidence": {"F1": {"finding_title": "F1"`) { + t.Fatalf("cluster_evidence key order wrong: %s", content) + } +} + +// Contract: evidenceVerifierContext == the findings block +// EvidenceVerifierPrompt embeds directly. +func TestEvidenceVerifierContextMatchesBuilder(t *testing.T) { + findings := sampleFindings() + evMap := evidenceOMaps(sampleEvidence("F2")) + content := evidenceVerifierContext(findings, evMap) + prompt := prompts.EvidenceVerifierPrompt(findings, evMap, "ctx", "") + if !strings.Contains(prompt, "## Findings to Verify\n\n"+content) { + t.Fatalf("assembled context diverges from the builder\ncontent: %s", content) + } + if !strings.Contains(content, `"extracted_code": {"primary_code"`) { + t.Fatalf("extracted_code missing: %s", content) + } +} + +// Contract: adversaryContext == the summary AdversaryPrompt renders inline +// ("Findings with ground-truth evidence:\n"). +func TestAdversaryContextMatchesBuilder(t *testing.T) { + findings := sampleFindings() + evMap := evidenceOMaps(sampleEvidence("F1")) + content := adversaryContext(findings, evMap) + prompt := prompts.AdversaryPrompt(findings, 0.2, "", "", evMap) + if !strings.Contains(prompt, "Findings with ground-truth evidence:\n"+content) { + t.Fatalf("assembled context diverges from the builder\ncontent: %s", content) + } + if !strings.Contains(content, `"ground_truth": {"primary_code"`) { + t.Fatalf("ground_truth missing: %s", content) + } +} + +// Contract: renderPatches(filterPairs(...)) == the block DeepenFindingsPrompt / +// ExtractObligationsPrompt render inline ("## Changed code (diffs)\n\n"). +func TestRenderPatchesMatchesBuilders(t *testing.T) { + patches := OrderedPatches{ + {Key: "a.go", Val: "+added line"}, + {Key: "b.go", Val: ""}, + {Key: "c.go", Val: "-removed"}, + } + text := renderPatches(filterPairs(patches)) + deepen := prompts.DeepenFindingsPrompt([]prompts.StrPair(patches), nil, "", "") + if !strings.Contains(deepen, "## Changed code (diffs)\n\n"+text) { + t.Fatalf("deepen prompt diverges from renderPatches:\n%s", text) + } + obligations := prompts.ExtractObligationsPrompt([]prompts.StrPair(patches), "", "") + if !strings.Contains(obligations, "## Changed code (diffs)\n\n"+text) { + t.Fatalf("obligations prompt diverges from renderPatches:\n%s", text) + } +} + +// Contract: an oversized meta context is written to +// /.pr-af-context/meta__context.json with content identical to +// prompts.MetaContext, and the prompt references that path instead of the +// inline JSON. +func TestMetaSelectorWritesContextFile(t *testing.T) { + repo := t.TempDir() + // >8000 chars of diff patches pushes the context over the threshold. + bigPatch := strings.Repeat("+x\n", 4000) + in := MetaInput{ + Depth: "standard", + RepoPath: repo, + DiffPatches: OrderedPatches{{Key: "a.go", Val: bigPatch}}, + } + h := &mockHarness{parseFail: true} + if _, err := MetaSemantic(context.Background(), Deps{Harness: h}, in); err != nil { + t.Fatal(err) + } + path := filepath.Join(repo, ".pr-af-context", "meta_semantic_context.json") + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("context file not written: %v", err) + } + want := prompts.MetaContext(in.Intake, in.Anatomy, []prompts.StrPair(in.DiffPatches), "") + if string(b) != want { + t.Fatal("context file content diverges from prompts.MetaContext") + } + if !strings.Contains(h.gotPrompt, "Full analysis context written to: "+path) { + t.Fatal("prompt should reference the context file path") + } + if strings.Contains(h.gotPrompt, bigPatch) { + t.Fatal("oversized context must not be inlined") + } +} + +// Contract: review_dimension writes oversized target-file diff patches and +// primed code to their .pr-af-context files, with the prompt referencing them. +func TestReviewDimensionWritesContextFiles(t *testing.T) { + repo := t.TempDir() + bigPatch := strings.Repeat("+line\n", 1500) // > 6000 chars + bigPrimed := strings.Repeat("code\n", 1500) // > 6000 chars + h := &mockHarness{parseFail: true} + _, err := ReviewDimension(context.Background(), Deps{Harness: h}, ReviewDimensionInput{ + ReviewPrompt: "x", + TargetFiles: []string{"a.go"}, + RepoPath: repo, + MaxDepth: 2, + DiffPatches: map[string]string{"a.go": bigPatch, "other.go": "ignored"}, + PrimedCode: bigPrimed, + }) + if err != nil { + t.Fatal(err) + } + diffPath := filepath.Join(repo, ".pr-af-context", "review_dimension_diff_patches.md") + b, err := os.ReadFile(diffPath) + if err != nil { + t.Fatalf("diff patches file not written: %v", err) + } + if want := "### a.go\n```diff\n" + bigPatch + "\n```"; string(b) != want { + t.Fatal("diff patches file content diverges") + } + primedPath := filepath.Join(repo, ".pr-af-context", "review_dimension_primed_code.md") + pb, err := os.ReadFile(primedPath) + if err != nil { + t.Fatalf("primed code file not written: %v", err) + } + if string(pb) != bigPrimed { + t.Fatal("primed code file content diverges") + } + if !strings.Contains(h.gotPrompt, "Full diff patches written to: "+diffPath) { + t.Fatal("prompt should reference the diff patches file") + } + if !strings.Contains(h.gotPrompt, "context is written to: "+primedPath) { + t.Fatal("prompt should reference the primed code file") + } +} + +// Contract: oversized deepen/obligation diffs are written to their files with +// exactly the rendered patch text. +func TestDeepenAndObligationsWriteDiffFiles(t *testing.T) { + repo := t.TempDir() + bigPatch := strings.Repeat("+line\n", 2000) // > 9000 chars rendered + patches := OrderedPatches{{Key: "a.go", Val: bigPatch}} + want := renderPatches(filterPairs(patches)) + + h := &mockHarness{parseFail: true} + if _, err := DeepenFindings(context.Background(), Deps{Harness: h}, DeepenInput{ + DiffPatches: patches, RepoPath: repo, + }); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(filepath.Join(repo, ".pr-af-context", "deepen_diff.md")) + if err != nil { + t.Fatalf("deepen_diff.md not written: %v", err) + } + if string(b) != want { + t.Fatal("deepen_diff.md content diverges from renderPatches") + } + if !strings.Contains(h.gotPrompt, "Changed-code diffs written to: ") { + t.Fatal("deepen prompt should reference the diff file") + } + + h = &mockHarness{parseFail: true} + if _, err := ExtractObligations(context.Background(), Deps{Harness: h}, ExtractObligationsInput{ + DiffPatches: patches, RepoPath: repo, + }); err != nil { + t.Fatal(err) + } + b, err = os.ReadFile(filepath.Join(repo, ".pr-af-context", "obligations_diff.md")) + if err != nil { + t.Fatalf("obligations_diff.md not written: %v", err) + } + if string(b) != want { + t.Fatal("obligations_diff.md content diverges from renderPatches") + } +} + +// Contract: an oversized compound-finder context is written to +// compound_cluster_findings.json with content identical to the builder's +// summary, and the prompt references the path. +func TestCompoundFinderWritesContextFile(t *testing.T) { + repo := t.TempDir() + big := strings.Repeat("x", 12000) + ev := sampleEvidence("F1") + ev["F1"]["primary_code"] = big // > 10000 rendered + evMap := evidenceOMaps(ev) + findings := sampleFindings() + want := compoundFinderContext(findings, evMap) + + h := &mockHarness{parseFail: true} + if _, err := CompoundFinderPhase(context.Background(), Deps{Harness: h}, CompoundFinderInput{ + ClusterFindings: findings, RepoPath: repo, EvidenceMap: ev, + }); err != nil { + t.Fatal(err) + } + path := filepath.Join(repo, ".pr-af-context", "compound_cluster_findings.json") + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("compound context file not written: %v", err) + } + if string(b) != want { + t.Fatal("compound context file diverges from the builder summary") + } + if !strings.Contains(h.gotPrompt, "Cluster findings and evidence written to: "+path) { + t.Fatal("prompt should reference the context file") + } +} + +// Contract: evidenceOMaps preserves EvidencePackage.model_dump() key order and +// keeps `bool(ev_map)` truthiness (nil for empty). +func TestEvidenceOMapsOrderAndTruthiness(t *testing.T) { + if evidenceOMaps(nil) != nil || evidenceOMaps(map[string]map[string]any{}) != nil { + t.Fatal("empty evidence must convert to nil (has_evidence falsy)") + } + pkg := sampleEvidence("T")["T"] + pkg["verification"] = map[string]any{ + "verification_notes": "n", "verified": true, "actual_behavior": "a", + } + om := orderedOMap(pkg, evidenceKeyOrder) + got := prompts.PyJSON(om) + want := `{"finding_title": "T", "primary_code": "def f():\n return 1", ` + + `"caller_snippets": ["caller1()", "caller2()"], "cross_ref_snippets": ["xref1"], ` + + `"diff_hunk": "+return 1", "import_context": "imports os", "related_code": "def g(): pass", ` + + `"verification": {"verified": true, "actual_behavior": "a", "verification_notes": "n"}}` + if got != want { + t.Fatalf("evidence OMap rendering:\n got %s\n want %s", got, want) + } +} diff --git a/go/internal/reasoners/coverage.go b/go/internal/reasoners/coverage.go new file mode 100644 index 0000000..790296a --- /dev/null +++ b/go/internal/reasoners/coverage.go @@ -0,0 +1,26 @@ +package reasoners + +import ( + "context" + + "github.com/Agent-Field/pr-af/go/internal/prompts" + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// CoverageGate ports coverage_gate: one .ai() call that decides whether the +// review dimensions covered every change cluster. +// +// Output keys (§B.2): fully_covered, gap_descriptions, confident. Absent +// response keys land on the pydantic defaults through CoverageGate's seeded +// UnmarshalJSON (confident=true, gap_descriptions=[]); an AI transport or +// decode error propagates exactly as Python lets the exception escape. +func CoverageGate(ctx context.Context, deps Deps, in CoverageGateInput) (map[string]any, error) { + prompt := prompts.CoverageGatePrompt(in.Anatomy, in.ReviewedClusters, in.DimensionNamesReviewed) + + var gate schemas.CoverageGate + if err := aiStructured(ctx, deps.AI, prompt, prompts.CoverageGateSystem, schemas.CoverageGate{}, &gate); err != nil { + return nil, err + } + gate.GapDescriptions = orEmptyStrs(gate.GapDescriptions) + return dumpMap(gate) +} diff --git a/go/internal/reasoners/deepen.go b/go/internal/reasoners/deepen.go new file mode 100644 index 0000000..e41c8f7 --- /dev/null +++ b/go/internal/reasoners/deepen.go @@ -0,0 +1,47 @@ +package reasoners + +import ( + "context" + "unicode/utf8" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/pr-af/go/internal/harnessx" + "github.com/Agent-Field/pr-af/go/internal/prompts" +) + +// DeepenFindings ports deepen_findings (registered but DEAD on the live path): +// the literal ground-truth verification pass over the changed code. +// +// Output keys (§B.2): findings. No non-empty patches short-circuits to an +// empty list without a harness call; parse failure degrades to an empty list. +func DeepenFindings(ctx context.Context, deps Deps, in DeepenInput) (map[string]any, error) { + patches := filterPairs(in.DiffPatches) + if len(patches) == 0 { + return map[string]any{"findings": []any{}}, nil + } + + // The builder embeds a file reference when the rendered patches exceed + // 9000 characters and a repo path exists; the write is the reasoner's job. + patchesText := renderPatches(patches) + if utf8.RuneCountInString(patchesText) > 9000 && in.RepoPath != "" { + if _, err := writeContextFile(patchesText, "deepen_diff.md", in.RepoPath); err != nil { + return nil, err + } + } + + prompt := prompts.DeepenFindingsPrompt([]prompts.StrPair(in.DiffPatches), in.ExistingTitles, in.RepoPath, in.PrContext) + parsed, _, err := harnessx.Run[deepenResult](ctx, deps.Harness, prompt, harness.Options{Cwd: in.RepoPath}) + if err != nil { + return nil, err + } + result := *parsed + for i := range result.Findings { + result.Findings[i].Tags = orEmptyStrs(result.Findings[i].Tags) + } + findings, err := dumpSlice(result.Findings) + if err != nil { + return nil, err + } + return map[string]any{"findings": findings}, nil +} diff --git a/go/internal/reasoners/helpers.go b/go/internal/reasoners/helpers.go new file mode 100644 index 0000000..d7e1c66 --- /dev/null +++ b/go/internal/reasoners/helpers.go @@ -0,0 +1,552 @@ +package reasoners + +import ( + "os" + "path/filepath" + "sort" + "strings" + + "github.com/Agent-Field/pr-af/go/internal/prompts" + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// This file ports the deterministic (non-LLM) helpers from +// reasoners/harnesses.py. Where Python iterates a dict for its insertion +// order (ext_map, area_patterns), the Go port uses an ordered slice of pairs — +// iteration order is part of the observable behavior (first-match wins for +// languages, output order for areas). + +// autoDepth ports _auto_depth: complexity -> review depth, default "standard". +func autoDepth(complexity string) string { + switch complexity { + case "trivial": + return "quick" + case "standard": + return "standard" + case "complex", "massive": + return "deep" + default: + return "standard" + } +} + +// extPair keeps _language_from_path's ext_map insertion order (first suffix +// match wins, exactly like the Python dict iteration). +type extPair struct{ ext, language string } + +var extMap = []extPair{ + {".py", "python"}, + {".js", "javascript"}, + {".jsx", "javascript"}, + {".ts", "typescript"}, + {".tsx", "typescript"}, + {".go", "go"}, + {".rs", "rust"}, + {".java", "java"}, + {".rb", "ruby"}, + {".php", "php"}, + {".swift", "swift"}, + {".kt", "kotlin"}, + {".cs", "csharp"}, + {".cpp", "cpp"}, + {".c", "c"}, + {".sql", "sql"}, + {".html", "html"}, + {".css", "css"}, + {".md", "markdown"}, + {".json", "json"}, + {".yaml", "yaml"}, + {".yml", "yaml"}, + {".sh", "bash"}, +} + +// languageFromPath ports _language_from_path. +func languageFromPath(path string) string { + for _, p := range extMap { + if strings.HasSuffix(path, p.ext) { + return p.language + } + } + return "" +} + +// extractLanguages ports _extract_languages: the sorted set of non-empty +// languages across the PR's changed files. +func extractLanguages(pr schemas.GitHubPRData) []string { + set := map[string]struct{}{} + for _, changed := range pr.ChangedFiles { + if lang := languageFromPath(changed.Path); lang != "" { + set[lang] = struct{}{} + } + } + out := make([]string, 0, len(set)) + for lang := range set { + out = append(out, lang) + } + sort.Strings(out) + return out +} + +// writeContextFile ports _write_context_file: writes large context under +// /.pr-af-context/ for the harness subprocess to read, returning +// the file path. Errors propagate (Python lets the OSError escape). +func writeContextFile(content, name, repoPath string) (string, error) { + ctxDir := filepath.Join(repoPath, ".pr-af-context") + if err := os.MkdirAll(ctxDir, 0o777); err != nil { + return "", err + } + path := filepath.Join(ctxDir, name) + if err := os.WriteFile(path, []byte(content), 0o666); err != nil { + return "", err + } + return path, nil +} + +// areaPair keeps _extract_areas' area_patterns insertion order (the detected +// list is emitted in this order). +type areaPair struct { + area string + patterns []string +} + +var areaPatterns = []areaPair{ + {"auth", []string{"auth", "login", "oauth", "permission", "acl"}}, + {"database", []string{"db", "database", "migration", "schema", "model"}}, + {"api", []string{"api", "endpoint", "route", "controller", "handler"}}, + {"frontend", []string{"ui", "component", "view", "page", "css", "tsx", "jsx"}}, + {"tests", []string{"test", "spec", "fixture"}}, + {"ci", []string{".github", "workflow", "ci", "pipeline"}}, + {"config", []string{"config", "settings", ".env", "yaml", "toml", "json"}}, + {"infra", []string{"docker", "k8s", "terraform", "helm", "ansible"}}, + {"security", []string{"security", "crypto", "token", "jwt", "secret"}}, +} + +// extractAreas ports _extract_areas; empty detection falls back to +// ["application"]. +func extractAreas(paths []string) []string { + lowered := make([]string, len(paths)) + for i, p := range paths { + lowered[i] = strings.ToLower(p) + } + detected := []string{} + for _, ap := range areaPatterns { + hit := false + scan: + for _, path := range lowered { + for _, pattern := range ap.patterns { + if strings.Contains(path, pattern) { + hit = true + break scan + } + } + } + if hit { + detected = append(detected, ap.area) + } + } + if len(detected) == 0 { + detected = append(detected, "application") + } + return detected +} + +// riskSignals ports _risk_signals (signal order fixed by the Python code). +func riskSignals(pr schemas.GitHubPRData, areasTouched []string, filesChanged int) []string { + signals := []string{} + has := func(area string) bool { + for _, a := range areasTouched { + if a == area { + return true + } + } + return false + } + if has("security") || has("auth") { + signals = append(signals, "touches authentication or security-sensitive paths") + } + if has("database") { + signals = append(signals, "modifies data model or schema-affecting code") + } + if has("api") { + signals = append(signals, "changes API surface or request/response behavior") + } + if filesChanged >= 25 { + signals = append(signals, "large change footprint across many files") + } + configChange := false + for _, cf := range pr.ChangedFiles { + if strings.HasSuffix(cf.Path, ".yml") || strings.HasSuffix(cf.Path, ".yaml") || + strings.HasSuffix(cf.Path, ".toml") || strings.HasSuffix(cf.Path, ".json") { + configChange = true + break + } + } + if configChange { + signals = append(signals, "includes configuration changes") + } + testChange := false + for _, cf := range pr.ChangedFiles { + if strings.Contains(strings.ToLower(cf.Path), "test") { + testChange = true + break + } + } + if testChange { + signals = append(signals, "test behavior updated") + } + return signals +} + +// aiGeneratedPatterns is _ai_generated_confidence's substring pattern tuple. +var aiGeneratedPatterns = []string{ + "generated by", + "co-authored-by: claude", + "co-authored-by: gpt", + "ai-assisted", + "autogenerated", + "chatgpt", + "copilot", + "claude", + "llm", +} + +// aiGeneratedConfidence ports _ai_generated_confidence: the fraction of +// non-empty text blobs (title, description, commit messages) that carry an +// AI-generation marker, capped at 1.0. Zero non-empty blobs -> 0.0. +func aiGeneratedConfidence(pr schemas.GitHubPRData) float64 { + signals := 0 + evidence := 0 + blobs := append([]string{pr.Title, pr.Description}, pr.CommitMessages...) + for _, blob := range blobs { + if blob == "" { + continue + } + evidence++ + lower := strings.ToLower(blob) + for _, pattern := range aiGeneratedPatterns { + if strings.Contains(lower, pattern) { + signals++ + break + } + } + } + if evidence == 0 { + return 0.0 + } + ratio := float64(signals) / float64(evidence) + if ratio > 1.0 { + return 1.0 + } + return ratio +} + +// prSummary ports _pr_summary: the stripped description, else +// ". Files changed: <n>.". +func prSummary(pr schemas.GitHubPRData) string { + description := strings.TrimSpace(pr.Description) + if description != "" { + return description + } + return pr.Title + ". Files changed: " + itoa(len(pr.ChangedFiles)) + "." +} + +// fileChangesFromMetadata ports _file_changes_from_metadata: FileChange stubs +// built from the GitHub /files metadata when the diff text yields nothing. +func fileChangesFromMetadata(pr schemas.GitHubPRData) []schemas.FileChange { + out := make([]schemas.FileChange, 0, len(pr.ChangedFiles)) + for _, changed := range pr.ChangedFiles { + out = append(out, schemas.FileChange{ + Path: changed.Path, + Status: changed.Status, + Language: languageFromPath(changed.Path), + LinesAdded: changed.Additions, + LinesRemoved: changed.Deletions, + Hunks: []schemas.Hunk{}, + }) + } + return out +} + +// --------------------------------------------------------------------------- +// Small Python-parity utilities shared by the context assemblies below. These +// intentionally mirror the (package-private) equivalents inside prompts — +// contexts_test.go pins the two copies together by asserting each assembled +// context string appears verbatim inside the corresponding built prompt. +// --------------------------------------------------------------------------- + +func itoa(n int) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + var buf [20]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} + +// runeCap reproduces Python's s[:n] (code points, not bytes). +func runeCap(s string, n int) string { + if n < 0 { + n = 0 + } + runes := []rune(s) + if len(runes) <= n { + return s + } + return string(runes[:n]) +} + +// capN reproduces xs[:n]. +func capN[T any](xs []T, n int) []T { + if len(xs) <= n { + return xs + } + return xs[:n] +} + +// orEmptyStrs reproduces Python's `x or []` truthiness for string lists. +func orEmptyStrs(xs []string) []string { + if xs == nil { + return []string{} + } + return xs +} + +// filterPairs ports `{k: v for k, v in (diff_patches or {}).items() if v}`: +// drops empty values, dedups keys (last value wins, first position kept). Must +// stay in lockstep with prompts.filterPatches. +func filterPairs(patches []prompts.StrPair) []prompts.StrPair { + idx := map[string]int{} + out := []prompts.StrPair{} + for _, p := range patches { + if p.Val == "" { + continue + } + if i, ok := idx[p.Key]; ok { + out[i].Val = p.Val + continue + } + idx[p.Key] = len(out) + out = append(out, p) + } + return out +} + +// renderPatches ports "\n\n".join(f"### {p}\n```diff\n{d}\n```") over the +// first 20 (already filtered) patches. Must stay in lockstep with +// prompts.renderPatchesText. +func renderPatches(patches []prompts.StrPair) string { + patches = capN(patches, 20) + parts := make([]string, len(patches)) + for i, p := range patches { + parts[i] = "### " + p.Key + "\n```diff\n" + p.Val + "\n```" + } + return strings.Join(parts, "\n\n") +} + +// --------------------------------------------------------------------------- +// Evidence-package plumbing. The reasoners accept evidence packages exactly as +// Python does — dict[str, dict], i.e. EvidencePackage.model_dump() maps (plus +// the orchestrator-injected "verification" sub-dict on the adversary path) — +// and convert them to the insertion-ordered *prompts.OMap the builders need. +// --------------------------------------------------------------------------- + +// evidenceKeyOrder is EvidencePackage.model_dump()'s field order, with the +// orchestrator's appended "verification" key last — the insertion order the +// Python dicts carry on the live path. +var evidenceKeyOrder = []string{ + "finding_title", + "primary_code", + "caller_snippets", + "cross_ref_snippets", + "diff_hunk", + "import_context", + "related_code", + "verification", +} + +// verificationKeyOrder is the orchestrator's verification sub-dict insertion +// order (orch.py _run_review_layer). +var verificationKeyOrder = []string{"verified", "actual_behavior", "verification_notes"} + +// evidenceOMaps converts a Python-shaped evidence map (dict[str, dict]) to the +// ordered form the prompt builders consume. Returns nil for a nil/empty input +// so `bool(ev_map)` truthiness (adversary's has_evidence) is preserved. +func evidenceOMaps(packages map[string]map[string]any) map[string]*prompts.OMap { + if len(packages) == 0 { + return nil + } + out := make(map[string]*prompts.OMap, len(packages)) + for title, pkg := range packages { + out[title] = orderedOMap(pkg, evidenceKeyOrder) + } + return out +} + +// orderedOMap builds an *prompts.OMap from m, emitting known keys in keyOrder +// first, then any remaining keys sorted (deterministic; unreachable on the +// live path, where the dicts are exactly EvidencePackage dumps). +func orderedOMap(m map[string]any, keyOrder []string) *prompts.OMap { + o := prompts.NewOMap() + seen := map[string]struct{}{} + for _, k := range keyOrder { + if v, ok := m[k]; ok { + o.Set(k, omapValue(k, v)) + seen[k] = struct{}{} + } + } + rest := make([]string, 0) + for k := range m { + if _, ok := seen[k]; !ok { + rest = append(rest, k) + } + } + sort.Strings(rest) + for _, k := range rest { + o.Set(k, omapValue(k, m[k])) + } + return o +} + +// omapValue normalizes a JSON-round-tripped value for OMap storage: []any of +// strings becomes []string (what GetStrSlice reads), nested maps become +// ordered OMaps (verification keys in orchestrator order). +func omapValue(key string, v any) any { + switch x := v.(type) { + case []any: + strs := make([]string, 0, len(x)) + for _, e := range x { + s, ok := e.(string) + if !ok { + return x // mixed list: keep as-is for PyJSON's generic path + } + strs = append(strs, s) + } + return strs + case map[string]any: + if key == "verification" { + return orderedOMap(x, verificationKeyOrder) + } + return orderedOMap(x, nil) + default: + return v + } +} + +// --------------------------------------------------------------------------- +// Context assemblies for the three evidence-based reasoners. Each reproduces +// the json.dumps payload its Python reasoner computes (and the Go prompt +// builder recomputes internally), so the reasoner can (a) decide the +// write-to-file branch on the same string and (b) write byte-identical file +// content. contexts_test.go asserts each assembly matches the builder's +// inline rendering. +// --------------------------------------------------------------------------- + +// compoundFinderContext reproduces compound_finder_phase's findings_summary: +// json.dumps({"cluster_findings": [...], "cluster_evidence": {...}}). +func compoundFinderContext(findings []schemas.ReviewFinding, evidenceMap map[string]*prompts.OMap) string { + withContext := make([]*prompts.OMap, 0, 4) + for _, f := range capN(findings, 4) { + entry := prompts.NewOMap(). + Set("title", f.Title). + Set("severity", string(f.Severity)). + Set("file_path", f.FilePath). + Set("line_start", f.LineStart). + Set("line_end", f.LineEnd). + Set("dimension_name", f.DimensionName). + Set("body", f.Body). + Set("evidence", f.Evidence). + Set("suggestion", f.Suggestion). + Set("tags", orEmptyStrs(f.Tags)) + if ev, ok := evidenceMap[f.Title]; ok && ev != nil { + entry.Set("evidence_package", prompts.NewOMap(). + Set("primary_code", runeCap(ev.GetStr("primary_code", ""), 4000)). + Set("import_context", runeCap(ev.GetStr("import_context", ""), 2500)). + Set("caller_snippets", capN(ev.GetStrSlice("caller_snippets"), 5)). + Set("related_code", runeCap(ev.GetStr("related_code", ""), 2500)). + Set("cross_ref_snippets", capN(ev.GetStrSlice("cross_ref_snippets"), 4))) + } + withContext = append(withContext, entry) + } + clusterEvidence := prompts.NewOMap() + seen := map[string]bool{} + for _, f := range findings { + if seen[f.Title] { + continue + } + seen[f.Title] = true + if ev, ok := evidenceMap[f.Title]; ok { + clusterEvidence.Set(f.Title, ev) + } + } + payload := prompts.NewOMap(). + Set("cluster_findings", withContext). + Set("cluster_evidence", clusterEvidence) + return prompts.PyJSON(payload) +} + +// evidenceVerifierContext reproduces evidence_verifier's findings_text. +func evidenceVerifierContext(findings []schemas.ReviewFinding, evidenceMap map[string]*prompts.OMap) string { + payload := make([]*prompts.OMap, 0, len(findings)) + for _, f := range findings { + entry := prompts.NewOMap(). + Set("title", f.Title). + Set("severity", string(f.Severity)). + Set("file_path", f.FilePath). + Set("line_start", f.LineStart). + Set("dimension_name", f.DimensionName). + Set("body", f.Body). + Set("evidence", f.Evidence). + Set("confidence", f.Confidence) + if ev, ok := evidenceMap[f.Title]; ok && ev != nil { + entry.Set("extracted_code", prompts.NewOMap(). + Set("primary_code", runeCap(ev.GetStr("primary_code", ""), 4000)). + Set("caller_snippets", capN(ev.GetStrSlice("caller_snippets"), 5)). + Set("diff_hunk", runeCap(ev.GetStr("diff_hunk", ""), 2000)). + Set("import_context", ev.GetStr("import_context", "")). + Set("related_code", runeCap(ev.GetStr("related_code", ""), 2000)). + Set("cross_ref_snippets", capN(ev.GetStrSlice("cross_ref_snippets"), 3))) + } + payload = append(payload, entry) + } + return prompts.PyJSON(payload) +} + +// adversaryContext reproduces adversary_phase's findings_summary (over the +// first 20 findings). +func adversaryContext(findings []schemas.ReviewFinding, evidenceMap map[string]*prompts.OMap) string { + withEvidence := make([]*prompts.OMap, 0, 20) + for _, f := range capN(findings, 20) { + entry := prompts.NewOMap(). + Set("title", f.Title). + Set("severity", string(f.Severity)). + Set("file_path", f.FilePath). + Set("dimension_name", f.DimensionName). + Set("body", f.Body). + Set("evidence", f.Evidence). + Set("suggestion", f.Suggestion). + Set("confidence", f.Confidence) + if ev, ok := evidenceMap[f.Title]; ok && ev != nil { + entry.Set("ground_truth", prompts.NewOMap(). + Set("primary_code", runeCap(ev.GetStr("primary_code", ""), 3000)). + Set("caller_snippets", capN(ev.GetStrSlice("caller_snippets"), 5)). + Set("diff_hunk", runeCap(ev.GetStr("diff_hunk", ""), 2000)). + Set("import_context", ev.GetStr("import_context", "")). + Set("related_code", runeCap(ev.GetStr("related_code", ""), 2000))) + } + withEvidence = append(withEvidence, entry) + } + return prompts.PyJSON(withEvidence) +} diff --git a/go/internal/reasoners/helpers_test.go b/go/internal/reasoners/helpers_test.go new file mode 100644 index 0000000..a4fc49a --- /dev/null +++ b/go/internal/reasoners/helpers_test.go @@ -0,0 +1,188 @@ +package reasoners + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/Agent-Field/pr-af/go/internal/prompts" + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// Golden values in this file were captured from the real Python helpers: +// +// PYTHONPATH=src <praf-venv>/bin/python — importing +// pr_af.reasoners.harnesses._auto_depth / _language_from_path / +// _extract_languages / _extract_areas / _risk_signals / +// _ai_generated_confidence / _pr_summary over the fixturePR() inputs. + +// Contract: _auto_depth mapping with "standard" as the default. +func TestAutoDepthGolden(t *testing.T) { + cases := map[string]string{ + "trivial": "quick", + "standard": "standard", + "complex": "deep", + "massive": "deep", + "": "standard", + "weird": "standard", + "quick": "standard", + } + for in, want := range cases { + if got := autoDepth(in); got != want { + t.Errorf("autoDepth(%q) = %q, want %q", in, got, want) + } + } +} + +// Contract: _language_from_path first-suffix-match semantics, including paths +// with no mapped extension. +func TestLanguageFromPathGolden(t *testing.T) { + paths := []string{ + "src/auth/login.py", "db/migrations/0001_init.sql", "web/App.tsx", + "cmd/main.go", "config/settings.yml", "tests/test_login.py", + "Dockerfile", "README.md", "script.unknownext", + } + want := []string{"python", "sql", "typescript", "go", "yaml", "python", "", "markdown", ""} + for i, p := range paths { + if got := languageFromPath(p); got != want[i] { + t.Errorf("languageFromPath(%q) = %q, want %q", p, got, want[i]) + } + } +} + +// Contract: _extract_languages is the sorted unique set. +func TestExtractLanguagesGolden(t *testing.T) { + want := []string{"go", "markdown", "python", "sql", "typescript", "yaml"} + if got := extractLanguages(fixturePR()); !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +// Contract: _extract_areas emits areas in area_patterns declaration order; +// no hit falls back to ["application"]. +func TestExtractAreasGolden(t *testing.T) { + paths := []string{ + "src/auth/login.py", "db/migrations/0001_init.sql", "web/App.tsx", + "cmd/main.go", "config/settings.yml", "tests/test_login.py", + "Dockerfile", "README.md", "script.unknownext", + } + want := []string{"auth", "database", "frontend", "tests", "config", "infra"} + if got := extractAreas(paths); !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } + if got := extractAreas(nil); !reflect.DeepEqual(got, []string{"application"}) { + t.Fatalf("empty: got %v", got) + } + if got := extractAreas([]string{"lib/util.go"}); !reflect.DeepEqual(got, []string{"application"}) { + t.Fatalf("plain: got %v", got) + } +} + +// Contract: _risk_signals ordering and triggers (auth/database areas, config +// suffixes, test paths; >=25 files trips the footprint signal). +func TestRiskSignalsGolden(t *testing.T) { + pr := fixturePR() + areas := extractAreas([]string{ + "src/auth/login.py", "db/migrations/0001_init.sql", "web/App.tsx", + "cmd/main.go", "config/settings.yml", "tests/test_login.py", + "Dockerfile", "README.md", "script.unknownext", + }) + want := []string{ + "touches authentication or security-sensitive paths", + "modifies data model or schema-affecting code", + "includes configuration changes", + "test behavior updated", + } + if got := riskSignals(pr, areas, len(pr.ChangedFiles)); !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } + + // 25 plain .go files: only the footprint signal (Python golden). + files := make([]schemas.ChangedFile, 25) + for i := range files { + files[i] = schemas.ChangedFile{Path: "pkg/modx/filex.go"} + } + big := schemas.GitHubPRData{Title: "big", ChangedFiles: files} + bigAreas := extractAreas([]string{"pkg/modx/filex.go"}) + want = []string{"large change footprint across many files"} + if got := riskSignals(big, bigAreas, 25); !reflect.DeepEqual(got, want) { + t.Fatalf("big: got %v, want %v", got, want) + } +} + +// Contract: _ai_generated_confidence = marked-blobs / non-empty-blobs, capped; +// zero evidence -> 0.0. +func TestAIGeneratedConfidenceGolden(t *testing.T) { + if got := aiGeneratedConfidence(fixturePR()); got != 0.2 { // 1 of 5 blobs + t.Fatalf("got %v, want 0.2", got) + } + if got := aiGeneratedConfidence(schemas.GitHubPRData{}); got != 0.0 { + t.Fatalf("empty: got %v, want 0.0", got) + } + all := schemas.GitHubPRData{Title: "Generated by Claude", Description: "chatgpt did this"} + if got := aiGeneratedConfidence(all); got != 1.0 { + t.Fatalf("all-marked: got %v, want 1.0", got) + } +} + +// Contract: _pr_summary strips the description; empty description falls back +// to "<title>. Files changed: <n>.". +func TestPrSummaryGolden(t *testing.T) { + if got := prSummary(fixturePR()); got != "Implements OAuth2 login flow." { + t.Fatalf("got %q", got) + } + pr := fixturePR() + pr.Description = "" + pr.Title = "T" + if got := prSummary(pr); got != "T. Files changed: 9." { + t.Fatalf("got %q", got) + } +} + +// Contract: _write_context_file creates <repo>/.pr-af-context/<name> with the +// exact content and returns its path. +func TestWriteContextFile(t *testing.T) { + repo := t.TempDir() + path, err := writeContextFile("hello context", "meta_semantic_context.json", repo) + if err != nil { + t.Fatal(err) + } + want := filepath.Join(repo, ".pr-af-context", "meta_semantic_context.json") + if path != want { + t.Fatalf("path = %q, want %q", path, want) + } + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(b) != "hello context" { + t.Fatalf("content = %q", b) + } +} + +// Contract: _file_changes_from_metadata maps ChangedFile metadata onto +// FileChange stubs with empty hunks. +func TestFileChangesFromMetadata(t *testing.T) { + got := fileChangesFromMetadata(fixturePR()) + if len(got) != 9 { + t.Fatalf("len = %d", len(got)) + } + first := got[0] + if first.Path != "src/auth/login.py" || first.Language != "python" || + first.LinesAdded != 3 || first.LinesRemoved != 1 || len(first.Hunks) != 0 || first.Hunks == nil { + t.Fatalf("got %+v", first) + } +} + +// Contract: filterPairs drops empty values and dedups keys (last value wins, +// first position kept) — dict-comprehension semantics. +func TestFilterPairs(t *testing.T) { + in := []prompts.StrPair{ + {Key: "a", Val: "1"}, {Key: "b", Val: ""}, {Key: "c", Val: "3"}, {Key: "a", Val: "9"}, + } + want := []prompts.StrPair{{Key: "a", Val: "9"}, {Key: "c", Val: "3"}} + if got := filterPairs(in); !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} diff --git a/go/internal/reasoners/inputs.go b/go/internal/reasoners/inputs.go new file mode 100644 index 0000000..7281f3a --- /dev/null +++ b/go/internal/reasoners/inputs.go @@ -0,0 +1,246 @@ +package reasoners + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/Agent-Field/pr-af/go/internal/prompts" + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// This file declares the typed inputs for each reasoner (design §C.6 "typed +// input equivalent"). Field names and json tags are the exact Python keyword +// parameter names, so node/register.go can afx.Bind a CP request body straight +// into them; structs whose Python signature carries non-zero defaults seed +// them in UnmarshalJSON. The orchestrator constructs these values directly +// for the in-process calls. + +// OrderedPatches is a diff_patches dict with preserved insertion order — +// Python's dict[str, str] whose ordering the meta/deepen/obligation prompts +// render. It JSON-round-trips as an object, decoding in document order. +type OrderedPatches []prompts.StrPair + +// UnmarshalJSON decodes a JSON object into ordered pairs, preserving the +// document's key order (duplicate keys: last value wins, first position kept — +// matching Python's dict literal semantics). null decodes to nil. +func (p *OrderedPatches) UnmarshalJSON(b []byte) error { + dec := json.NewDecoder(bytes.NewReader(b)) + tok, err := dec.Token() + if err != nil { + return err + } + if tok == nil { // JSON null + *p = nil + return nil + } + if d, ok := tok.(json.Delim); !ok || d != '{' { + return fmt.Errorf("reasoners: diff_patches must be an object, got %v", tok) + } + out := OrderedPatches{} + idx := map[string]int{} + for dec.More() { + kTok, err := dec.Token() + if err != nil { + return err + } + key, ok := kTok.(string) + if !ok { + return fmt.Errorf("reasoners: diff_patches key %v is not a string", kTok) + } + var val string + if err := dec.Decode(&val); err != nil { + return fmt.Errorf("reasoners: diff_patches[%q]: %w", key, err) + } + if i, dup := idx[key]; dup { + out[i].Val = val + continue + } + idx[key] = len(out) + out = append(out, prompts.StrPair{Key: key, Val: val}) + } + if _, err := dec.Token(); err != nil { // consume '}' + return err + } + *p = out + return nil +} + +// MarshalJSON renders the pairs as a JSON object in slice order. +func (p OrderedPatches) MarshalJSON() ([]byte, error) { + if p == nil { + return []byte("null"), nil + } + var buf bytes.Buffer + buf.WriteByte('{') + for i, pair := range p { + if i > 0 { + buf.WriteByte(',') + } + k, err := json.Marshal(pair.Key) + if err != nil { + return nil, err + } + v, err := json.Marshal(pair.Val) + if err != nil { + return nil, err + } + buf.Write(k) + buf.WriteByte(':') + buf.Write(v) + } + buf.WriteByte('}') + return buf.Bytes(), nil +} + +// IntakeInput mirrors intake_phase(pr_data, depth="standard"). +type IntakeInput struct { + PRData schemas.GitHubPRData `json:"pr_data"` + Depth string `json:"depth"` +} + +func (i *IntakeInput) UnmarshalJSON(b []byte) error { + *i = IntakeInput{Depth: "standard"} + type alias IntakeInput + return json.Unmarshal(b, (*alias)(i)) +} + +// AnatomyInput mirrors anatomy_phase(pr_data, intake, repo_path=""). +type AnatomyInput struct { + PRData schemas.GitHubPRData `json:"pr_data"` + Intake schemas.IntakeResult `json:"intake"` + RepoPath string `json:"repo_path"` +} + +// PlanningInput mirrors planning_phase(intake, anatomy, depth="standard", +// hints=None). +type PlanningInput struct { + Intake schemas.IntakeResult `json:"intake"` + Anatomy schemas.AnatomyResult `json:"anatomy"` + Depth string `json:"depth"` + Hints []string `json:"hints"` +} + +func (p *PlanningInput) UnmarshalJSON(b []byte) error { + *p = PlanningInput{Depth: "standard"} + type alias PlanningInput + return json.Unmarshal(b, (*alias)(p)) +} + +// MetaInput mirrors the shared signature of meta_semantic / meta_mechanical / +// meta_systemic(intake, anatomy, depth="standard", repo_path="", +// diff_patches=None, reviewer_feedback=""). +type MetaInput struct { + Intake schemas.IntakeResult `json:"intake"` + Anatomy schemas.AnatomyResult `json:"anatomy"` + Depth string `json:"depth"` + RepoPath string `json:"repo_path"` + DiffPatches OrderedPatches `json:"diff_patches"` + ReviewerFeedback string `json:"reviewer_feedback"` +} + +func (m *MetaInput) UnmarshalJSON(b []byte) error { + *m = MetaInput{Depth: "standard"} + type alias MetaInput + return json.Unmarshal(b, (*alias)(m)) +} + +// ReviewDimensionInput mirrors review_dimension's long keyword signature. +// DiffPatches is a plain map — the prompt renders patches in TargetFiles +// order, so dict ordering is immaterial here. +type ReviewDimensionInput struct { + ReviewPrompt string `json:"review_prompt"` + TargetFiles []string `json:"target_files"` + ContextFiles []string `json:"context_files"` + RepoPath string `json:"repo_path"` + CurrentDepth int `json:"current_depth"` + MaxDepth int `json:"max_depth"` + PrNarrative string `json:"pr_narrative"` + RiskSurfaces []string `json:"risk_surfaces"` + IntakeSummary string `json:"intake_summary"` + DiffPatches map[string]string `json:"diff_patches"` + AllDimensionNames []string `json:"all_dimension_names"` + ReviewerFeedback string `json:"reviewer_feedback"` + PrimedCode string `json:"primed_code"` +} + +func (r *ReviewDimensionInput) UnmarshalJSON(b []byte) error { + *r = ReviewDimensionInput{MaxDepth: 2} + type alias ReviewDimensionInput + return json.Unmarshal(b, (*alias)(r)) +} + +// CompoundFinderInput mirrors compound_finder_phase(cluster_findings, +// repo_path="", evidence_map=None). EvidenceMap values are +// EvidencePackage.model_dump()-shaped dicts keyed by finding title. +type CompoundFinderInput struct { + ClusterFindings []schemas.ReviewFinding `json:"cluster_findings"` + RepoPath string `json:"repo_path"` + EvidenceMap map[string]map[string]any `json:"evidence_map"` +} + +// PostWorthinessInput mirrors post_worthiness_gate(findings). The live path +// passes ReviewFinding dumps. +type PostWorthinessInput struct { + Findings []schemas.ReviewFinding `json:"findings"` +} + +// CompoundDedupInput mirrors compound_dedup_phase(compound_findings, +// individual_findings_summary=""). +type CompoundDedupInput struct { + CompoundFindings []schemas.ReviewFinding `json:"compound_findings"` + IndividualFindingsSummary string `json:"individual_findings_summary"` +} + +// EvidenceVerifierInput mirrors evidence_verifier(findings, +// evidence_packages=None, pr_context="", repo_path=""). +type EvidenceVerifierInput struct { + Findings []schemas.ReviewFinding `json:"findings"` + EvidencePackages map[string]map[string]any `json:"evidence_packages"` + PrContext string `json:"pr_context"` + RepoPath string `json:"repo_path"` +} + +// AdversaryInput mirrors adversary_phase(findings, ai_generated_confidence=0.0, +// pr_context="", repo_path="", evidence_packages=None). +type AdversaryInput struct { + Findings []schemas.ReviewFinding `json:"findings"` + AIGeneratedConfidence float64 `json:"ai_generated_confidence"` + PrContext string `json:"pr_context"` + RepoPath string `json:"repo_path"` + EvidencePackages map[string]map[string]any `json:"evidence_packages"` +} + +// DeepenInput mirrors deepen_findings(diff_patches=None, existing_titles=None, +// repo_path="", pr_context=""). +type DeepenInput struct { + DiffPatches OrderedPatches `json:"diff_patches"` + ExistingTitles []string `json:"existing_titles"` + RepoPath string `json:"repo_path"` + PrContext string `json:"pr_context"` +} + +// ExtractObligationsInput mirrors extract_obligations(diff_patches=None, +// repo_path="", pr_context=""). +type ExtractObligationsInput struct { + DiffPatches OrderedPatches `json:"diff_patches"` + RepoPath string `json:"repo_path"` + PrContext string `json:"pr_context"` +} + +// VerifyObligationInput mirrors verify_obligation(obligation, repo_path=""). +// Obligation is the raw dict (an ExtractObligations output element); the +// reasoner validates it into the private obligation struct exactly as Python +// runs _Obligation.model_validate. +type VerifyObligationInput struct { + Obligation map[string]any `json:"obligation"` + RepoPath string `json:"repo_path"` +} + +// CoverageGateInput mirrors coverage_gate(anatomy, reviewed_clusters, +// dimension_names_reviewed=None). +type CoverageGateInput struct { + Anatomy schemas.AnatomyResult `json:"anatomy"` + ReviewedClusters []string `json:"reviewed_clusters"` + DimensionNamesReviewed []string `json:"dimension_names_reviewed"` +} diff --git a/go/internal/reasoners/inputs_test.go b/go/internal/reasoners/inputs_test.go new file mode 100644 index 0000000..672bf84 --- /dev/null +++ b/go/internal/reasoners/inputs_test.go @@ -0,0 +1,118 @@ +package reasoners + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/Agent-Field/pr-af/go/internal/prompts" +) + +// Contract: OrderedPatches decodes a JSON object preserving document key order +// (Python dict semantics), with duplicate keys last-value-wins first-position, +// and null -> nil. +func TestOrderedPatchesUnmarshal(t *testing.T) { + var p OrderedPatches + if err := json.Unmarshal([]byte(`{"z.go":"1","a.go":"2","m.go":"3","z.go":"9"}`), &p); err != nil { + t.Fatal(err) + } + want := OrderedPatches{ + {Key: "z.go", Val: "9"}, {Key: "a.go", Val: "2"}, {Key: "m.go", Val: "3"}, + } + if !reflect.DeepEqual(p, want) { + t.Fatalf("got %v, want %v", p, want) + } + + var nilP OrderedPatches + if err := json.Unmarshal([]byte(`null`), &nilP); err != nil { + t.Fatal(err) + } + if nilP != nil { + t.Fatalf("null should decode to nil, got %v", nilP) + } + + if err := json.Unmarshal([]byte(`[1,2]`), &p); err == nil { + t.Fatal("array should be rejected") + } +} + +// Contract: OrderedPatches marshals back to an object in slice order. +func TestOrderedPatchesMarshalRoundTrip(t *testing.T) { + p := OrderedPatches{{Key: "b.go", Val: "x"}, {Key: "a.go", Val: "y\n"}} + b, err := json.Marshal(p) + if err != nil { + t.Fatal(err) + } + if string(b) != `{"b.go":"x","a.go":"y\n"}` { + t.Fatalf("got %s", b) + } + var back OrderedPatches + if err := json.Unmarshal(b, &back); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(back, p) { + t.Fatalf("round trip: %v != %v", back, p) + } +} + +// Contract: input structs seed their Python keyword defaults on decode +// (depth="standard", max_depth=2) while present keys override. +func TestInputDefaultSeeding(t *testing.T) { + var intake IntakeInput + if err := json.Unmarshal([]byte(`{}`), &intake); err != nil { + t.Fatal(err) + } + if intake.Depth != "standard" { + t.Fatalf("IntakeInput.Depth = %q", intake.Depth) + } + if err := json.Unmarshal([]byte(`{"depth":"auto"}`), &intake); err != nil { + t.Fatal(err) + } + if intake.Depth != "auto" { + t.Fatalf("override lost: %q", intake.Depth) + } + + var planning PlanningInput + if err := json.Unmarshal([]byte(`{}`), &planning); err != nil { + t.Fatal(err) + } + if planning.Depth != "standard" { + t.Fatalf("PlanningInput.Depth = %q", planning.Depth) + } + + var meta MetaInput + if err := json.Unmarshal([]byte(`{"diff_patches":{"b.go":"2","a.go":"1"}}`), &meta); err != nil { + t.Fatal(err) + } + if meta.Depth != "standard" { + t.Fatalf("MetaInput.Depth = %q", meta.Depth) + } + wantPatches := OrderedPatches{{Key: "b.go", Val: "2"}, {Key: "a.go", Val: "1"}} + if !reflect.DeepEqual(meta.DiffPatches, wantPatches) { + t.Fatalf("MetaInput.DiffPatches = %v", meta.DiffPatches) + } + + var rd ReviewDimensionInput + if err := json.Unmarshal([]byte(`{"review_prompt":"p"}`), &rd); err != nil { + t.Fatal(err) + } + if rd.MaxDepth != 2 || rd.CurrentDepth != 0 { + t.Fatalf("ReviewDimensionInput seeds: %+v", rd) + } + if err := json.Unmarshal([]byte(`{"max_depth":0}`), &rd); err != nil { + t.Fatal(err) + } + if rd.MaxDepth != 0 { // present zero must override the seed + t.Fatalf("present max_depth=0 lost: %+v", rd) + } +} + +// Contract: prompts.StrPair and OrderedPatches interconvert by simple slice +// conversion (the builders take []StrPair). +func TestOrderedPatchesConvertsToStrPairs(t *testing.T) { + p := OrderedPatches{{Key: "a", Val: "1"}} + pairs := []prompts.StrPair(p) + if len(pairs) != 1 || pairs[0].Key != "a" { + t.Fatalf("got %v", pairs) + } +} diff --git a/go/internal/reasoners/intake.go b/go/internal/reasoners/intake.go new file mode 100644 index 0000000..8d21ac7 --- /dev/null +++ b/go/internal/reasoners/intake.go @@ -0,0 +1,70 @@ +package reasoners + +import ( + "context" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/pr-af/go/internal/harnessx" + "github.com/Agent-Field/pr-af/go/internal/prompts" + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// IntakePhase ports intake_phase: a fast .ai() metadata classification gate +// that, when confident, combines with the deterministic extractors into an +// IntakeResult; otherwise it escalates to a full .harness() classification. +// +// Output keys (§B.2): pr_type, complexity, languages, areas_touched, +// risk_signals, ai_generated, review_depth, pr_summary — or {} when the +// harness fallback fails to parse (Python returns a literal empty dict). +func IntakePhase(ctx context.Context, deps Deps, in IntakeInput) (map[string]any, error) { + pr := in.PRData + filesChanged := len(pr.ChangedFiles) + languages := extractLanguages(pr) + + gatePrompt := prompts.IntakeGatePrompt( + pr.Title, pr.Description, pr.Labels, pr.Author, filesChanged, languages, pr.CommitMessages, + ) + var gate schemas.IntakeGate + if err := aiStructured(ctx, deps.AI, gatePrompt, prompts.IntakeGateSystem, schemas.IntakeGate{}, &gate); err != nil { + return nil, err + } + + if gate.Confident { + paths := make([]string, len(pr.ChangedFiles)) + for i, changed := range pr.ChangedFiles { + paths[i] = changed.Path + } + areasTouched := extractAreas(paths) + reviewDepth := in.Depth + if in.Depth == "auto" { + reviewDepth = autoDepth(gate.Complexity) + } + intake := schemas.IntakeResult{ + PrType: gate.PrType, + Complexity: gate.Complexity, + Languages: languages, + AreasTouched: areasTouched, + RiskSignals: riskSignals(pr, areasTouched, filesChanged), + AIGenerated: aiGeneratedConfidence(pr), + ReviewDepth: reviewDepth, + PrSummary: prSummary(pr), + } + return dumpMap(intake) + } + + fallbackPrompt := prompts.IntakeFallbackPrompt(pr.Title, pr.Description, in.Depth, languages, filesChanged) + parsed, res, err := harnessx.Run[schemas.IntakeResult](ctx, deps.Harness, fallbackPrompt, harness.Options{}) + if err != nil { + return nil, err + } + if res == nil || res.Parsed == nil { + // Python: `fallback_result.parsed.model_dump() if fallback_result.parsed else {}`. + return map[string]any{}, nil + } + out := *parsed + out.Languages = orEmptyStrs(out.Languages) + out.AreasTouched = orEmptyStrs(out.AreasTouched) + out.RiskSignals = orEmptyStrs(out.RiskSignals) + return dumpMap(out) +} diff --git a/go/internal/reasoners/meta.go b/go/internal/reasoners/meta.go new file mode 100644 index 0000000..745629c --- /dev/null +++ b/go/internal/reasoners/meta.go @@ -0,0 +1,70 @@ +package reasoners + +import ( + "context" + "unicode/utf8" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/pr-af/go/internal/harnessx" + "github.com/Agent-Field/pr-af/go/internal/prompts" + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// The three meta-dimension selectors (meta_semantic / meta_mechanical / +// meta_systemic) share one implementation parameterized by lens: build the +// shared context, spill it to <repo>/.pr-af-context/meta_<lens>_context.json +// when it exceeds 8000 characters (code points), run the lens prompt, then +// FORCE the lens field regardless of what the model returned. +// +// Output keys (§B.2): lens, dimensions, confidence, rationale. Parse failure +// degrades to lens + empty dimensions + the seeded confidence (0.7). + +// MetaSemantic ports meta_semantic. +func MetaSemantic(ctx context.Context, deps Deps, in MetaInput) (map[string]any, error) { + return runMetaLens(ctx, deps, in, "semantic", prompts.MetaSemanticPrompt) +} + +// MetaMechanical ports meta_mechanical. +func MetaMechanical(ctx context.Context, deps Deps, in MetaInput) (map[string]any, error) { + return runMetaLens(ctx, deps, in, "mechanical", prompts.MetaMechanicalPrompt) +} + +// MetaSystemic ports meta_systemic. +func MetaSystemic(ctx context.Context, deps Deps, in MetaInput) (map[string]any, error) { + return runMetaLens(ctx, deps, in, "systemic", prompts.MetaSystemicPrompt) +} + +func runMetaLens( + ctx context.Context, + deps Deps, + in MetaInput, + lens string, + buildPrompt func(context, repoPath, depth string) string, +) (map[string]any, error) { + metaContext := prompts.MetaContext(in.Intake, in.Anatomy, []prompts.StrPair(in.DiffPatches), in.ReviewerFeedback) + // The builder embeds a file reference under the same condition; the write + // itself is this reasoner's job (Python _write_context_file). + if in.RepoPath != "" && utf8.RuneCountInString(metaContext) > 8000 { + if _, err := writeContextFile(metaContext, "meta_"+lens+"_context.json", in.RepoPath); err != nil { + return nil, err + } + } + + prompt := buildPrompt(metaContext, in.RepoPath, in.Depth) + parsed, _, err := harnessx.Run[schemas.MetaDimensionResult](ctx, deps.Harness, prompt, harness.Options{Cwd: in.RepoPath}) + if err != nil { + return nil, err + } + result := *parsed + // Python forces the lens on both the parsed and the fallback result. + result.Lens = lens + if result.Dimensions == nil { + result.Dimensions = []schemas.ReviewDimension{} + } + for i := range result.Dimensions { + result.Dimensions[i].TargetFiles = orEmptyStrs(result.Dimensions[i].TargetFiles) + result.Dimensions[i].ContextFiles = orEmptyStrs(result.Dimensions[i].ContextFiles) + } + return dumpMap(result) +} diff --git a/go/internal/reasoners/obligations.go b/go/internal/reasoners/obligations.go new file mode 100644 index 0000000..46ecf62 --- /dev/null +++ b/go/internal/reasoners/obligations.go @@ -0,0 +1,73 @@ +package reasoners + +import ( + "context" + "encoding/json" + "fmt" + "unicode/utf8" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/pr-af/go/internal/harnessx" + "github.com/Agent-Field/pr-af/go/internal/prompts" +) + +// ExtractObligations ports extract_obligations: enumerate the cross-location +// consistency obligations the changed code creates (breadth pass; judged one +// at a time by VerifyObligation). +// +// Output keys (§B.2): obligations (each: id, where, relies_on, property). No +// non-empty patches short-circuits to an empty list without a harness call; +// parse failure degrades to an empty list. +func ExtractObligations(ctx context.Context, deps Deps, in ExtractObligationsInput) (map[string]any, error) { + patches := filterPairs(in.DiffPatches) + if len(patches) == 0 { + return map[string]any{"obligations": []any{}}, nil + } + + // The builder embeds a file reference when the rendered patches exceed + // 9000 characters and a repo path exists; the write is the reasoner's job. + patchesText := renderPatches(patches) + if utf8.RuneCountInString(patchesText) > 9000 && in.RepoPath != "" { + if _, err := writeContextFile(patchesText, "obligations_diff.md", in.RepoPath); err != nil { + return nil, err + } + } + + prompt := prompts.ExtractObligationsPrompt([]prompts.StrPair(in.DiffPatches), in.RepoPath, in.PrContext) + parsed, _, err := harnessx.Run[obligationsResult](ctx, deps.Harness, prompt, harness.Options{Cwd: in.RepoPath}) + if err != nil { + return nil, err + } + obligations, err := dumpSlice(parsed.Obligations) + if err != nil { + return nil, err + } + return map[string]any{"obligations": obligations}, nil +} + +// VerifyObligation ports verify_obligation: read BOTH ends of one consistency +// obligation and decide whether the property holds. +// +// Output keys (§B.2): holds, title, severity, file_path, line_start, line_end, +// body, evidence, suggestion, confidence. Parse failure degrades to the seeded +// verdict (holds=true, severity="important", confidence=0.7) — Python's +// `_ObligationVerdict(holds=True)`. +func VerifyObligation(ctx context.Context, deps Deps, in VerifyObligationInput) (map[string]any, error) { + // Python: `_Obligation.model_validate(obligation)`. + var o obligation + b, err := json.Marshal(in.Obligation) + if err != nil { + return nil, fmt.Errorf("reasoners: verify_obligation input: %w", err) + } + if err := json.Unmarshal(b, &o); err != nil { + return nil, fmt.Errorf("reasoners: verify_obligation input: %w", err) + } + + prompt := prompts.VerifyObligationPrompt(o.Where, o.ReliesOn, o.Property) + parsed, _, err := harnessx.Run[obligationVerdict](ctx, deps.Harness, prompt, harness.Options{Cwd: in.RepoPath}) + if err != nil { + return nil, err + } + return dumpMap(*parsed) +} diff --git a/go/internal/reasoners/planning.go b/go/internal/reasoners/planning.go new file mode 100644 index 0000000..8fa99af --- /dev/null +++ b/go/internal/reasoners/planning.go @@ -0,0 +1,56 @@ +package reasoners + +import ( + "context" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/pr-af/go/internal/harnessx" + "github.com/Agent-Field/pr-af/go/internal/prompts" + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// PlanningPhase ports planning_phase (registered but DEAD on the live path — +// the meta-selectors replaced it; kept for surface parity). +// +// Output keys (§B.2): dimensions, cross_ref_hints, ai_adjusted, total_budget — +// or the literal {"dimensions": [], "cross_ref_hints": []} on parse failure. +func PlanningPhase(ctx context.Context, deps Deps, in PlanningInput) (map[string]any, error) { + prompt := prompts.PlanningPrompt(in.Intake, in.Anatomy, in.Depth, in.Hints) + parsed, res, err := harnessx.Run[schemas.ReviewPlan](ctx, deps.Harness, prompt, harness.Options{}) + if err != nil { + return nil, err + } + if res == nil || res.Parsed == nil { + // Python: `{"dimensions": [], "cross_ref_hints": []}` — two keys only. + return map[string]any{ + "dimensions": []any{}, + "cross_ref_hints": []any{}, + }, nil + } + plan := *parsed + if plan.Dimensions == nil { + plan.Dimensions = []schemas.ReviewDimension{} + } + // Python: total_budget = Field(default_factory=BudgetAllocation) — an + // absent key gets the seeded default (0.5/60/3/2). schemas.ReviewPlan has + // no UnmarshalJSON seeding, so an absent total_budget arrives as the zero + // struct; re-seed it. (A model explicitly emitting all four zeros would be + // re-seeded too — pydantic would keep them — but Python also re-seeds the + // far likelier `"total_budget": {}`, and this reasoner is dead on the live + // path.) + if plan.TotalBudget == (schemas.BudgetAllocation{}) { + plan.TotalBudget = schemas.BudgetAllocation{ + MaxCostUSD: 0.5, + MaxDurationSeconds: 60, + MaxReferenceFollows: 3, + MaxChildSpawns: 2, + } + } + plan.CrossRefHints = orEmptyStrs(plan.CrossRefHints) + for i := range plan.Dimensions { + plan.Dimensions[i].TargetFiles = orEmptyStrs(plan.Dimensions[i].TargetFiles) + plan.Dimensions[i].ContextFiles = orEmptyStrs(plan.Dimensions[i].ContextFiles) + } + return dumpMap(plan) +} diff --git a/go/internal/reasoners/reasoners.go b/go/internal/reasoners/reasoners.go new file mode 100644 index 0000000..3fc3866 --- /dev/null +++ b/go/internal/reasoners/reasoners.go @@ -0,0 +1,134 @@ +// Package reasoners ports the 16 router reasoners in +// src/pr_af/reasoners/harnesses.py (intake_phase … coverage_gate) to Go. +// +// Each reasoner is a plain function +// +// func XxxPhase(ctx context.Context, deps Deps, in XxxInput) (map[string]any, error) +// +// that builds its prompt with the byte-verbatim builders in internal/prompts, +// invokes the harness through the harnessx.Run choke point (or the AI seam for +// the two .ai() gates: IntakePhase and CoverageGate), and returns the exact +// snake_case key set Python's model_dump() emits (design §B.2). The +// orchestrator (T4.1) calls these in-process; node/register.go (T4.2) wraps +// them in afx.Bind adapters for control-plane registration. +// +// Parse-failure behavior mirrors Python per reasoner: when the harness could +// not produce a schema-valid result (Result.Parsed == nil), each reasoner +// applies the same deterministic fallback its Python counterpart does — a +// seeded default struct, an empty key set, or a keep-everything index list — +// never an error (design §C.3 step 4). Harness transport errors and fatal +// (non-retryable) API errors propagate as Go errors, exactly as Python lets +// the exception escape the reasoner. +package reasoners + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + "strings" + + "github.com/Agent-Field/agentfield/sdk/go/agent" + "github.com/Agent-Field/agentfield/sdk/go/ai" + + "github.com/Agent-Field/pr-af/go/internal/harnessx" +) + +// AICaller is the seam for the SDK's LLM chat entry point (Agent.AI), used by +// the two .ai() reasoners (intake_phase's gate and coverage_gate). Declaring +// the one-method interface here lets tests script structured responses without +// a live model; *agent.Agent satisfies it (sdk/go/agent/agent.go). +type AICaller interface { + AI(ctx context.Context, prompt string, opts ...ai.Option) (*ai.Response, error) +} + +// Compile-time assertions: the real SDK agent satisfies both seams. +var ( + _ AICaller = (*agent.Agent)(nil) + _ harnessx.HarnessCaller = (*agent.Agent)(nil) +) + +// Deps carries the two injectable capabilities a reasoner may use. Harness is +// required by every reasoner except CoverageGate; AI is required only by +// IntakePhase and CoverageGate (Python's router.app.ai calls). +type Deps struct { + Harness harnessx.HarnessCaller + AI AICaller +} + +// maxParseRetries mirrors the Python SDK's max_parse_retries=2 +// (agent_ai.py:747): a malformed structured response re-invokes the LLM up to +// two more times before the parse error surfaces. +const maxParseRetries = 2 + +// aiStructured mirrors Python's `await router.app.ai(prompt, system=..., +// schema=Model)`: a chat call with a structured-output schema, decoded into +// dest with the Python SDK's parse tolerance (agent_ai.py:806-846) — direct +// JSON parse, then a greedy first-'{'-to-last-'}' extraction (Python's +// re.search(r"\{.*\}", text, re.DOTALL) over fenced/prose output), then a +// fresh LLM call, up to maxParseRetries times. Transport/API errors are NOT +// retried here (Python retries only "Could not parse structured response"). +// Each attempt decodes into a fresh instance so a failed attempt's partial +// fill never bleeds into the next one. schemaSample is a zero value of the +// schema struct (the Go analogue of passing the pydantic class). +func aiStructured(ctx context.Context, caller AICaller, prompt, system string, schemaSample any, dest any) error { + if caller == nil { + return fmt.Errorf("reasoners: AI seam is nil") + } + destV := reflect.ValueOf(dest) + if destV.Kind() != reflect.Pointer || destV.IsNil() { + return fmt.Errorf("reasoners: aiStructured dest must be a non-nil pointer, got %T", dest) + } + var lastErr error + for attempt := 0; attempt <= maxParseRetries; attempt++ { + resp, err := caller.AI(ctx, prompt, ai.WithSystem(system), ai.WithSchema(schemaSample)) + if err != nil { + return err + } + text := resp.Text() + fresh := reflect.New(destV.Type().Elem()) + if err := json.Unmarshal([]byte(text), fresh.Interface()); err == nil { + destV.Elem().Set(fresh.Elem()) + return nil + } + if start, end := strings.Index(text, "{"), strings.LastIndex(text, "}"); start >= 0 && end > start { + fresh = reflect.New(destV.Type().Elem()) + if err := json.Unmarshal([]byte(text[start:end+1]), fresh.Interface()); err == nil { + destV.Elem().Set(fresh.Elem()) + return nil + } + } + lastErr = fmt.Errorf("Could not parse structured response: %s", text) + } + return lastErr +} + +// dumpMap is the Go analogue of pydantic model_dump(): a JSON round trip that +// yields a map containing every field (the schemas structs carry no omitempty), +// with nil pointers as JSON null. +func dumpMap(v any) (map[string]any, error) { + b, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("reasoners: dump %T: %w", v, err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + return nil, fmt.Errorf("reasoners: dump %T: %w", v, err) + } + return m, nil +} + +// dumpSlice dumps each element like Python's `[x.model_dump() for x in xs]`. +// A nil slice dumps to an empty (non-nil) list — Python list comprehensions +// never yield None. +func dumpSlice[T any](xs []T) ([]any, error) { + out := make([]any, 0, len(xs)) + for i := range xs { + m, err := dumpMap(&xs[i]) + if err != nil { + return nil, err + } + out = append(out, m) + } + return out, nil +} diff --git a/go/internal/reasoners/reasoners_test.go b/go/internal/reasoners/reasoners_test.go new file mode 100644 index 0000000..971507b --- /dev/null +++ b/go/internal/reasoners/reasoners_test.go @@ -0,0 +1,968 @@ +package reasoners + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "sort" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/pr-af/go/internal/prompts" + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// --- seams ------------------------------------------------------------------- + +// mockHarness scripts one harness response: on ok, the JSON payload is decoded +// into dest and returned as Parsed (what the SDK does after schema validation); +// on parseFail, a Result with Parsed==nil comes back — the path harnessx.Run +// turns into a seeded default. +type mockHarness struct { + payload string + parseFail bool + calls int + gotPrompt string + gotOpts harness.Options +} + +func (m *mockHarness) Harness(_ context.Context, prompt string, _ map[string]any, dest any, opts harness.Options) (*harness.Result, error) { + m.calls++ + m.gotPrompt = prompt + m.gotOpts = opts + if m.parseFail { + return &harness.Result{IsError: true, ErrorMessage: "schema validation failed"}, nil + } + if err := json.Unmarshal([]byte(m.payload), dest); err != nil { + return nil, err + } + return &harness.Result{Parsed: dest, Result: m.payload}, nil +} + +// fakeAI scripts one structured .ai() response and records the prompt/system. +type fakeAI struct { + text string + gotPrompt string + gotSystem string + calls int +} + +func (f *fakeAI) AI(_ context.Context, prompt string, opts ...ai.Option) (*ai.Response, error) { + f.calls++ + f.gotPrompt = prompt + var req ai.Request + for _, o := range opts { + if err := o(&req); err != nil { + return nil, err + } + } + for _, msg := range req.Messages { + if msg.Role == "system" && len(msg.Content) > 0 { + f.gotSystem = msg.Content[0].Text + } + } + return &ai.Response{ + Choices: []ai.Choice{{ + Message: ai.Message{ + Role: "assistant", + Content: []ai.ContentPart{{Type: "text", Text: f.text}}, + }, + }}, + }, nil +} + +func keySet(m map[string]any) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func wantKeys(t *testing.T, m map[string]any, want ...string) { + t.Helper() + sort.Strings(want) + if got := keySet(m); !reflect.DeepEqual(got, want) { + t.Fatalf("key set mismatch:\n got %v\n want %v", got, want) + } +} + +// fixturePR mirrors the Python fixture used to capture helper goldens +// (helpers_test.go documents the capture run). +func fixturePR() schemas.GitHubPRData { + paths := []string{ + "src/auth/login.py", "db/migrations/0001_init.sql", "web/App.tsx", + "cmd/main.go", "config/settings.yml", "tests/test_login.py", + "Dockerfile", "README.md", "script.unknownext", + } + files := make([]schemas.ChangedFile, len(paths)) + for i, p := range paths { + files[i] = schemas.ChangedFile{Path: p, Status: "modified", Additions: 3, Deletions: 1} + } + return schemas.GitHubPRData{ + Owner: "o", Repo: "r", Number: 7, + Title: "Add OAuth login", + Description: " Implements OAuth2 login flow. ", + Labels: []string{"auth"}, + Author: "alice", + BaseSHA: "b", HeadSHA: "h", + CommitMessages: []string{"init", "Co-Authored-By: Claude <x>", "fix tests"}, + ChangedFiles: files, + } +} + +var intakeKeys = []string{ + "pr_type", "complexity", "languages", "areas_touched", + "risk_signals", "ai_generated", "review_depth", "pr_summary", +} + +// --- intake_phase ------------------------------------------------------------- + +// Contract: a confident gate yields the full IntakeResult key set with the +// deterministic extractor values; depth "auto" resolves through _auto_depth. +func TestIntakePhaseConfidentGate(t *testing.T) { + aiSeam := &fakeAI{text: `{"pr_type":"feature","complexity":"trivial","confident":true}`} + h := &mockHarness{} + out, err := IntakePhase(context.Background(), Deps{Harness: h, AI: aiSeam}, IntakeInput{ + PRData: fixturePR(), Depth: "auto", + }) + if err != nil { + t.Fatal(err) + } + wantKeys(t, out, intakeKeys...) + if h.calls != 0 { + t.Fatalf("confident gate must not invoke the harness fallback, got %d calls", h.calls) + } + if aiSeam.gotSystem != prompts.IntakeGateSystem { + t.Fatalf("system prompt = %q", aiSeam.gotSystem) + } + if out["pr_type"] != "feature" || out["complexity"] != "trivial" { + t.Fatalf("gate fields not propagated: %v", out) + } + if out["review_depth"] != "quick" { // auto + trivial -> quick (Python golden) + t.Fatalf("review_depth = %v, want quick", out["review_depth"]) + } + if out["ai_generated"] != 0.2 { // Python golden for the fixture + t.Fatalf("ai_generated = %v, want 0.2", out["ai_generated"]) + } + if out["pr_summary"] != "Implements OAuth2 login flow." { + t.Fatalf("pr_summary = %v", out["pr_summary"]) + } + wantLangs := []any{"go", "markdown", "python", "sql", "typescript", "yaml"} + if !reflect.DeepEqual(out["languages"], wantLangs) { + t.Fatalf("languages = %v, want %v", out["languages"], wantLangs) + } + wantAreas := []any{"auth", "database", "frontend", "tests", "config", "infra"} + if !reflect.DeepEqual(out["areas_touched"], wantAreas) { + t.Fatalf("areas_touched = %v, want %v", out["areas_touched"], wantAreas) + } + wantRisk := []any{ + "touches authentication or security-sensitive paths", + "modifies data model or schema-affecting code", + "includes configuration changes", + "test behavior updated", + } + if !reflect.DeepEqual(out["risk_signals"], wantRisk) { + t.Fatalf("risk_signals = %v", out["risk_signals"]) + } +} + +// Contract: an unconfident gate escalates to the harness; a parsed fallback +// result dumps the full key set. +func TestIntakePhaseFallbackParsed(t *testing.T) { + aiSeam := &fakeAI{text: `{"pr_type":"","complexity":"","confident":false}`} + h := &mockHarness{payload: `{ + "pr_type":"refactor","complexity":"standard","languages":["go"], + "areas_touched":["api"],"risk_signals":[],"ai_generated":0.1, + "review_depth":"standard","pr_summary":"s"}`} + out, err := IntakePhase(context.Background(), Deps{Harness: h, AI: aiSeam}, IntakeInput{ + PRData: fixturePR(), Depth: "standard", + }) + if err != nil { + t.Fatal(err) + } + if h.calls != 1 { + t.Fatalf("harness calls = %d, want 1", h.calls) + } + if !strings.HasPrefix(h.gotPrompt, "Classify this pull request for a multi-agent review pipeline.") { + t.Fatalf("fallback prompt mismatch: %q", h.gotPrompt[:60]) + } + wantKeys(t, out, intakeKeys...) + if out["pr_type"] != "refactor" { + t.Fatalf("pr_type = %v", out["pr_type"]) + } +} + +// Contract: fallback parse failure returns Python's literal empty dict. +func TestIntakePhaseFallbackParseFailReturnsEmpty(t *testing.T) { + aiSeam := &fakeAI{text: `{"pr_type":"","complexity":"","confident":false}`} + h := &mockHarness{parseFail: true} + out, err := IntakePhase(context.Background(), Deps{Harness: h, AI: aiSeam}, IntakeInput{ + PRData: fixturePR(), Depth: "standard", + }) + if err != nil { + t.Fatal(err) + } + if len(out) != 0 { + t.Fatalf("want {}, got %v", out) + } +} + +// --- anatomy_phase ------------------------------------------------------------- + +var anatomyKeys = []string{ + "files", "clusters", "blast_radius", "dependency_graph", "stats", + "pr_narrative", "risk_surfaces", "unrelated_changes", "intent_gaps", "context_notes", +} + +// Contract: anatomy merges deterministic diff decomposition with the parsed +// semantic fields; empty diff falls back to metadata-derived FileChanges. +func TestAnatomyPhaseHappyPath(t *testing.T) { + h := &mockHarness{payload: `{ + "pr_narrative":"replaces X with Y","risk_surfaces":["callers of X"], + "unrelated_changes":[],"intent_gaps":["undocumented flag"],"context_notes":"n"}`} + out, err := AnatomyPhase(context.Background(), Deps{Harness: h}, AnatomyInput{ + PRData: fixturePR(), Intake: schemas.IntakeResult{PrType: "feature", Complexity: "standard", PrSummary: "s"}, + }) + if err != nil { + t.Fatal(err) + } + wantKeys(t, out, anatomyKeys...) + if out["pr_narrative"] != "replaces X with Y" { + t.Fatalf("pr_narrative = %v", out["pr_narrative"]) + } + files := out["files"].([]any) + if len(files) != 9 { // metadata fallback: one FileChange per changed file + t.Fatalf("files len = %d, want 9", len(files)) + } + if dg := out["dependency_graph"].(map[string]any); len(dg) != 0 { + t.Fatalf("dependency_graph = %v, want {}", dg) + } + if !strings.HasPrefix(h.gotPrompt, "You are a senior engineer performing structural analysis") { + t.Fatalf("prompt mismatch: %q", h.gotPrompt[:50]) + } +} + +// Contract: semantic parse failure degrades to empty narrative fields with the +// full key set intact (never an error). +func TestAnatomyPhaseParseFailSeedsEmptySemantics(t *testing.T) { + h := &mockHarness{parseFail: true} + out, err := AnatomyPhase(context.Background(), Deps{Harness: h}, AnatomyInput{ + PRData: fixturePR(), Intake: schemas.IntakeResult{}, + }) + if err != nil { + t.Fatal(err) + } + wantKeys(t, out, anatomyKeys...) + if out["pr_narrative"] != "" || out["context_notes"] != "" { + t.Fatalf("want empty semantics, got %v / %v", out["pr_narrative"], out["context_notes"]) + } + if rs := out["risk_surfaces"].([]any); len(rs) != 0 { + t.Fatalf("risk_surfaces = %v, want []", rs) + } +} + +// --- planning_phase ------------------------------------------------------------- + +// Contract: a parsed plan dumps the four ReviewPlan keys; an absent +// total_budget lands on the pydantic default_factory seed (0.5/60/3/2). +func TestPlanningPhaseHappyPath(t *testing.T) { + h := &mockHarness{payload: `{ + "dimensions":[{"id":"d1","name":"N","review_prompt":"p","target_files":["a.go"]}], + "cross_ref_hints":["h"],"ai_adjusted":true}`} + out, err := PlanningPhase(context.Background(), Deps{Harness: h}, PlanningInput{Depth: "standard"}) + if err != nil { + t.Fatal(err) + } + wantKeys(t, out, "dimensions", "cross_ref_hints", "ai_adjusted", "total_budget") + dims := out["dimensions"].([]any) + dim := dims[0].(map[string]any) + if dim["priority"] != float64(1) { // seeded ReviewDimension default + t.Fatalf("priority = %v, want 1", dim["priority"]) + } + tb := out["total_budget"].(map[string]any) + if tb["max_cost_usd"] != 0.5 || tb["max_duration_seconds"] != float64(60) { + t.Fatalf("total_budget not seeded: %v", tb) + } +} + +// Contract: parse failure returns Python's literal two-key fallback. +func TestPlanningPhaseParseFailFallback(t *testing.T) { + h := &mockHarness{parseFail: true} + out, err := PlanningPhase(context.Background(), Deps{Harness: h}, PlanningInput{Depth: "deep"}) + if err != nil { + t.Fatal(err) + } + wantKeys(t, out, "dimensions", "cross_ref_hints") + if len(out["dimensions"].([]any)) != 0 || len(out["cross_ref_hints"].([]any)) != 0 { + t.Fatalf("want empty lists, got %v", out) + } +} + +// --- meta selectors ------------------------------------------------------------- + +var metaKeys = []string{"lens", "dimensions", "confidence", "rationale"} + +// Contract: each selector forces its own lens (even over a model-supplied +// value) and emits the MetaDimensionResult key set. +func TestMetaSelectorsForceLens(t *testing.T) { + cases := []struct { + lens string + fn func(context.Context, Deps, MetaInput) (map[string]any, error) + }{ + {"semantic", MetaSemantic}, + {"mechanical", MetaMechanical}, + {"systemic", MetaSystemic}, + } + for _, tc := range cases { + t.Run(tc.lens, func(t *testing.T) { + h := &mockHarness{payload: `{ + "lens":"WRONG", + "dimensions":[{"id":"x","name":"X","review_prompt":"p","target_files":["a"],"priority":5}], + "confidence":0.9,"rationale":"r"}`} + out, err := tc.fn(context.Background(), Deps{Harness: h}, MetaInput{Depth: "standard"}) + if err != nil { + t.Fatal(err) + } + wantKeys(t, out, metaKeys...) + if out["lens"] != tc.lens { + t.Fatalf("lens = %v, want %s", out["lens"], tc.lens) + } + if out["confidence"] != 0.9 { + t.Fatalf("confidence = %v", out["confidence"]) + } + if !strings.Contains(h.gotPrompt, strings.ToUpper(tc.lens)) { + t.Fatalf("prompt does not carry the %s lens", tc.lens) + } + }) + } +} + +// Contract: parse failure yields lens + empty dimensions + the seeded 0.7 +// confidence (Python's MetaDimensionResult(lens=..., dimensions=[])). +func TestMetaSelectorParseFail(t *testing.T) { + h := &mockHarness{parseFail: true} + out, err := MetaMechanical(context.Background(), Deps{Harness: h}, MetaInput{Depth: "quick"}) + if err != nil { + t.Fatal(err) + } + wantKeys(t, out, metaKeys...) + if out["lens"] != "mechanical" { + t.Fatalf("lens = %v", out["lens"]) + } + if len(out["dimensions"].([]any)) != 0 { + t.Fatalf("dimensions = %v, want []", out["dimensions"]) + } + if out["confidence"] != 0.7 { + t.Fatalf("confidence = %v, want seeded 0.7", out["confidence"]) + } + if out["rationale"] != "" { + t.Fatalf("rationale = %v", out["rationale"]) + } +} + +// --- review_dimension ------------------------------------------------------------- + +var reviewDimKeys = []string{"findings", "sub_reviews", "current_depth"} + +var findingDumpKeys = []string{ + "dimension_id", "dimension_name", "file_path", "line_start", "line_end", + "hunk_context", "severity", "title", "body", "suggestion", "evidence", + "confidence", "tags", +} + +// Contract: findings dump with the full ReviewFinding key set; sub_reviews are +// sliced to 2 THEN filtered on review_prompt+target_files; current_depth echoes +// the input. +func TestReviewDimensionHappyPath(t *testing.T) { + h := &mockHarness{payload: `{ + "findings":[{"title":"T","severity":"high","file_path":"a.go","line_start":3}], + "sub_reviews":[ + {"reason":"r1","review_prompt":"p1","target_files":["a.go"]}, + {"reason":"r2","review_prompt":"","target_files":["b.go"]}, + {"reason":"r3","review_prompt":"p3","target_files":["c.go"]} + ]}`} + out, err := ReviewDimension(context.Background(), Deps{Harness: h}, ReviewDimensionInput{ + ReviewPrompt: "Investigate X", TargetFiles: []string{"a.go"}, + CurrentDepth: 1, MaxDepth: 2, + }) + if err != nil { + t.Fatal(err) + } + wantKeys(t, out, reviewDimKeys...) + if out["current_depth"] != 1 { + t.Fatalf("current_depth = %v", out["current_depth"]) + } + findings := out["findings"].([]any) + if len(findings) != 1 { + t.Fatalf("findings = %v", findings) + } + f := findings[0].(map[string]any) + wantKeys(t, f, findingDumpKeys...) + if f["severity"] != "important" { // "high" coerced by the canonical map + t.Fatalf("severity = %v, want important", f["severity"]) + } + if f["confidence"] != 0.5 { // seeded pydantic default + t.Fatalf("confidence = %v, want 0.5", f["confidence"]) + } + // [:2] slice happens BEFORE the filter: sr2 (empty prompt) is inside the + // slice and dropped, sr3 is outside it — only sr1 survives. + subs := out["sub_reviews"].([]any) + if len(subs) != 1 { + t.Fatalf("sub_reviews = %v, want exactly 1", subs) + } + sub := subs[0].(map[string]any) + wantKeys(t, sub, "reason", "review_prompt", "target_files", "context_files", "priority") + if sub["priority"] != 1 { // seeded default + t.Fatalf("priority = %v", sub["priority"]) + } +} + +// Contract: at max depth no sub-reviews are forwarded even if the model +// returned some. +func TestReviewDimensionAtMaxDepthDropsSubReviews(t *testing.T) { + h := &mockHarness{payload: `{ + "findings":[], + "sub_reviews":[{"reason":"r","review_prompt":"p","target_files":["a"]}]}`} + out, err := ReviewDimension(context.Background(), Deps{Harness: h}, ReviewDimensionInput{ + ReviewPrompt: "x", TargetFiles: []string{"a"}, CurrentDepth: 2, MaxDepth: 2, + }) + if err != nil { + t.Fatal(err) + } + if len(out["sub_reviews"].([]any)) != 0 { + t.Fatalf("sub_reviews = %v, want []", out["sub_reviews"]) + } + if !strings.Contains(h.gotPrompt, "You are at maximum review depth.") { + t.Fatal("prompt should carry the no-spawn instruction") + } +} + +// Contract: schema parse failure reports zero findings (with the key set +// intact), never an error. +func TestReviewDimensionParseFail(t *testing.T) { + h := &mockHarness{parseFail: true} + out, err := ReviewDimension(context.Background(), Deps{Harness: h}, ReviewDimensionInput{ + ReviewPrompt: "x", TargetFiles: []string{"a"}, CurrentDepth: 0, MaxDepth: 2, + }) + if err != nil { + t.Fatal(err) + } + wantKeys(t, out, reviewDimKeys...) + if len(out["findings"].([]any)) != 0 || len(out["sub_reviews"].([]any)) != 0 { + t.Fatalf("want empty findings/sub_reviews, got %v", out) + } + if out["current_depth"] != 0 { + t.Fatalf("current_depth = %v", out["current_depth"]) + } +} + +// --- compound finder / dedup ------------------------------------------------------------- + +// Contract: fewer than two findings short-circuits without a harness call. +func TestCompoundFinderShortCircuit(t *testing.T) { + h := &mockHarness{} + out, err := CompoundFinderPhase(context.Background(), Deps{Harness: h}, CompoundFinderInput{ + ClusterFindings: []schemas.ReviewFinding{{Title: "only"}}, + }) + if err != nil { + t.Fatal(err) + } + wantKeys(t, out, "findings") + if len(out["findings"].([]any)) != 0 || h.calls != 0 { + t.Fatalf("want no harness call and [], got calls=%d out=%v", h.calls, out) + } +} + +var compoundFindingKeys = []string{ + "title", "severity", "file_path", "line_start", "line_end", "body", + "evidence", "suggestion", "confidence", "tags", "contributing_findings", +} + +// Contract: compound findings dump the _CompoundFinding key set with seeded +// defaults for absent fields. +func TestCompoundFinderHappyPath(t *testing.T) { + h := &mockHarness{payload: `{"findings":[{"title":"combo","contributing_findings":["a","b"]}]}`} + out, err := CompoundFinderPhase(context.Background(), Deps{Harness: h}, CompoundFinderInput{ + ClusterFindings: []schemas.ReviewFinding{{Title: "a"}, {Title: "b"}}, + }) + if err != nil { + t.Fatal(err) + } + findings := out["findings"].([]any) + f := findings[0].(map[string]any) + wantKeys(t, f, compoundFindingKeys...) + if f["severity"] != "suggestion" || f["confidence"] != 0.5 { + t.Fatalf("seeded defaults missing: %v", f) + } +} + +// Contract: compound parse failure degrades to an empty findings list. +func TestCompoundFinderParseFail(t *testing.T) { + h := &mockHarness{parseFail: true} + out, err := CompoundFinderPhase(context.Background(), Deps{Harness: h}, CompoundFinderInput{ + ClusterFindings: []schemas.ReviewFinding{{Title: "a"}, {Title: "b"}}, + }) + if err != nil { + t.Fatal(err) + } + if len(out["findings"].([]any)) != 0 { + t.Fatalf("want [], got %v", out["findings"]) + } +} + +// Contract: <=1 compound findings short-circuit with the fixed reasoning +// string; valid indices filter; empty/invalid indices keep everything. +func TestCompoundDedupPhase(t *testing.T) { + // Short circuit. + out, err := CompoundDedupPhase(context.Background(), Deps{}, CompoundDedupInput{ + CompoundFindings: []schemas.ReviewFinding{{Title: "x"}}, + }) + if err != nil { + t.Fatal(err) + } + wantKeys(t, out, "keep_indices", "reasoning") + if out["reasoning"] != "single finding, no dedup needed" { + t.Fatalf("reasoning = %v", out["reasoning"]) + } + if !reflect.DeepEqual(out["keep_indices"], []int{0}) { + t.Fatalf("keep_indices = %v", out["keep_indices"]) + } + + // Valid subset (out-of-range dropped). + h := &mockHarness{payload: `{"keep_indices":[1,7,-1],"reasoning":"r"}`} + out, err = CompoundDedupPhase(context.Background(), Deps{Harness: h}, CompoundDedupInput{ + CompoundFindings: []schemas.ReviewFinding{{Title: "a"}, {Title: "b"}}, + }) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(out["keep_indices"], []int{1}) || out["reasoning"] != "r" { + t.Fatalf("got %v", out) + } + + // Parse failure -> keep all, reasoning "". + h = &mockHarness{parseFail: true} + out, err = CompoundDedupPhase(context.Background(), Deps{Harness: h}, CompoundDedupInput{ + CompoundFindings: []schemas.ReviewFinding{{Title: "a"}, {Title: "b"}, {Title: "c"}}, + }) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(out["keep_indices"], []int{0, 1, 2}) || out["reasoning"] != "" { + t.Fatalf("got %v", out) + } +} + +// --- post_worthiness_gate ------------------------------------------------------------- + +// Contract: <=1 finding returns keep_indices ONLY (no reasoning key — exact +// Python branch); >1 filters valid indices; empty/parse-fail keeps everything. +func TestPostWorthinessGate(t *testing.T) { + // Zero findings: keep_indices [] and no reasoning key. + out, err := PostWorthinessGate(context.Background(), Deps{}, PostWorthinessInput{}) + if err != nil { + t.Fatal(err) + } + wantKeys(t, out, "keep_indices") + if len(out["keep_indices"].([]int)) != 0 { + t.Fatalf("keep_indices = %v", out["keep_indices"]) + } + + // One finding: [0], still no reasoning key, no harness call. + h := &mockHarness{} + out, err = PostWorthinessGate(context.Background(), Deps{Harness: h}, PostWorthinessInput{ + Findings: []schemas.ReviewFinding{{Title: "t"}}, + }) + if err != nil { + t.Fatal(err) + } + wantKeys(t, out, "keep_indices") + if !reflect.DeepEqual(out["keep_indices"], []int{0}) || h.calls != 0 { + t.Fatalf("got %v calls=%d", out, h.calls) + } + + // Judged subset. + h = &mockHarness{payload: `{"keep_indices":[0,2,9],"reasoning":"kept real bugs"}`} + out, err = PostWorthinessGate(context.Background(), Deps{Harness: h}, PostWorthinessInput{ + Findings: []schemas.ReviewFinding{{Title: "a"}, {Title: "b"}, {Title: "c"}}, + }) + if err != nil { + t.Fatal(err) + } + wantKeys(t, out, "keep_indices", "reasoning") + if !reflect.DeepEqual(out["keep_indices"], []int{0, 2}) || out["reasoning"] != "kept real bugs" { + t.Fatalf("got %v", out) + } + + // Parse failure never silences everything. + h = &mockHarness{parseFail: true} + out, err = PostWorthinessGate(context.Background(), Deps{Harness: h}, PostWorthinessInput{ + Findings: []schemas.ReviewFinding{{Title: "a"}, {Title: "b"}}, + }) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(out["keep_indices"], []int{0, 1}) { + t.Fatalf("got %v", out) + } +} + +// --- evidence_verifier ------------------------------------------------------------- + +var verifiedFindingKeys = []string{ + "title", "verified", "actual_behavior", "revised_severity", + "revised_confidence", "verification_notes", +} + +// Contract: verified findings dump the _VerifiedFinding key set; absent fields +// land on seeded defaults (verified=true, revised_confidence=0.5). +func TestEvidenceVerifierHappyPath(t *testing.T) { + h := &mockHarness{payload: `{"verified_findings":[{"title":"T","verified":false,"revised_severity":"nitpick"}]}`} + out, err := EvidenceVerifier(context.Background(), Deps{Harness: h}, EvidenceVerifierInput{ + Findings: []schemas.ReviewFinding{{Title: "T", Severity: "important"}}, + }) + if err != nil { + t.Fatal(err) + } + wantKeys(t, out, "verified_findings") + vf := out["verified_findings"].([]any)[0].(map[string]any) + wantKeys(t, vf, verifiedFindingKeys...) + if vf["verified"] != false || vf["revised_confidence"] != 0.5 { + t.Fatalf("got %v", vf) + } +} + +// Contract: parse failure degrades to an empty verified_findings list. +func TestEvidenceVerifierParseFail(t *testing.T) { + h := &mockHarness{parseFail: true} + out, err := EvidenceVerifier(context.Background(), Deps{Harness: h}, EvidenceVerifierInput{ + Findings: []schemas.ReviewFinding{{Title: "T"}}, + }) + if err != nil { + t.Fatal(err) + } + if len(out["verified_findings"].([]any)) != 0 { + t.Fatalf("got %v", out) + } +} + +// --- adversary_phase ------------------------------------------------------------- + +var adversaryResultKeys = []string{ + "finding_title", "verdict", "reason", "severity_adjustment", "hidden_trap", +} + +// Contract: results dump the AdversaryResult key set (seeded +// severity_adjustment="none", hidden_trap null); skepticism escalates in the +// prompt above 0.5 AI confidence. +func TestAdversaryPhaseHappyPath(t *testing.T) { + h := &mockHarness{payload: `{"results":[{"finding_title":"T","verdict":"confirmed","reason":"r"}]}`} + out, err := AdversaryPhase(context.Background(), Deps{Harness: h}, AdversaryInput{ + Findings: []schemas.ReviewFinding{{Title: "T"}}, + AIGeneratedConfidence: 0.8, + }) + if err != nil { + t.Fatal(err) + } + wantKeys(t, out, "results") + r := out["results"].([]any)[0].(map[string]any) + wantKeys(t, r, adversaryResultKeys...) + if r["severity_adjustment"] != "none" { + t.Fatalf("severity_adjustment = %v, want seeded none", r["severity_adjustment"]) + } + if r["hidden_trap"] != nil { + t.Fatalf("hidden_trap = %v, want null", r["hidden_trap"]) + } + if !strings.Contains(h.gotPrompt, "Skepticism mode: high") { + t.Fatal("skepticism should escalate above 0.5") + } + if !strings.Contains(h.gotPrompt, "AI-generated confidence: 0.8") { + t.Fatal("prompt should carry the AI confidence") + } +} + +// Contract: parse failure degrades to an empty results list. +func TestAdversaryPhaseParseFail(t *testing.T) { + h := &mockHarness{parseFail: true} + out, err := AdversaryPhase(context.Background(), Deps{Harness: h}, AdversaryInput{ + Findings: []schemas.ReviewFinding{{Title: "T"}}, + }) + if err != nil { + t.Fatal(err) + } + if len(out["results"].([]any)) != 0 { + t.Fatalf("got %v", out) + } +} + +// --- deepen_findings ------------------------------------------------------------- + +var deepenFindingKeys = []string{ + "dimension_id", "dimension_name", "file_path", "line_start", "line_end", + "severity", "title", "body", "suggestion", "evidence", "confidence", "tags", +} + +// Contract: no non-empty patches short-circuits without a harness call; parsed +// findings carry the _DeepenFinding seeds for absent fields. +func TestDeepenFindings(t *testing.T) { + h := &mockHarness{} + out, err := DeepenFindings(context.Background(), Deps{Harness: h}, DeepenInput{ + DiffPatches: OrderedPatches{{Key: "a.go", Val: ""}}, // filtered to empty + }) + if err != nil { + t.Fatal(err) + } + if len(out["findings"].([]any)) != 0 || h.calls != 0 { + t.Fatalf("want short-circuit, got calls=%d out=%v", h.calls, out) + } + + h = &mockHarness{payload: `{"findings":[{"title":"wrong arg","file_path":"a.go","line_start":3}]}`} + out, err = DeepenFindings(context.Background(), Deps{Harness: h}, DeepenInput{ + DiffPatches: OrderedPatches{{Key: "a.go", Val: "+x"}}, + }) + if err != nil { + t.Fatal(err) + } + wantKeys(t, out, "findings") + f := out["findings"].([]any)[0].(map[string]any) + wantKeys(t, f, deepenFindingKeys...) + if f["dimension_id"] != "literal-verify" || + f["dimension_name"] != "Literal-Correctness Verifier" || + f["severity"] != "important" || f["confidence"] != 0.7 { + t.Fatalf("seeded defaults missing: %v", f) + } + + h = &mockHarness{parseFail: true} + out, err = DeepenFindings(context.Background(), Deps{Harness: h}, DeepenInput{ + DiffPatches: OrderedPatches{{Key: "a.go", Val: "+x"}}, + }) + if err != nil { + t.Fatal(err) + } + if len(out["findings"].([]any)) != 0 { + t.Fatalf("want [], got %v", out["findings"]) + } +} + +// --- extract_obligations / verify_obligation ------------------------------------------------------------- + +// Contract: obligations dump {id, where, relies_on, property}; empty patches +// short-circuit; parse failure degrades to an empty list. +func TestExtractObligations(t *testing.T) { + h := &mockHarness{} + out, err := ExtractObligations(context.Background(), Deps{Harness: h}, ExtractObligationsInput{}) + if err != nil { + t.Fatal(err) + } + if len(out["obligations"].([]any)) != 0 || h.calls != 0 { + t.Fatalf("want short-circuit, got %v", out) + } + + h = &mockHarness{payload: `{"obligations":[{"id":"o1","where":"w","relies_on":"r","property":"p"}]}`} + out, err = ExtractObligations(context.Background(), Deps{Harness: h}, ExtractObligationsInput{ + DiffPatches: OrderedPatches{{Key: "a.go", Val: "+x"}}, + }) + if err != nil { + t.Fatal(err) + } + wantKeys(t, out, "obligations") + o := out["obligations"].([]any)[0].(map[string]any) + wantKeys(t, o, "id", "where", "relies_on", "property") + + h = &mockHarness{parseFail: true} + out, err = ExtractObligations(context.Background(), Deps{Harness: h}, ExtractObligationsInput{ + DiffPatches: OrderedPatches{{Key: "a.go", Val: "+x"}}, + }) + if err != nil { + t.Fatal(err) + } + if len(out["obligations"].([]any)) != 0 { + t.Fatalf("want [], got %v", out) + } +} + +var verdictKeys = []string{ + "holds", "title", "severity", "file_path", "line_start", "line_end", + "body", "evidence", "suggestion", "confidence", +} + +// Contract: a parsed verdict dumps all ten keys; parse failure returns the +// seeded holds=true verdict (severity="important", confidence=0.7). +func TestVerifyObligation(t *testing.T) { + h := &mockHarness{payload: `{ + "holds":false,"title":"key mismatch","severity":"critical","file_path":"a.go", + "line_start":10,"line_end":12,"body":"b","evidence":"e","suggestion":"s","confidence":0.9}`} + out, err := VerifyObligation(context.Background(), Deps{Harness: h}, VerifyObligationInput{ + Obligation: map[string]any{"id": "o1", "where": "w", "relies_on": "r", "property": "p"}, + }) + if err != nil { + t.Fatal(err) + } + wantKeys(t, out, verdictKeys...) + if out["holds"] != false || out["severity"] != "critical" { + t.Fatalf("got %v", out) + } + if !strings.Contains(h.gotPrompt, "- WHERE (the changed code): w\n") { + t.Fatalf("obligation fields not in prompt: %q", h.gotPrompt) + } + + h = &mockHarness{parseFail: true} + out, err = VerifyObligation(context.Background(), Deps{Harness: h}, VerifyObligationInput{ + Obligation: map[string]any{"where": "w", "relies_on": "r", "property": "p"}, + }) + if err != nil { + t.Fatal(err) + } + wantKeys(t, out, verdictKeys...) + if out["holds"] != true || out["severity"] != "important" || out["confidence"] != 0.7 { + t.Fatalf("seeded verdict wrong: %v", out) + } + if out["suggestion"] != nil { + t.Fatalf("suggestion = %v, want null", out["suggestion"]) + } +} + +// --- coverage_gate ------------------------------------------------------------- + +// Contract: the .ai() gate dumps {fully_covered, gap_descriptions, confident}; +// absent keys land on the pydantic seeds (confident=true, gaps=[]). +func TestCoverageGate(t *testing.T) { + aiSeam := &fakeAI{text: `{"fully_covered":false,"gap_descriptions":["cluster_1 unreviewed"]}`} + out, err := CoverageGate(context.Background(), Deps{AI: aiSeam}, CoverageGateInput{ + Anatomy: schemas.AnatomyResult{ + Clusters: []schemas.ChangeCluster{{ID: "cluster_0", Name: "root", Files: []string{"a.go"}}}, + }, + ReviewedClusters: []string{"cluster_0"}, + DimensionNamesReviewed: []string{"Dim A"}, + }) + if err != nil { + t.Fatal(err) + } + wantKeys(t, out, "fully_covered", "gap_descriptions", "confident") + if out["fully_covered"] != false || out["confident"] != true { + t.Fatalf("got %v", out) + } + if aiSeam.gotSystem != prompts.CoverageGateSystem { + t.Fatalf("system = %q", aiSeam.gotSystem) + } + if !strings.Contains(aiSeam.gotPrompt, "Dimensions already reviewed: Dim A.") { + t.Fatalf("prompt = %q", aiSeam.gotPrompt) + } + + // Empty response object: everything seeded. + aiSeam = &fakeAI{text: `{}`} + out, err = CoverageGate(context.Background(), Deps{AI: aiSeam}, CoverageGateInput{}) + if err != nil { + t.Fatal(err) + } + if out["confident"] != true || out["fully_covered"] != false { + t.Fatalf("seeds wrong: %v", out) + } + if len(out["gap_descriptions"].([]any)) != 0 { + t.Fatalf("gap_descriptions = %v", out["gap_descriptions"]) + } +} + +// --- error propagation ------------------------------------------------------------- + +type erroringHarness struct{} + +func (erroringHarness) Harness(context.Context, string, map[string]any, any, harness.Options) (*harness.Result, error) { + return nil, context.DeadlineExceeded +} + +// Contract: harness transport errors propagate out of every harness-backed +// reasoner (Python lets the exception escape). +func TestHarnessErrorPropagates(t *testing.T) { + deps := Deps{Harness: erroringHarness{}} + if _, err := AnatomyPhase(context.Background(), deps, AnatomyInput{PRData: fixturePR()}); err == nil { + t.Fatal("anatomy: want error") + } + if _, err := ReviewDimension(context.Background(), deps, ReviewDimensionInput{TargetFiles: []string{"a"}}); err == nil { + t.Fatal("review_dimension: want error") + } + if _, err := VerifyObligation(context.Background(), deps, VerifyObligationInput{Obligation: map[string]any{}}); err == nil { + t.Fatal("verify_obligation: want error") + } +} + +// fakeAISeq scripts a SEQUENCE of .ai() responses (one per call), for the +// parse-retry contract of aiStructured. +type fakeAISeq struct { + texts []string + err error + calls int +} + +func (f *fakeAISeq) AI(_ context.Context, _ string, _ ...ai.Option) (*ai.Response, error) { + f.calls++ + if f.err != nil { + return nil, f.err + } + i := f.calls - 1 + if i >= len(f.texts) { + i = len(f.texts) - 1 + } + return &ai.Response{Choices: []ai.Choice{{Message: ai.Message{Role: "assistant", Content: []ai.ContentPart{{Type: "text", Text: f.texts[i]}}}}}}, nil +} + +// aiStructured mirrors the Python SDK's structured-parse tolerance +// (agent_ai.py:806-846): direct parse, then greedy {...} extraction over +// fenced/prose output, then whole-call retries up to max_parse_retries=2. +func TestAIStructuredParseToleranceMirrorsPython(t *testing.T) { + type gate struct { + PrType string `json:"pr_type"` + Confident bool `json:"confident"` + } + + t.Run("fenced JSON extracted on the first call, no retry", func(t *testing.T) { + f := &fakeAISeq{texts: []string{"```json\n{\"pr_type\":\"feature\",\"confident\":true}\n```"}} + var g gate + if err := aiStructured(context.Background(), f, "p", "s", gate{}, &g); err != nil { + t.Fatalf("aiStructured: %v", err) + } + if f.calls != 1 || g.PrType != "feature" || !g.Confident { + t.Fatalf("calls=%d gate=%+v, want 1 call and parsed fields", f.calls, g) + } + }) + + t.Run("malformed first response retries the LLM call", func(t *testing.T) { + f := &fakeAISeq{texts: []string{"sorry, no data", `{"pr_type":"fix","confident":false}`}} + var g gate + if err := aiStructured(context.Background(), f, "p", "s", gate{}, &g); err != nil { + t.Fatalf("aiStructured: %v", err) + } + if f.calls != 2 || g.PrType != "fix" { + t.Fatalf("calls=%d gate=%+v, want 2 calls and second parse", f.calls, g) + } + }) + + t.Run("all attempts malformed -> 3 calls and the Python error string", func(t *testing.T) { + f := &fakeAISeq{texts: []string{"garbage"}} + var g gate + err := aiStructured(context.Background(), f, "p", "s", gate{}, &g) + if err == nil || !strings.HasPrefix(err.Error(), "Could not parse structured response: ") { + t.Fatalf("err = %v, want Could-not-parse prefix", err) + } + if f.calls != 3 { + t.Fatalf("calls = %d, want 3 (initial + max_parse_retries=2)", f.calls) + } + }) + + t.Run("API error is not parse-retried", func(t *testing.T) { + f := &fakeAISeq{err: errors.New("boom")} + var g gate + if err := aiStructured(context.Background(), f, "p", "s", gate{}, &g); err == nil || err.Error() != "boom" { + t.Fatalf("err = %v, want boom", err) + } + if f.calls != 1 { + t.Fatalf("calls = %d, want 1", f.calls) + } + }) +} diff --git a/go/internal/reasoners/reviewdim.go b/go/internal/reasoners/reviewdim.go new file mode 100644 index 0000000..972af7d --- /dev/null +++ b/go/internal/reasoners/reviewdim.go @@ -0,0 +1,113 @@ +package reasoners + +import ( + "context" + "fmt" + "strings" + "unicode/utf8" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/pr-af/go/internal/harnessx" + "github.com/Agent-Field/pr-af/go/internal/prompts" +) + +// ReviewDimension ports review_dimension: one focused reviewer over an +// assigned dimension, with optional sub-review spawning below max depth. +// +// Output keys (§B.2): findings, sub_reviews, current_depth. A schema parse +// failure logs loudly (indistinguishable from a clean review otherwise) and +// reports zero findings — Python prints and substitutes an empty +// _ReviewFindingsResult. +func ReviewDimension(ctx context.Context, deps Deps, in ReviewDimensionInput) (map[string]any, error) { + canSpawn := in.CurrentDepth < in.MaxDepth + + // The prompt builder embeds file references for oversized diff/primed + // sections under these exact conditions; the writes are the reasoner's job. + if len(in.DiffPatches) > 0 { + var parts []string + for _, path := range in.TargetFiles { + if patch, ok := in.DiffPatches[path]; ok && patch != "" { + parts = append(parts, "### "+path+"\n```diff\n"+patch+"\n```") + } + } + if len(parts) > 0 { + patchesText := strings.Join(parts, "\n\n") + if in.RepoPath != "" && utf8.RuneCountInString(patchesText) > 6000 { + if _, err := writeContextFile(patchesText, "review_dimension_diff_patches.md", in.RepoPath); err != nil { + return nil, err + } + } + } + } + if in.PrimedCode != "" && in.RepoPath != "" && utf8.RuneCountInString(in.PrimedCode) > 6000 { + if _, err := writeContextFile(in.PrimedCode, "review_dimension_primed_code.md", in.RepoPath); err != nil { + return nil, err + } + } + + prompt := prompts.ReviewDimensionPrompt(prompts.ReviewDimensionOptions{ + ReviewPrompt: in.ReviewPrompt, + TargetFiles: in.TargetFiles, + ContextFiles: in.ContextFiles, + RepoPath: in.RepoPath, + CurrentDepth: in.CurrentDepth, + MaxDepth: in.MaxDepth, + PrNarrative: in.PrNarrative, + RiskSurfaces: in.RiskSurfaces, + IntakeSummary: in.IntakeSummary, + DiffPatches: in.DiffPatches, + AllDimensionNames: in.AllDimensionNames, + ReviewerFeedback: in.ReviewerFeedback, + PrimedCode: in.PrimedCode, + }) + + parsed, res, err := harnessx.Run[reviewFindingsResult](ctx, deps.Harness, prompt, harness.Options{Cwd: in.RepoPath}) + if err != nil { + return nil, err + } + if res == nil || res.Parsed == nil { + // Schema parse failed entirely — don't silently report "0 findings", + // which is indistinguishable from a clean review. Make it visible. + errMsg := "None" + if res != nil && res.ErrorMessage != "" { + errMsg = "'" + res.ErrorMessage + "'" + } + fmt.Printf( + "[PR-AF] review_dimension: schema parse failed — treating as 0 findings for this dimension (error=%s)\n", + errMsg, + ) + } + result := *parsed + for i := range result.Findings { + result.Findings[i].Tags = orEmptyStrs(result.Findings[i].Tags) + } + + subReviewDicts := []any{} + if canSpawn && len(result.SubReviews) > 0 { + // Python: `for sr in parsed.sub_reviews[:2] if sr.review_prompt and + // sr.target_files` — slice first, then filter. + for _, sr := range capN(result.SubReviews, 2) { + if sr.ReviewPrompt == "" || len(sr.TargetFiles) == 0 { + continue + } + subReviewDicts = append(subReviewDicts, map[string]any{ + "reason": sr.Reason, + "review_prompt": sr.ReviewPrompt, + "target_files": orEmptyStrs(sr.TargetFiles), + "context_files": orEmptyStrs(sr.ContextFiles), + "priority": sr.Priority, + }) + } + } + + findings, err := dumpSlice(result.Findings) + if err != nil { + return nil, err + } + return map[string]any{ + "findings": findings, + "sub_reviews": subReviewDicts, + "current_depth": in.CurrentDepth, + }, nil +} diff --git a/go/internal/reasoners/schemas.go b/go/internal/reasoners/schemas.go new file mode 100644 index 0000000..1af2f9a --- /dev/null +++ b/go/internal/reasoners/schemas.go @@ -0,0 +1,237 @@ +package reasoners + +import ( + "encoding/json" + + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// This file ports the private harness-result models declared at module level in +// reasoners/harnesses.py (design §C.1 "private harness-result structs"). They +// live here — not in internal/schemas — because nothing outside the reasoners +// consumes them: they are only the typed targets of harnessx.Run. +// +// Every struct with at least one non-zero pydantic default (or a +// default_factory=list field) gets an UnmarshalJSON that seeds the default +// before decoding (the seed-then-`type alias` idiom from schemas/defaults.go), +// so harnessx.Run's seeded parse-failure fallback and absent-key decoding both +// match pydantic exactly. Note _SubReviewRequest is structurally identical to +// schemas.SubReviewRequest (same fields, defaults, and json tags), so the +// committed schemas struct is reused instead of duplicated. + +// anatomySemanticResult ports _AnatomySemanticResult. +type anatomySemanticResult struct { + PrNarrative string `json:"pr_narrative"` + RiskSurfaces []string `json:"risk_surfaces"` + UnrelatedChanges []string `json:"unrelated_changes"` + IntentGaps []string `json:"intent_gaps"` + ContextNotes string `json:"context_notes"` +} + +func (a *anatomySemanticResult) UnmarshalJSON(b []byte) error { + *a = anatomySemanticResult{ + RiskSurfaces: []string{}, + UnrelatedChanges: []string{}, + IntentGaps: []string{}, + } + type alias anatomySemanticResult + return json.Unmarshal(b, (*alias)(a)) +} + +// reviewFindingsResult ports _ReviewFindingsResult. The sub_reviews element +// type reuses schemas.SubReviewRequest (identical to _SubReviewRequest). +type reviewFindingsResult struct { + Findings []schemas.ReviewFinding `json:"findings"` + SubReviews []schemas.SubReviewRequest `json:"sub_reviews"` +} + +func (r *reviewFindingsResult) UnmarshalJSON(b []byte) error { + *r = reviewFindingsResult{ + Findings: []schemas.ReviewFinding{}, + SubReviews: []schemas.SubReviewRequest{}, + } + type alias reviewFindingsResult + return json.Unmarshal(b, (*alias)(r)) +} + +// compoundFinding ports _CompoundFinding. +type compoundFinding struct { + Title string `json:"title"` + Severity schemas.Severity `json:"severity"` + FilePath string `json:"file_path"` + LineStart int `json:"line_start"` + LineEnd int `json:"line_end"` + Body string `json:"body"` + Evidence string `json:"evidence"` + Suggestion *string `json:"suggestion"` + Confidence float64 `json:"confidence"` + Tags []string `json:"tags"` + ContributingFindings []string `json:"contributing_findings"` +} + +func (c *compoundFinding) UnmarshalJSON(b []byte) error { + *c = compoundFinding{ + Severity: schemas.DefaultSeverity, + Confidence: 0.5, + Tags: []string{}, + ContributingFindings: []string{}, + } + type alias compoundFinding + return json.Unmarshal(b, (*alias)(c)) +} + +// compoundResult ports _CompoundResult. +type compoundResult struct { + Findings []compoundFinding `json:"findings"` +} + +func (c *compoundResult) UnmarshalJSON(b []byte) error { + *c = compoundResult{Findings: []compoundFinding{}} + type alias compoundResult + return json.Unmarshal(b, (*alias)(c)) +} + +// postWorthinessResult ports _PostWorthinessResult. +type postWorthinessResult struct { + KeepIndices []int `json:"keep_indices"` + Reasoning string `json:"reasoning"` +} + +func (p *postWorthinessResult) UnmarshalJSON(b []byte) error { + *p = postWorthinessResult{KeepIndices: []int{}} + type alias postWorthinessResult + return json.Unmarshal(b, (*alias)(p)) +} + +// compoundDedupResult ports _CompoundDedupResult. +type compoundDedupResult struct { + KeepIndices []int `json:"keep_indices"` + Reasoning string `json:"reasoning"` +} + +func (c *compoundDedupResult) UnmarshalJSON(b []byte) error { + *c = compoundDedupResult{KeepIndices: []int{}} + type alias compoundDedupResult + return json.Unmarshal(b, (*alias)(c)) +} + +// adversaryPhaseResult ports _AdversaryPhaseResult. +type adversaryPhaseResult struct { + Results []schemas.AdversaryResult `json:"results"` +} + +func (a *adversaryPhaseResult) UnmarshalJSON(b []byte) error { + *a = adversaryPhaseResult{Results: []schemas.AdversaryResult{}} + type alias adversaryPhaseResult + return json.Unmarshal(b, (*alias)(a)) +} + +// verifiedFinding ports _VerifiedFinding. revised_severity is a plain string in +// Python (str = ""), NOT the Severity enum — kept as-is so an off-vocabulary +// label survives to the orchestrator, which normalizes it there. +type verifiedFinding struct { + Title string `json:"title"` + Verified bool `json:"verified"` + ActualBehavior string `json:"actual_behavior"` + RevisedSeverity string `json:"revised_severity"` + RevisedConfidence float64 `json:"revised_confidence"` + VerificationNotes string `json:"verification_notes"` +} + +func (v *verifiedFinding) UnmarshalJSON(b []byte) error { + *v = verifiedFinding{Verified: true, RevisedConfidence: 0.5} + type alias verifiedFinding + return json.Unmarshal(b, (*alias)(v)) +} + +// verificationResult ports _VerificationResult. +type verificationResult struct { + VerifiedFindings []verifiedFinding `json:"verified_findings"` +} + +func (v *verificationResult) UnmarshalJSON(b []byte) error { + *v = verificationResult{VerifiedFindings: []verifiedFinding{}} + type alias verificationResult + return json.Unmarshal(b, (*alias)(v)) +} + +// deepenFinding ports _DeepenFinding (seeds dimension_id="literal-verify", +// dimension_name="Literal-Correctness Verifier", severity="important", +// confidence=0.7). +type deepenFinding struct { + DimensionID string `json:"dimension_id"` + DimensionName string `json:"dimension_name"` + FilePath string `json:"file_path"` + LineStart int `json:"line_start"` + LineEnd int `json:"line_end"` + Severity schemas.Severity `json:"severity"` + Title string `json:"title"` + Body string `json:"body"` + Suggestion *string `json:"suggestion"` + Evidence string `json:"evidence"` + Confidence float64 `json:"confidence"` + Tags []string `json:"tags"` +} + +func (d *deepenFinding) UnmarshalJSON(b []byte) error { + *d = deepenFinding{ + DimensionID: "literal-verify", + DimensionName: "Literal-Correctness Verifier", + Severity: "important", + Confidence: 0.7, + Tags: []string{}, + } + type alias deepenFinding + return json.Unmarshal(b, (*alias)(d)) +} + +// deepenResult ports _DeepenResult. +type deepenResult struct { + Findings []deepenFinding `json:"findings"` +} + +func (d *deepenResult) UnmarshalJSON(b []byte) error { + *d = deepenResult{Findings: []deepenFinding{}} + type alias deepenResult + return json.Unmarshal(b, (*alias)(d)) +} + +// obligation ports _Obligation. +type obligation struct { + ID string `json:"id"` + Where string `json:"where"` // the changed line/operation that creates the reliance + ReliesOn string `json:"relies_on"` // the OTHER location/fact to go find and read + Property string `json:"property"` // the exact thing that must hold for correctness +} + +// obligationsResult ports _ObligationsResult. +type obligationsResult struct { + Obligations []obligation `json:"obligations"` +} + +func (o *obligationsResult) UnmarshalJSON(b []byte) error { + *o = obligationsResult{Obligations: []obligation{}} + type alias obligationsResult + return json.Unmarshal(b, (*alias)(o)) +} + +// obligationVerdict ports _ObligationVerdict (seeds holds=true, +// severity="important", confidence=0.7). +type obligationVerdict struct { + Holds bool `json:"holds"` + Title string `json:"title"` + Severity schemas.Severity `json:"severity"` + FilePath string `json:"file_path"` + LineStart int `json:"line_start"` + LineEnd int `json:"line_end"` + Body string `json:"body"` + Evidence string `json:"evidence"` + Suggestion *string `json:"suggestion"` + Confidence float64 `json:"confidence"` +} + +func (o *obligationVerdict) UnmarshalJSON(b []byte) error { + *o = obligationVerdict{Holds: true, Severity: "important", Confidence: 0.7} + type alias obligationVerdict + return json.Unmarshal(b, (*alias)(o)) +} diff --git a/go/internal/reasoners/schemas_drift_test.go b/go/internal/reasoners/schemas_drift_test.go new file mode 100644 index 0000000..680b886 --- /dev/null +++ b/go/internal/reasoners/schemas_drift_test.go @@ -0,0 +1,150 @@ +package reasoners + +import ( + "reflect" + "sort" + "strings" + "testing" + + "github.com/Agent-Field/pr-af/go/internal/harnessx" + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// Drift guard for the committed pydantic schema fixtures. Every destination type +// registered in schemas_registry.go is validated on two fronts: +// +// 1. Its type is actually registered (harnessx.RegisteredSchema resolves). +// 2. Every OBJECT node in the embedded schema — the root and each $defs entry — +// has a property-key set that EXACTLY matches the json tags of the Go struct +// it decodes into. +// +// (2) is the load-bearing check: if someone adds/removes/renames a field on a Go +// struct (or its Python source) without regenerating the fixtures, the key sets +// diverge and this test fails loudly, pointing at the exact type. It also proves +// the standing invariant the fix depends on — every schema property is decodable +// into the struct and every struct field is described by the schema. + +// rootTypes maps each registered destination type to its embedded fixture. +var rootTypes = map[reflect.Type]string{ + reflect.TypeOf(adversaryPhaseResult{}): "AdversaryPhaseResult", + reflect.TypeOf(anatomySemanticResult{}): "AnatomySemanticResult", + reflect.TypeOf(compoundDedupResult{}): "CompoundDedupResult", + reflect.TypeOf(compoundResult{}): "CompoundResult", + reflect.TypeOf(deepenResult{}): "DeepenResult", + reflect.TypeOf(obligationVerdict{}): "ObligationVerdict", + reflect.TypeOf(obligationsResult{}): "ObligationsResult", + reflect.TypeOf(postWorthinessResult{}): "PostWorthinessResult", + reflect.TypeOf(reviewFindingsResult{}): "ReviewFindingsResult", + reflect.TypeOf(verificationResult{}): "VerificationResult", + reflect.TypeOf(schemas.IntakeResult{}): "IntakeResult", + reflect.TypeOf(schemas.MetaDimensionResult{}): "MetaDimensionResult", + reflect.TypeOf(schemas.ReviewPlan{}): "ReviewPlan", +} + +// defTypes maps each nested $defs title (as pydantic emits it — private models +// keep their leading underscore in the JSON, unlike the fixture filenames) to +// the Go struct that $def decodes into. A $defs entry with no mapping here fails +// the test, forcing every nested type to be accounted for. +var defTypes = map[string]reflect.Type{ + "AdversaryResult": reflect.TypeOf(schemas.AdversaryResult{}), + "BudgetAllocation": reflect.TypeOf(schemas.BudgetAllocation{}), + "ReviewDimension": reflect.TypeOf(schemas.ReviewDimension{}), + "ReviewFinding": reflect.TypeOf(schemas.ReviewFinding{}), + "_CompoundFinding": reflect.TypeOf(compoundFinding{}), + "_DeepenFinding": reflect.TypeOf(deepenFinding{}), + "_Obligation": reflect.TypeOf(obligation{}), + "_SubReviewRequest": reflect.TypeOf(schemas.SubReviewRequest{}), + "_VerifiedFinding": reflect.TypeOf(verifiedFinding{}), +} + +func TestEmbeddedSchemasMatchGoStructs(t *testing.T) { + for rt, fixture := range rootTypes { + rt, fixture := rt, fixture + t.Run(fixture, func(t *testing.T) { + schema, ok := harnessx.RegisteredSchema(rt) + if !ok { + t.Fatalf("%s (%s) is not registered — schemas_registry.go and gen_schemas.go are out of sync", + rt.Name(), fixture) + } + + // Root object node ↔ the registered struct. + assertPropsMatchTags(t, "root", schemaPropertyKeys(schema), structTagKeys(rt)) + + // Every $defs object node ↔ its mapped struct. + defs, _ := schema["$defs"].(map[string]any) + for name, raw := range defs { + node, ok := raw.(map[string]any) + if !ok { + continue + } + dt, mapped := defTypes[name] + if !mapped { + t.Fatalf("$defs.%s has no Go struct mapping in defTypes — add it so drift is caught", name) + } + assertPropsMatchTags(t, "$defs."+name, schemaPropertyKeys(node), structTagKeys(dt)) + } + }) + } +} + +// assertPropsMatchTags asserts bidirectional set equality between a schema +// node's property names and a struct's json tag names. +func assertPropsMatchTags(t *testing.T, where string, schemaKeys, tagKeys map[string]bool) { + t.Helper() + for k := range schemaKeys { + if !tagKeys[k] { + t.Errorf("%s: schema property %q has no matching json tag on the Go struct (undecodable / stale fixture?)", where, k) + } + } + for k := range tagKeys { + if !schemaKeys[k] { + t.Errorf("%s: struct json tag %q is absent from the schema (regenerate fixtures via gen_schemas.py?)", where, k) + } + } +} + +// schemaPropertyKeys returns the property-name set of an object schema node. +func schemaPropertyKeys(node map[string]any) map[string]bool { + out := map[string]bool{} + props, _ := node["properties"].(map[string]any) + for k := range props { + out[k] = true + } + return out +} + +// structTagKeys returns the json-tag names of a struct type's fields, skipping +// json:"-" and honoring the ",omitempty"/",string" option suffixes. +func structTagKeys(t reflect.Type) map[string]bool { + out := map[string]bool{} + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + tag := f.Tag.Get("json") + if tag == "-" { + continue + } + name := tag + if comma := strings.IndexByte(tag, ','); comma >= 0 { + name = tag[:comma] + } + if name == "" { + name = f.Name + } + out[name] = true + } + return out +} + +// TestAllRunDestinationsRegistered is a lightweight cross-check that the number +// of registered fixtures matches the number of rootTypes the drift test walks, +// so a newly added Run[T] destination cannot silently skip registration. +func TestRootTypeCountMatchesFixtures(t *testing.T) { + names := make([]string, 0, len(rootTypes)) + for _, n := range rootTypes { + names = append(names, n) + } + sort.Strings(names) + if len(names) != 13 { + t.Fatalf("expected 13 registered Run[T] destination fixtures, got %d: %v", len(names), names) + } +} diff --git a/go/internal/reasoners/schemas_registry.go b/go/internal/reasoners/schemas_registry.go new file mode 100644 index 0000000..2ffae9a --- /dev/null +++ b/go/internal/reasoners/schemas_registry.go @@ -0,0 +1,48 @@ +package reasoners + +import ( + "reflect" + + "github.com/Agent-Field/pr-af/go/internal/harnessx" + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// This file wires every destination type used with harnessx.Run[T] to its +// committed pydantic-generated schema fixture (go/internal/harnessx/testdata/ +// schemas/<Name>.json, produced by go/scripts/gen_schemas.py). harnessx.Run's +// schemaFor[T] consults this registry so the Go SDK validates parsed harness +// output against the SAME schema the Python SDK builds from the corresponding +// pydantic model — restoring parity on the required/nullable/additionalProperties +// axes that invopop reflection broke (design §C.3 HIGH-severity fix). +// +// Registration lives HERE, not in harnessx, because the reasoners package owns +// the destination types (the private harness-result structs below plus the +// schemas.* pipeline types) and already imports harnessx — so there is no import +// cycle. init() runs before any Run[T] call: a reasoner that calls Run[T] is in +// this package, whose init() therefore executes first. +// +// The fixture basename is the Python model name with any leading underscore +// stripped (go:embed skips "_"-prefixed filenames). Each mapping is 1:1 with an +// entry in gen_schemas.py's MODELS; the drift test cross-checks both ends. +func init() { + reg := func(v any, fixture string) { + harnessx.RegisterSchema(reflect.TypeOf(v), fixture) + } + + // Private harness-result models (reasoners/harnesses.py -> schemas.go here). + reg(adversaryPhaseResult{}, "AdversaryPhaseResult") + reg(anatomySemanticResult{}, "AnatomySemanticResult") + reg(compoundDedupResult{}, "CompoundDedupResult") + reg(compoundResult{}, "CompoundResult") + reg(deepenResult{}, "DeepenResult") + reg(obligationVerdict{}, "ObligationVerdict") + reg(obligationsResult{}, "ObligationsResult") + reg(postWorthinessResult{}, "PostWorthinessResult") + reg(reviewFindingsResult{}, "ReviewFindingsResult") + reg(verificationResult{}, "VerificationResult") + + // Public pipeline models (schemas/pipeline.py -> internal/schemas). + reg(schemas.IntakeResult{}, "IntakeResult") + reg(schemas.MetaDimensionResult{}, "MetaDimensionResult") + reg(schemas.ReviewPlan{}, "ReviewPlan") +} diff --git a/go/internal/reasoners/verify.go b/go/internal/reasoners/verify.go new file mode 100644 index 0000000..4205942 --- /dev/null +++ b/go/internal/reasoners/verify.go @@ -0,0 +1,41 @@ +package reasoners + +import ( + "context" + "unicode/utf8" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/pr-af/go/internal/harnessx" + "github.com/Agent-Field/pr-af/go/internal/prompts" +) + +// EvidenceVerifier ports evidence_verifier: independent verification of +// critical/important findings against programmatically extracted ground-truth +// code, before the adversarial phase. +// +// Output keys (§B.2): verified_findings. Parse failure degrades to an empty +// list. +func EvidenceVerifier(ctx context.Context, deps Deps, in EvidenceVerifierInput) (map[string]any, error) { + evMap := evidenceOMaps(in.EvidencePackages) + + // The builder embeds a file reference when the findings JSON exceeds 12000 + // characters and a repo path exists; the write is the reasoner's job. + findingsText := evidenceVerifierContext(in.Findings, evMap) + if utf8.RuneCountInString(findingsText) > 12000 && in.RepoPath != "" { + if _, err := writeContextFile(findingsText, "verification_findings.json", in.RepoPath); err != nil { + return nil, err + } + } + + prompt := prompts.EvidenceVerifierPrompt(in.Findings, evMap, in.PrContext, in.RepoPath) + parsed, _, err := harnessx.Run[verificationResult](ctx, deps.Harness, prompt, harness.Options{Cwd: in.RepoPath}) + if err != nil { + return nil, err + } + verified, err := dumpSlice(parsed.VerifiedFindings) + if err != nil { + return nil, err + } + return map[string]any{"verified_findings": verified}, nil +} diff --git a/go/internal/reasoners/worthiness.go b/go/internal/reasoners/worthiness.go new file mode 100644 index 0000000..b74afe9 --- /dev/null +++ b/go/internal/reasoners/worthiness.go @@ -0,0 +1,61 @@ +package reasoners + +import ( + "context" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/pr-af/go/internal/harnessx" + "github.com/Agent-Field/pr-af/go/internal/prompts" + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// PostWorthinessGate ports post_worthiness_gate: an experienced-reviewer +// judgment over which findings are genuinely worth posting (recall-preserving: +// unsure -> keep). +// +// Output keys (§B.2): keep_indices, reasoning — EXCEPT the <=1-finding +// short-circuit, which Python returns as {"keep_indices": [...]} with no +// reasoning key. Empty/invalid harness indices (including parse failure) +// degrade to keep-everything, never silencing all findings. +func PostWorthinessGate(ctx context.Context, deps Deps, in PostWorthinessInput) (map[string]any, error) { + n := len(in.Findings) + if n <= 1 { + // Python: `return {"keep_indices": list(range(len(findings)))}` — no + // reasoning key on this branch. + return map[string]any{"keep_indices": rangeInts(n)}, nil + } + + // The committed prompt builder is typed over ScoredFinding; the live path + // feeds ReviewFinding dumps. The prompt reads only the fields the two + // shapes share (severity, file_path, line_start, title, body, evidence), + // so a straight field copy is exact. + scored := make([]schemas.ScoredFinding, n) + for i, f := range in.Findings { + scored[i] = schemas.ScoredFinding{ + Severity: f.Severity, + FilePath: f.FilePath, + LineStart: f.LineStart, + Title: f.Title, + Body: f.Body, + Evidence: f.Evidence, + } + } + prompt := prompts.PostWorthinessPrompt(scored) + + parsed, _, err := harnessx.Run[postWorthinessResult](ctx, deps.Harness, prompt, harness.Options{}) + if err != nil { + return nil, err + } + + keep := []int{} + for _, i := range parsed.KeepIndices { + if i >= 0 && i < n { + keep = append(keep, i) + } + } + if len(keep) == 0 { // never silence everything on a parse/judgment failure + keep = rangeInts(n) + } + return map[string]any{"keep_indices": keep, "reasoning": parsed.Reasoning}, nil +} diff --git a/go/internal/schemas/defaults.go b/go/internal/schemas/defaults.go new file mode 100644 index 0000000..7857467 --- /dev/null +++ b/go/internal/schemas/defaults.go @@ -0,0 +1,179 @@ +package schemas + +import "encoding/json" + +// This file implements non-zero-default seeding for every struct that has at +// least one field whose pydantic default is not the Go zero value (design §C.1 +// seeding table). Go's json.Unmarshal leaves an absent key at the Go zero +// value, whereas pydantic fills the declared default; each struct below gets a +// defaultXxx() constructor (also usable as the deterministic fallback a role +// returns on a harness parse-failure) plus an UnmarshalJSON that seeds the +// default before decoding — so an absent key keeps the default while a present +// key (even false/0/"") overrides it, matching pydantic exactly. +// +// The `type alias X` trick strips X's methods (including UnmarshalJSON) so the +// inner json.Unmarshal does not recurse; nested field types (e.g. Severity, +// BudgetAllocation) keep their own UnmarshalJSON. +// +// Where a struct in this table has a `default_factory=list` field, the +// constructor seeds it to a non-nil empty slice so that unmarshaling "{}" round +// trips to `[]` (never null), honoring the design's "empty slices marshal as [] +// where Python default is a list" rule for the structs whose constructors this +// package owns. + +// --- input.go --- + +func defaultReviewInput() ReviewInput { + return ReviewInput{ + Depth: "auto", + Focus: "auto", + OutputFormat: "github", + SuggestionMode: "comment", + MaxReviewDepth: 2, + IgnorePaths: []string{}, + Hints: []string{}, + } +} + +// UnmarshalJSON seeds ReviewInput's non-zero pydantic defaults. The budget-cap +// pointers (MaxCostUSD / MaxDurationSeconds) stay nil — they are resolved later +// by config.ResolveBudgetCaps / ReviewConfig.FromInput. +func (r *ReviewInput) UnmarshalJSON(b []byte) error { + *r = defaultReviewInput() + type alias ReviewInput + return json.Unmarshal(b, (*alias)(r)) +} + +// --- pipeline.go --- + +func defaultBudgetAllocation() BudgetAllocation { + return BudgetAllocation{ + MaxCostUSD: 0.5, + MaxDurationSeconds: 60, + MaxReferenceFollows: 3, + MaxChildSpawns: 2, + } +} + +// UnmarshalJSON seeds BudgetAllocation defaults (0.5 / 60 / 3 / 2). +func (a *BudgetAllocation) UnmarshalJSON(b []byte) error { + *a = defaultBudgetAllocation() + type alias BudgetAllocation + return json.Unmarshal(b, (*alias)(a)) +} + +func defaultReviewDimension() ReviewDimension { + return ReviewDimension{ + ContextFiles: []string{}, + Priority: 1, + Budget: defaultBudgetAllocation(), + } +} + +// UnmarshalJSON seeds ReviewDimension.Priority=1 and a default Budget. When a +// "budget" key is present, BudgetAllocation's own UnmarshalJSON re-seeds it +// before applying the overrides. +func (d *ReviewDimension) UnmarshalJSON(b []byte) error { + *d = defaultReviewDimension() + type alias ReviewDimension + return json.Unmarshal(b, (*alias)(d)) +} + +func defaultSubReviewRequest() SubReviewRequest { + return SubReviewRequest{ + ContextFiles: []string{}, + Priority: 1, + } +} + +// UnmarshalJSON seeds SubReviewRequest.Priority=1. +func (s *SubReviewRequest) UnmarshalJSON(b []byte) error { + *s = defaultSubReviewRequest() + type alias SubReviewRequest + return json.Unmarshal(b, (*alias)(s)) +} + +func defaultReviewFinding() ReviewFinding { + return ReviewFinding{ + Severity: DefaultSeverity, + Confidence: 0.5, + Tags: []string{}, + } +} + +// UnmarshalJSON seeds ReviewFinding.Confidence=0.5, Severity="suggestion". +func (f *ReviewFinding) UnmarshalJSON(b []byte) error { + *f = defaultReviewFinding() + type alias ReviewFinding + return json.Unmarshal(b, (*alias)(f)) +} + +func defaultAdversaryResult() AdversaryResult { + return AdversaryResult{SeverityAdjustment: "none"} +} + +// UnmarshalJSON seeds AdversaryResult.SeverityAdjustment="none". +func (a *AdversaryResult) UnmarshalJSON(b []byte) error { + *a = defaultAdversaryResult() + type alias AdversaryResult + return json.Unmarshal(b, (*alias)(a)) +} + +func defaultMetaDimensionResult() MetaDimensionResult { + return MetaDimensionResult{Confidence: 0.7} +} + +// UnmarshalJSON seeds MetaDimensionResult.Confidence=0.7. Dimensions is a +// required field in Python (no default), so it is not seeded. +func (m *MetaDimensionResult) UnmarshalJSON(b []byte) error { + *m = defaultMetaDimensionResult() + type alias MetaDimensionResult + return json.Unmarshal(b, (*alias)(m)) +} + +// --- gates.go --- + +func defaultCoverageGate() CoverageGate { + return CoverageGate{ + GapDescriptions: []string{}, + Confident: true, + } +} + +// UnmarshalJSON seeds CoverageGate.Confident=true. +func (c *CoverageGate) UnmarshalJSON(b []byte) error { + *c = defaultCoverageGate() + type alias CoverageGate + return json.Unmarshal(b, (*alias)(c)) +} + +// --- output.go --- + +func defaultScoredFinding() ScoredFinding { + return ScoredFinding{ + DiffSide: "RIGHT", + Severity: DefaultSeverity, + Confidence: 0.5, + Tags: []string{}, + ActiveMultipliers: []string{}, + } +} + +// UnmarshalJSON seeds ScoredFinding.DiffSide="RIGHT", Confidence=0.5, +// Severity="suggestion". +func (s *ScoredFinding) UnmarshalJSON(b []byte) error { + *s = defaultScoredFinding() + type alias ScoredFinding + return json.Unmarshal(b, (*alias)(s)) +} + +func defaultGitHubComment() GitHubComment { + return GitHubComment{Side: "RIGHT"} +} + +// UnmarshalJSON seeds GitHubComment.Side="RIGHT". +func (c *GitHubComment) UnmarshalJSON(b []byte) error { + *c = defaultGitHubComment() + type alias GitHubComment + return json.Unmarshal(b, (*alias)(c)) +} diff --git a/go/internal/schemas/gates.go b/go/internal/schemas/gates.go new file mode 100644 index 0000000..3b2c430 --- /dev/null +++ b/go/internal/schemas/gates.go @@ -0,0 +1,23 @@ +package schemas + +// This file ports the flat .ai() gate schemas from schemas/gates.py that the +// design §C.1 keeps: IntakeGate and CoverageGate. (schemas/gates.py also +// defines DedupGate, which the ported pipeline does not use — the compound +// dedup phase returns keep_indices/reasoning instead — so it is intentionally +// omitted per §C.1.) + +// IntakeGate is the fast PR classification. It escalates to .harness() when not +// confident. No non-zero defaults, so not seeded. +type IntakeGate struct { + PrType string `json:"pr_type"` // feature | bugfix | refactor | docs | infra | mixed + Complexity string `json:"complexity"` // trivial | standard | complex | massive + Confident bool `json:"confident"` // Whether classification is confident enough +} + +// CoverageGate checks whether the review plan covered all change clusters. +// Seeded (defaults.go): confident=true, gap_descriptions=[]. +type CoverageGate struct { + FullyCovered bool `json:"fully_covered"` + GapDescriptions []string `json:"gap_descriptions"` + Confident bool `json:"confident"` +} diff --git a/go/internal/schemas/input.go b/go/internal/schemas/input.go new file mode 100644 index 0000000..2e47ba8 --- /dev/null +++ b/go/internal/schemas/input.go @@ -0,0 +1,74 @@ +package schemas + +// This file ports schemas/input.py — the pipeline entry-point structs. + +// ReviewInput is the top-level input to the PR-AF pipeline. +// +// Divergence from schemas/input.py, deliberate (design §C.1): the pydantic +// ReviewInput types max_cost_usd / max_duration_seconds as non-nullable float / +// int with concrete defaults (2.0 / 300). The Go struct instead mirrors the +// actual node entry point — app.py review()'s signature, where both are +// `... | None = None` — so they are *float64 / *int and stay nil until +// config.ResolveBudgetCaps / config.ReviewConfig.FromInput resolve the +// explicit-arg > env > default (2.0 / 300) cascade. This keeps the "was a cap +// explicitly supplied?" signal that Python recovers from the review() defaults. +type ReviewInput struct { + // Mode 1: GitHub PR URL. + PrURL *string `json:"pr_url"` + // Mode 2: Raw diff. + DiffText *string `json:"diff_text"` + // Mode 3: Local repo. + RepoPath *string `json:"repo_path"` + BaseRef *string `json:"base_ref"` + HeadRef *string `json:"head_ref"` + + // Configuration overrides. + Depth string `json:"depth"` // auto | quick | standard | deep + MaxCostUSD *float64 `json:"max_cost_usd"` + MaxDurationSeconds *int `json:"max_duration_seconds"` + Focus string `json:"focus"` // auto | security | correctness | performance | tests + IgnorePaths []string `json:"ignore_paths"` + Hints []string `json:"hints"` // Project-specific review hints + + // Model overrides (per-call). Keys match ModelConfig field names. + Models map[string]string `json:"models"` + + // Budget overrides. + MaxConcurrentReviewers *int `json:"max_concurrent_reviewers"` + MaxCoverageIterations *int `json:"max_coverage_iterations"` + MaxReviewDepth int `json:"max_review_depth"` // 1=flat, 2=one sub-level, 3=max + + // Output. + OutputFormat string `json:"output_format"` // github | json | sarif | markdown + DryRun bool `json:"dry_run"` // Don't post to GitHub, just return findings + PostPRNumber *int `json:"post_pr_number"` + + // Comment formatting. + SuggestionMode string `json:"suggestion_mode"` // comment | code +} + +// GitHubPRData is the data fetched from the GitHub API for a pull request. +type GitHubPRData struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Number int `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + Labels []string `json:"labels"` + Author string `json:"author"` + BaseSHA string `json:"base_sha"` + HeadSHA string `json:"head_sha"` + CommitMessages []string `json:"commit_messages"` + Diff string `json:"diff"` // Unified diff text + ChangedFiles []ChangedFile `json:"changed_files"` +} + +// ChangedFile is a single file changed in the PR. +type ChangedFile struct { + Path string `json:"path"` + Status string `json:"status"` // added | modified | removed | renamed + Additions int `json:"additions"` + Deletions int `json:"deletions"` + Patch string `json:"patch"` // Unified diff patch for this file + PreviousPath *string `json:"previous_path"` // For renames +} diff --git a/go/internal/schemas/output.go b/go/internal/schemas/output.go new file mode 100644 index 0000000..e4062d6 --- /dev/null +++ b/go/internal/schemas/output.go @@ -0,0 +1,91 @@ +package schemas + +// This file ports schemas/output.py — the final pipeline deliverables. + +// ScoredFinding is a finding after scoring, dedup, and line mapping. Seeded +// (defaults.go): diff_side="RIGHT", confidence=0.5, severity="suggestion", +// tags=[], active_multipliers=[]. DiffLine and Suggestion are Optional (nil -> +// null). +type ScoredFinding struct { + ID string `json:"id"` + DimensionID string `json:"dimension_id"` + DimensionName string `json:"dimension_name"` + FilePath string `json:"file_path"` + LineStart int `json:"line_start"` + LineEnd int `json:"line_end"` + DiffLine *int `json:"diff_line"` // Line number in the diff (GitHub API) + DiffSide string `json:"diff_side"` // LEFT (deletion) | RIGHT (addition) + Severity Severity `json:"severity"` + Title string `json:"title"` + Body string `json:"body"` + Suggestion *string `json:"suggestion"` + Evidence string `json:"evidence"` + Confidence float64 `json:"confidence"` + Tags []string `json:"tags"` + Score float64 `json:"score"` + ActiveMultipliers []string `json:"active_multipliers"` + // Orthogonal release-blocker axis. Severity = "how bad", blocking = "must + // fix before merge". Default false so unreviewed findings never falsely + // block merge. + Blocking bool `json:"blocking"` + BlockingReason string `json:"blocking_reason"` +} + +// ReviewSummary holds aggregate statistics about the review. by_severity is a +// dict default in Python — construct it non-nil upstream to marshal as {}. +type ReviewSummary struct { + TotalFindings int `json:"total_findings"` + BySeverity map[string]int `json:"by_severity"` + BlockingCount int `json:"blocking_count"` + AdvisoryCount int `json:"advisory_count"` + DimensionsRun int `json:"dimensions_run"` + // Backward-compatible field name; value now represents synthesized compound + // findings. + CrossRefInteractions int `json:"cross_ref_interactions"` + AdversaryChallenged int `json:"adversary_challenged"` + AdversaryConfirmed int `json:"adversary_confirmed"` + CoverageIterations int `json:"coverage_iterations"` + AIGeneratedConfidence float64 `json:"ai_generated_confidence"` + CostUSD float64 `json:"cost_usd"` + DurationSeconds float64 `json:"duration_seconds"` + BudgetExhausted bool `json:"budget_exhausted"` +} + +// GitHubComment is a single inline comment for the GitHub PR Review API. Seeded +// (defaults.go): side="RIGHT". +type GitHubComment struct { + Path string `json:"path"` + Line int `json:"line"` + Side string `json:"side"` + Body string `json:"body"` +} + +// GitHubReview is the complete GitHub PR review payload. comments is a list +// default in Python — construct it non-nil upstream to marshal as []. +type GitHubReview struct { + Body string `json:"body"` // Executive summary + Event string `json:"event"` // APPROVE | COMMENT | REQUEST_CHANGES + Comments []GitHubComment `json:"comments"` +} + +// ReviewResult is the complete output of the PR-AF pipeline. +type ReviewResult struct { + ReviewID string `json:"review_id"` + PrURL string `json:"pr_url"` + Review GitHubReview `json:"review"` // The GitHub review payload + Findings []ScoredFinding `json:"findings"` // All scored findings + Summary ReviewSummary `json:"summary"` + Metadata ReviewMetadata `json:"metadata"` +} + +// ReviewMetadata holds pipeline metadata for debugging and observability. The +// intake/anatomy/plan/budget dicts and phases_completed list are collection +// defaults in Python — construct them non-nil upstream to marshal as {} / []. +type ReviewMetadata struct { + Intake map[string]any `json:"intake"` + Anatomy map[string]any `json:"anatomy"` + Plan map[string]any `json:"plan"` + Budget map[string]any `json:"budget"` + AgentInvocations int `json:"agent_invocations"` + PhasesCompleted []string `json:"phases_completed"` +} diff --git a/go/internal/schemas/pipeline.go b/go/internal/schemas/pipeline.go new file mode 100644 index 0000000..aff2b90 --- /dev/null +++ b/go/internal/schemas/pipeline.go @@ -0,0 +1,159 @@ +package schemas + +// This file ports schemas/pipeline.py — the structs that flow between pipeline +// phases. Field order follows the Python declarations so model_dump()-style key +// ordering is reproduced for downstream golden comparisons. + +// IntakeResult is Phase 1 output: structured fields for routing plus a +// pr_summary string for LLM context. All fields are required in Python (no +// defaults) so none are seeded. +type IntakeResult struct { + PrType string `json:"pr_type"` // feature | bugfix | refactor | docs | infra | mixed + Complexity string `json:"complexity"` // trivial | standard | complex | massive + Languages []string `json:"languages"` + AreasTouched []string `json:"areas_touched"` // auth, database, api, frontend, config... + RiskSignals []string `json:"risk_signals"` // "touches auth", "modifies schema", ... + AIGenerated float64 `json:"ai_generated"` // 0.0-1.0 confidence + ReviewDepth string `json:"review_depth"` // quick | standard | deep + PrSummary string `json:"pr_summary"` // Brief narrative (string for LLM context) +} + +// Hunk is a single diff hunk within a file. +type Hunk struct { + OldStart int `json:"old_start"` + OldCount int `json:"old_count"` + NewStart int `json:"new_start"` + NewCount int `json:"new_count"` + Header string `json:"header"` // @@ line + Content string `json:"content"` // The actual diff content +} + +// FileChange is a programmatic representation of a single file change. +type FileChange struct { + Path string `json:"path"` + Status string `json:"status"` // added | modified | removed | renamed + Language string `json:"language"` + LinesAdded int `json:"lines_added"` + LinesRemoved int `json:"lines_removed"` + Hunks []Hunk `json:"hunks"` +} + +// ChangeCluster is a group of related file changes (e.g. all auth-related files). +type ChangeCluster struct { + ID string `json:"id"` + Name string `json:"name"` // Human-readable cluster name + Files []string `json:"files"` // File paths in this cluster + PrimaryLanguage string `json:"primary_language"` + Description string `json:"description"` +} + +// DiffStats holds aggregate statistics about the diff. +type DiffStats struct { + TotalFiles int `json:"total_files"` + TotalAdditions int `json:"total_additions"` + TotalDeletions int `json:"total_deletions"` + FilesAdded int `json:"files_added"` + FilesModified int `json:"files_modified"` + FilesRemoved int `json:"files_removed"` + FilesRenamed int `json:"files_renamed"` + TestFilesChanged int `json:"test_files_changed"` + TestToCodeRatio float64 `json:"test_to_code_ratio"` +} + +// AnatomyResult is Phase 2 output: structured clusters for routing plus strings +// for LLM context. +type AnatomyResult struct { + // Structured — consumed by planner for routing. + Files []FileChange `json:"files"` + Clusters []ChangeCluster `json:"clusters"` + BlastRadius []string `json:"blast_radius"` // Files affected but not changed + DependencyGraph map[string][]string `json:"dependency_graph"` // file -> [files that import it] + Stats DiffStats `json:"stats"` + + // String — consumed by planner LLM for reasoning. + PrNarrative string `json:"pr_narrative"` + RiskSurfaces []string `json:"risk_surfaces"` + UnrelatedChanges []string `json:"unrelated_changes"` + IntentGaps []string `json:"intent_gaps"` + ContextNotes string `json:"context_notes"` +} + +// BudgetAllocation is a budget cap for an agent or phase. Seeded (defaults.go): +// max_cost_usd=0.5, max_duration_seconds=60, max_reference_follows=3, +// max_child_spawns=2. +type BudgetAllocation struct { + MaxCostUSD float64 `json:"max_cost_usd"` + MaxDurationSeconds int `json:"max_duration_seconds"` + MaxReferenceFollows int `json:"max_reference_follows"` + MaxChildSpawns int `json:"max_child_spawns"` +} + +// ReviewDimension is one parallel reviewer instance. Seeded (defaults.go): +// priority=1, budget=default BudgetAllocation, context_files=[]. +type ReviewDimension struct { + ID string `json:"id"` + Name string `json:"name"` // Human-readable (attributed in comments) + ReviewPrompt string `json:"review_prompt"` // Dynamically crafted (reviewer LLM prompt) + TargetFiles []string `json:"target_files"` + ContextFiles []string `json:"context_files"` + Priority int `json:"priority"` // Higher = more important = gets budget first + Budget BudgetAllocation `json:"budget"` +} + +// SubReviewRequest is a reviewer's request to spawn a deeper sub-review. Seeded +// (defaults.go): priority=1, context_files=[]. +type SubReviewRequest struct { + Reason string `json:"reason"` + ReviewPrompt string `json:"review_prompt"` + TargetFiles []string `json:"target_files"` + ContextFiles []string `json:"context_files"` + Priority int `json:"priority"` +} + +// ReviewPlan is Phase 3 output: the planner's complete review strategy. +type ReviewPlan struct { + Dimensions []ReviewDimension `json:"dimensions"` + CrossRefHints []string `json:"cross_ref_hints"` // Suspected interactions (string for LLM) + AIAdjusted bool `json:"ai_adjusted"` // Whether plan was adjusted for AI-generated code + TotalBudget BudgetAllocation `json:"total_budget"` +} + +// ReviewFinding is emitted to the findings queue as reviewers work. Seeded +// (defaults.go): confidence=0.5, severity="suggestion", tags=[]. Suggestion is +// Optional (nil -> null). +type ReviewFinding struct { + DimensionID string `json:"dimension_id"` + DimensionName string `json:"dimension_name"` + FilePath string `json:"file_path"` + LineStart int `json:"line_start"` + LineEnd int `json:"line_end"` + HunkContext string `json:"hunk_context"` // Code context around the finding + Severity Severity `json:"severity"` + Title string `json:"title"` + Body string `json:"body"` // Detailed explanation (GitHub comment) + Suggestion *string `json:"suggestion"` // Concrete fix (code block) + Evidence string `json:"evidence"` // Code references supporting this finding + Confidence float64 `json:"confidence"` + Tags []string `json:"tags"` // security, correctness, ... +} + +// AdversaryResult is the adversary reviewer's assessment of a finding. Seeded +// (defaults.go): severity_adjustment="none". HiddenTrap is Optional (nil -> +// null). +type AdversaryResult struct { + FindingTitle string `json:"finding_title"` + Verdict string `json:"verdict"` // confirmed | challenged | missed_trap + Reason string `json:"reason"` + SeverityAdjustment string `json:"severity_adjustment"` // boost | discount | none + HiddenTrap *string `json:"hidden_trap"` // set when verdict is missed_trap +} + +// MetaDimensionResult is the output of a meta-dimension selector (Semantic, +// Mechanical, or Systemic). Seeded (defaults.go): confidence=0.7. Dimensions is +// a required field in Python (no default) so it is not seeded. +type MetaDimensionResult struct { + Lens string `json:"lens"` // "semantic" | "mechanical" | "systemic" + Dimensions []ReviewDimension `json:"dimensions"` + Confidence float64 `json:"confidence"` // How complete this lens's coverage is (0-1) + Rationale string `json:"rationale"` +} diff --git a/go/internal/schemas/schemas_test.go b/go/internal/schemas/schemas_test.go new file mode 100644 index 0000000..dc3fce2 --- /dev/null +++ b/go/internal/schemas/schemas_test.go @@ -0,0 +1,382 @@ +package schemas + +import ( + "bytes" + "encoding/json" + "testing" +) + +// mustUnmarshal decodes data into a fresh T or fails the test. +func mustUnmarshal[T any](t *testing.T, data string) T { + t.Helper() + var v T + if err := json.Unmarshal([]byte(data), &v); err != nil { + t.Fatalf("unmarshal %q into %T: %v", data, v, err) + } + return v +} + +// --------------------------------------------------------------------------- +// V10 — UnmarshalJSON default-seeding: "{}" seeds the §C.1 non-zero defaults, +// while a present key (even false/0/"") overrides. Derived from the contract. +// --------------------------------------------------------------------------- + +func TestReviewInputDefaults(t *testing.T) { + in := mustUnmarshal[ReviewInput](t, "{}") + if in.Depth != "auto" { + t.Errorf("Depth = %q, want auto", in.Depth) + } + if in.Focus != "auto" { + t.Errorf("Focus = %q, want auto", in.Focus) + } + if in.OutputFormat != "github" { + t.Errorf("OutputFormat = %q, want github", in.OutputFormat) + } + if in.SuggestionMode != "comment" { + t.Errorf("SuggestionMode = %q, want comment", in.SuggestionMode) + } + if in.MaxReviewDepth != 2 { + t.Errorf("MaxReviewDepth = %d, want 2", in.MaxReviewDepth) + } + // Budget caps stay nil (resolved later by config). + if in.MaxCostUSD != nil { + t.Errorf("MaxCostUSD = %v, want nil", *in.MaxCostUSD) + } + if in.MaxDurationSeconds != nil { + t.Errorf("MaxDurationSeconds = %v, want nil", *in.MaxDurationSeconds) + } + if in.PrURL != nil { + t.Errorf("PrURL = %v, want nil", *in.PrURL) + } + // list defaults are non-nil empty slices. + if in.IgnorePaths == nil || len(in.IgnorePaths) != 0 { + t.Errorf("IgnorePaths = %v, want empty non-nil", in.IgnorePaths) + } + if in.Hints == nil || len(in.Hints) != 0 { + t.Errorf("Hints = %v, want empty non-nil", in.Hints) + } + + // Present zero/explicit values override the seeded defaults. + in2 := mustUnmarshal[ReviewInput](t, `{"depth":"deep","max_review_depth":0,"suggestion_mode":"code","dry_run":true}`) + if in2.Depth != "deep" { + t.Errorf("override Depth = %q, want deep", in2.Depth) + } + if in2.MaxReviewDepth != 0 { + t.Errorf("override MaxReviewDepth = %d, want 0", in2.MaxReviewDepth) + } + if in2.SuggestionMode != "code" { + t.Errorf("override SuggestionMode = %q, want code", in2.SuggestionMode) + } + if !in2.DryRun { + t.Errorf("override DryRun = false, want true") + } + + // A present budget cap yields a non-nil pointer to the value. + in3 := mustUnmarshal[ReviewInput](t, `{"max_cost_usd":1.5,"max_duration_seconds":600}`) + if in3.MaxCostUSD == nil || *in3.MaxCostUSD != 1.5 { + t.Errorf("MaxCostUSD = %v, want *1.5", in3.MaxCostUSD) + } + if in3.MaxDurationSeconds == nil || *in3.MaxDurationSeconds != 600 { + t.Errorf("MaxDurationSeconds = %v, want *600", in3.MaxDurationSeconds) + } +} + +func TestBudgetAllocationDefaults(t *testing.T) { + b := mustUnmarshal[BudgetAllocation](t, "{}") + if b.MaxCostUSD != 0.5 || b.MaxDurationSeconds != 60 || b.MaxReferenceFollows != 3 || b.MaxChildSpawns != 2 { + t.Errorf("BudgetAllocation{} = %+v, want {0.5 60 3 2}", b) + } + b0 := mustUnmarshal[BudgetAllocation](t, `{"max_cost_usd":0,"max_child_spawns":0}`) + if b0.MaxCostUSD != 0 || b0.MaxChildSpawns != 0 { + t.Errorf("override zeros = %+v, want MaxCostUSD=0 MaxChildSpawns=0", b0) + } + // Untouched fields keep their seeded defaults. + if b0.MaxDurationSeconds != 60 || b0.MaxReferenceFollows != 3 { + t.Errorf("partial override lost other defaults: %+v", b0) + } +} + +func TestReviewDimensionDefaults(t *testing.T) { + d := mustUnmarshal[ReviewDimension](t, "{}") + if d.Priority != 1 { + t.Errorf("Priority = %d, want 1", d.Priority) + } + if d.ContextFiles == nil || len(d.ContextFiles) != 0 { + t.Errorf("ContextFiles = %v, want empty non-nil", d.ContextFiles) + } + // Nested Budget is seeded even when absent. + if d.Budget != defaultBudgetAllocation() { + t.Errorf("Budget = %+v, want %+v", d.Budget, defaultBudgetAllocation()) + } + // A partially-specified budget re-seeds the untouched sub-fields. + d2 := mustUnmarshal[ReviewDimension](t, `{"budget":{"max_cost_usd":1.0},"priority":0}`) + if d2.Priority != 0 { + t.Errorf("override Priority = %d, want 0", d2.Priority) + } + if d2.Budget.MaxCostUSD != 1.0 || d2.Budget.MaxDurationSeconds != 60 { + t.Errorf("nested budget = %+v, want MaxCostUSD=1.0 MaxDurationSeconds=60", d2.Budget) + } +} + +func TestSubReviewRequestDefaults(t *testing.T) { + s := mustUnmarshal[SubReviewRequest](t, "{}") + if s.Priority != 1 { + t.Errorf("Priority = %d, want 1", s.Priority) + } + if s.ContextFiles == nil || len(s.ContextFiles) != 0 { + t.Errorf("ContextFiles = %v, want empty non-nil", s.ContextFiles) + } +} + +func TestReviewFindingDefaults(t *testing.T) { + f := mustUnmarshal[ReviewFinding](t, "{}") + if f.Severity != "suggestion" { + t.Errorf("Severity = %q, want suggestion", f.Severity) + } + if f.Confidence != 0.5 { + t.Errorf("Confidence = %v, want 0.5", f.Confidence) + } + if f.Tags == nil || len(f.Tags) != 0 { + t.Errorf("Tags = %v, want empty non-nil", f.Tags) + } + if f.Suggestion != nil { + t.Errorf("Suggestion = %v, want nil", *f.Suggestion) + } + // Present zero confidence overrides the 0.5 default. + f2 := mustUnmarshal[ReviewFinding](t, `{"confidence":0,"severity":"critical","suggestion":"fix"}`) + if f2.Confidence != 0 { + t.Errorf("override Confidence = %v, want 0", f2.Confidence) + } + if f2.Severity != "critical" { + t.Errorf("override Severity = %q, want critical", f2.Severity) + } + if f2.Suggestion == nil || *f2.Suggestion != "fix" { + t.Errorf("Suggestion = %v, want *fix", f2.Suggestion) + } +} + +func TestScoredFindingDefaults(t *testing.T) { + s := mustUnmarshal[ScoredFinding](t, "{}") + if s.DiffSide != "RIGHT" { + t.Errorf("DiffSide = %q, want RIGHT", s.DiffSide) + } + if s.Severity != "suggestion" { + t.Errorf("Severity = %q, want suggestion", s.Severity) + } + if s.Confidence != 0.5 { + t.Errorf("Confidence = %v, want 0.5", s.Confidence) + } + if s.Tags == nil || s.ActiveMultipliers == nil { + t.Errorf("Tags/ActiveMultipliers should be non-nil empty, got %v / %v", s.Tags, s.ActiveMultipliers) + } + if s.DiffLine != nil { + t.Errorf("DiffLine = %v, want nil", *s.DiffLine) + } + s2 := mustUnmarshal[ScoredFinding](t, `{"diff_side":"LEFT","confidence":0}`) + if s2.DiffSide != "LEFT" || s2.Confidence != 0 { + t.Errorf("override = %+v, want DiffSide=LEFT Confidence=0", s2) + } +} + +func TestGitHubCommentDefaults(t *testing.T) { + c := mustUnmarshal[GitHubComment](t, "{}") + if c.Side != "RIGHT" { + t.Errorf("Side = %q, want RIGHT", c.Side) + } + c2 := mustUnmarshal[GitHubComment](t, `{"side":"LEFT"}`) + if c2.Side != "LEFT" { + t.Errorf("override Side = %q, want LEFT", c2.Side) + } +} + +func TestAdversaryResultDefaults(t *testing.T) { + a := mustUnmarshal[AdversaryResult](t, "{}") + if a.SeverityAdjustment != "none" { + t.Errorf("SeverityAdjustment = %q, want none", a.SeverityAdjustment) + } + if a.HiddenTrap != nil { + t.Errorf("HiddenTrap = %v, want nil", *a.HiddenTrap) + } +} + +func TestMetaDimensionResultDefaults(t *testing.T) { + m := mustUnmarshal[MetaDimensionResult](t, "{}") + if m.Confidence != 0.7 { + t.Errorf("Confidence = %v, want 0.7", m.Confidence) + } +} + +func TestCoverageGateDefaults(t *testing.T) { + c := mustUnmarshal[CoverageGate](t, "{}") + if !c.Confident { + t.Errorf("Confident = false, want true") + } + if c.GapDescriptions == nil || len(c.GapDescriptions) != 0 { + t.Errorf("GapDescriptions = %v, want empty non-nil", c.GapDescriptions) + } + c2 := mustUnmarshal[CoverageGate](t, `{"confident":false}`) + if c2.Confident { + t.Errorf("override Confident = true, want false") + } +} + +// TestNestedSliceSeeding verifies each element of a typed slice seeds its own +// defaults on decode (V10 nested clause) — a MetaDimensionResult carrying +// dimensions seeds each ReviewDimension's Priority=1 and default Budget. +func TestNestedSliceSeeding(t *testing.T) { + m := mustUnmarshal[MetaDimensionResult](t, `{"lens":"semantic","dimensions":[{"id":"x"}]}`) + if len(m.Dimensions) != 1 { + t.Fatalf("Dimensions len = %d, want 1", len(m.Dimensions)) + } + if m.Dimensions[0].Priority != 1 { + t.Errorf("nested dimension Priority = %d, want 1", m.Dimensions[0].Priority) + } + if m.Dimensions[0].Budget != defaultBudgetAllocation() { + t.Errorf("nested dimension Budget = %+v, want default", m.Dimensions[0].Budget) + } +} + +// TestEmptySlicesMarshalAsArray asserts the seeded list-default fields serialize +// as `[]`, never `null` (design §B.2 empty-list rule) — verified end to end via +// unmarshal("{}") -> marshal. +func TestEmptySlicesMarshalAsArray(t *testing.T) { + in := mustUnmarshal[ReviewInput](t, "{}") + b, err := json.Marshal(in) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var m map[string]json.RawMessage + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("unmarshal map: %v", err) + } + for _, k := range []string{"ignore_paths", "hints"} { + if string(m[k]) != "[]" { + t.Errorf("ReviewInput.%s marshaled as %s, want []", k, m[k]) + } + } + // Optional (X|None) fields marshal as null, not omitted. + for _, k := range []string{"pr_url", "max_cost_usd", "max_duration_seconds"} { + if string(m[k]) != "null" { + t.Errorf("ReviewInput.%s marshaled as %s, want null", k, m[k]) + } + } + + f := mustUnmarshal[ReviewFinding](t, "{}") + fb, _ := json.Marshal(f) + var fm map[string]json.RawMessage + _ = json.Unmarshal(fb, &fm) + if string(fm["tags"]) != "[]" { + t.Errorf("ReviewFinding.tags marshaled as %s, want []", fm["tags"]) + } + if string(fm["suggestion"]) != "null" { + t.Errorf("ReviewFinding.suggestion marshaled as %s, want null", fm["suggestion"]) + } +} + +// --------------------------------------------------------------------------- +// V2 (unit half) — marshal -> unmarshal -> marshal is byte-identical for a +// fully-populated ReviewResult, exercising every nested struct, pointer, slice, +// and map field. +// --------------------------------------------------------------------------- + +func fullyPopulatedReviewResult() ReviewResult { + sug := "return early to avoid the nil deref" + dl := 5 + return ReviewResult{ + ReviewID: "rev_0123456789ab", + PrURL: "https://github.com/o/r/pull/7", + Review: GitHubReview{ + Body: "## Summary\nLooks risky.", + Event: "REQUEST_CHANGES", + Comments: []GitHubComment{ + {Path: "a.go", Line: 12, Side: "RIGHT", Body: "guard this"}, + }, + }, + Findings: []ScoredFinding{ + { + ID: "f_001", DimensionID: "sec_1", DimensionName: "Security", + FilePath: "a.go", LineStart: 10, LineEnd: 14, DiffLine: &dl, + DiffSide: "RIGHT", Severity: "critical", Title: "SQL injection", + Body: "concatenated query", Suggestion: &sug, Evidence: "line 12", + Confidence: 0.9, Tags: []string{"security", "correctness"}, + Score: 0.7, ActiveMultipliers: []string{"ai_generated_pr"}, + Blocking: true, BlockingReason: "data-loss risk", + }, + }, + Summary: ReviewSummary{ + TotalFindings: 1, BySeverity: map[string]int{"critical": 1}, + BlockingCount: 1, AdvisoryCount: 0, DimensionsRun: 3, + CrossRefInteractions: 0, AdversaryChallenged: 1, AdversaryConfirmed: 1, + CoverageIterations: 1, AIGeneratedConfidence: 0.3, CostUSD: 0.0, + DurationSeconds: 42.5, BudgetExhausted: false, + }, + Metadata: ReviewMetadata{ + Intake: map[string]any{"pr_type": "feature"}, + Anatomy: map[string]any{"total_files": float64(2)}, + Plan: map[string]any{}, + Budget: map[string]any{}, + AgentInvocations: 5, + PhasesCompleted: []string{"intake", "anatomy", "output"}, + }, + } +} + +func TestReviewResultRoundTripIdentical(t *testing.T) { + rr := fullyPopulatedReviewResult() + b1, err := json.Marshal(rr) + if err != nil { + t.Fatalf("marshal 1: %v", err) + } + var rr2 ReviewResult + if err := json.Unmarshal(b1, &rr2); err != nil { + t.Fatalf("unmarshal: %v", err) + } + b2, err := json.Marshal(rr2) + if err != nil { + t.Fatalf("marshal 2: %v", err) + } + if !bytes.Equal(b1, b2) { + t.Errorf("not byte-identical:\n first: %s\nsecond: %s", b1, b2) + } + // Top-level key set is exactly ReviewResult.model_dump()'s (design §B.2). + var m map[string]json.RawMessage + _ = json.Unmarshal(b2, &m) + want := []string{"review_id", "pr_url", "review", "findings", "summary", "metadata"} + if len(m) != len(want) { + t.Errorf("top-level key count = %d (%v), want %d", len(m), m, len(want)) + } + for _, k := range want { + if _, ok := m[k]; !ok { + t.Errorf("missing top-level key %q", k) + } + } +} + +func TestReviewInputRoundTripIdentical(t *testing.T) { + cost := 3.5 + dur := 900 + conc := 4 + cov := 2 + ppr := 42 + pr := "https://github.com/o/r/pull/1" + in := ReviewInput{ + PrURL: &pr, DiffText: nil, RepoPath: nil, BaseRef: nil, HeadRef: nil, + Depth: "deep", MaxCostUSD: &cost, MaxDurationSeconds: &dur, + Focus: "security", IgnorePaths: []string{"*.md"}, Hints: []string{"be strict"}, + Models: map[string]string{"reviewer": "anthropic/claude"}, + MaxConcurrentReviewers: &conc, MaxCoverageIterations: &cov, MaxReviewDepth: 3, + OutputFormat: "github", DryRun: true, PostPRNumber: &ppr, SuggestionMode: "code", + } + b1, err := json.Marshal(in) + if err != nil { + t.Fatalf("marshal 1: %v", err) + } + var in2 ReviewInput + if err := json.Unmarshal(b1, &in2); err != nil { + t.Fatalf("unmarshal: %v", err) + } + b2, _ := json.Marshal(in2) + if !bytes.Equal(b1, b2) { + t.Errorf("ReviewInput not byte-identical:\n first: %s\nsecond: %s", b1, b2) + } +} diff --git a/go/internal/schemas/severity.go b/go/internal/schemas/severity.go new file mode 100644 index 0000000..d6e6f04 --- /dev/null +++ b/go/internal/schemas/severity.go @@ -0,0 +1,101 @@ +// Package schemas ports PR-AF's pydantic schema layer to Go: the input, +// pipeline, gate, and output structs plus the canonical severity vocabulary. +// +// Parity rules (design §B.2 / §C.1): +// - Every field Python's model_dump() always emits carries a snake_case json +// tag and NO omitempty (model_dump emits every field, no exclude_none). +// - Pydantic `X | None` fields map to Go pointers so an unset value marshals +// to JSON null exactly as Python does. +// - Structs with a non-zero pydantic default get an UnmarshalJSON that seeds +// the default before decoding (defaults.go), so an absent key keeps the +// default while a present key (even false/0/"") overrides it. +package schemas + +import ( + "encoding/json" + "strings" +) + +// Severity is the canonical finding-severity vocabulary — always one of +// critical | important | suggestion | nitpick. It ports schemas/severity.py's +// Annotated Literal + BeforeValidator: the type advertises the enum, and its +// UnmarshalJSON coerces stray labels (e.g. "high" -> "important") through +// NormalizeSeverity instead of failing, so a model-emitted synonym can never +// break decoding (the incident that silently swallowed a real review). +type Severity string + +// DefaultSeverity is where unknown / empty / non-string labels land. It is the +// mid-low rung: keeps a finding visible without inflating it to a blocker. +const DefaultSeverity Severity = "suggestion" + +// ValidSeverities is the four canonical severities, in decreasing urgency. +var ValidSeverities = []Severity{"critical", "important", "suggestion", "nitpick"} + +// severityAliases maps common labels models reach for onto the 4-level scale by +// rank. This is the CANONICAL map (schemas/severity.py:46-72). The scoring +// package keeps its OWN, deliberately divergent map (medium->important, +// low->suggestion) — never share the two. +var severityAliases = map[string]Severity{ + // canonical (identity) + "critical": "critical", + "important": "important", + "suggestion": "suggestion", + "nitpick": "nitpick", + // critical-tier synonyms + "blocker": "critical", + "fatal": "critical", + "severe": "critical", + // important-tier synonyms (the "high" that caused the incident) + "high": "important", + "error": "important", + "major": "important", + // suggestion-tier synonyms + "medium": "suggestion", + "moderate": "suggestion", + "warning": "suggestion", + "warn": "suggestion", + // nitpick-tier synonyms + "low": "nitpick", + "minor": "nitpick", + "nit": "nitpick", + "info": "nitpick", + "informational": "nitpick", + "trivial": "nitpick", +} + +// NormalizeSeverity coerces an arbitrary severity label to the canonical +// vocabulary (schemas/severity.py normalize_severity). It is case-insensitive +// and whitespace-tolerant; empty, non-string, or unrecognized input falls back +// to def. Accepts a plain string or a Severity (mirroring Python's +// isinstance(value, str) which also accepts str subclasses). +func NormalizeSeverity(v any, def Severity) Severity { + var s string + switch t := v.(type) { + case string: + s = t + case Severity: + s = string(t) + default: + return def + } + key := strings.ToLower(strings.TrimSpace(s)) + if key == "" { + return def + } + if canon, ok := severityAliases[key]; ok { + return canon + } + return def +} + +// UnmarshalJSON coerces any JSON scalar (string synonym, null, number, …) to the +// canonical vocabulary via NormalizeSeverity — the Go equivalent of the pydantic +// BeforeValidator. A null or non-string value yields DefaultSeverity. +func (s *Severity) UnmarshalJSON(b []byte) error { + var v any + if err := json.Unmarshal(b, &v); err != nil { + return err + } + *s = NormalizeSeverity(v, DefaultSeverity) + return nil +} diff --git a/go/internal/schemas/severity_test.go b/go/internal/schemas/severity_test.go new file mode 100644 index 0000000..6bb551e --- /dev/null +++ b/go/internal/schemas/severity_test.go @@ -0,0 +1,124 @@ +package schemas + +import ( + "encoding/json" + "testing" +) + +// TestNormalizeSeverityCanonical is the canonical half of validation contract +// V3: the schemas/severity.py alias map. (The divergent scoring map — +// medium->important, low->suggestion — is tested in the scoring package.) Cases +// are derived from the contract, not the implementation: each label maps by +// rank, case/space-insensitively, with junk / non-string falling back to +// "suggestion". +func TestNormalizeSeverityCanonical(t *testing.T) { + cases := []struct { + in any + want Severity + }{ + // identity + {"critical", "critical"}, + {"important", "important"}, + {"suggestion", "suggestion"}, + {"nitpick", "nitpick"}, + // critical-tier synonyms + {"blocker", "critical"}, + {"fatal", "critical"}, + {"severe", "critical"}, + // important-tier synonyms (the "high" that caused the incident) + {"high", "important"}, + {"error", "important"}, + {"major", "important"}, + // suggestion-tier synonyms — DIVERGES from the scoring map on purpose + {"medium", "suggestion"}, + {"moderate", "suggestion"}, + {"warning", "suggestion"}, + {"warn", "suggestion"}, + // nitpick-tier synonyms + {"low", "nitpick"}, + {"minor", "nitpick"}, + {"nit", "nitpick"}, + {"info", "nitpick"}, + {"informational", "nitpick"}, + {"trivial", "nitpick"}, + // case / whitespace insensitivity + {" HIGH ", "important"}, + {"Medium", "suggestion"}, + {"LOW", "nitpick"}, + {"\tCritical\n", "critical"}, + // junk / empty -> default + {"banana", "suggestion"}, + {"", "suggestion"}, + {" ", "suggestion"}, + // non-string -> default + {42, "suggestion"}, + {3.14, "suggestion"}, + {true, "suggestion"}, + {nil, "suggestion"}, + {[]string{"high"}, "suggestion"}, + // a Severity value is accepted like a string (Python str subclass) + {Severity("blocker"), "critical"}, + } + for _, c := range cases { + if got := NormalizeSeverity(c.in, DefaultSeverity); got != c.want { + t.Errorf("NormalizeSeverity(%#v) = %q, want %q", c.in, got, c.want) + } + } +} + +// TestNormalizeSeverityCustomDefault verifies the def argument is honored on +// unknown/non-string input. +func TestNormalizeSeverityCustomDefault(t *testing.T) { + if got := NormalizeSeverity("banana", "critical"); got != "critical" { + t.Errorf("NormalizeSeverity with custom default = %q, want %q", got, "critical") + } + if got := NormalizeSeverity(nil, "nitpick"); got != "nitpick" { + t.Errorf("NormalizeSeverity(nil) custom default = %q, want %q", got, "nitpick") + } + // A known synonym ignores the custom default and maps by rank. + if got := NormalizeSeverity("high", "nitpick"); got != "important" { + t.Errorf("NormalizeSeverity(high) = %q, want %q", got, "important") + } +} + +// TestSeverityUnmarshalJSON asserts the Severity field type coerces stray labels +// (and null / non-string) through NormalizeSeverity on decode — the Go analog +// of the pydantic BeforeValidator. +func TestSeverityUnmarshalJSON(t *testing.T) { + cases := []struct { + json string + want Severity + }{ + {`"high"`, "important"}, + {`"critical"`, "critical"}, + {`" Blocker "`, "critical"}, + {`"medium"`, "suggestion"}, + {`"garbage"`, "suggestion"}, + {`null`, "suggestion"}, + {`123`, "suggestion"}, + {`""`, "suggestion"}, + } + for _, c := range cases { + var s Severity + if err := json.Unmarshal([]byte(c.json), &s); err != nil { + t.Fatalf("unmarshal %s into Severity: %v", c.json, err) + } + if s != c.want { + t.Errorf("Severity(%s) = %q, want %q", c.json, s, c.want) + } + } +} + +// TestSeverityInFinding verifies a stray severity inside a struct is normalized +// on decode (not just standalone). +func TestSeverityInFinding(t *testing.T) { + f := mustUnmarshal[ReviewFinding](t, `{"severity":"high"}`) + if f.Severity != "important" { + t.Errorf("ReviewFinding severity high -> %q, want important", f.Severity) + } + // Absent severity keeps the seeded default. + f2 := mustUnmarshal[ReviewFinding](t, `{}`) + if f2.Severity != "suggestion" { + t.Errorf("ReviewFinding absent severity -> %q, want suggestion", f2.Severity) + } +} diff --git a/go/internal/scoring/scoring.go b/go/internal/scoring/scoring.go new file mode 100644 index 0000000..8fd0a3d --- /dev/null +++ b/go/internal/scoring/scoring.go @@ -0,0 +1,264 @@ +// Package scoring is the deterministic scoring engine for PR-AF — a byte-exact +// port of src/pr_af/scoring.py. +// +// LLMs reason about issues; this code computes scores. The same findings always +// produce the same scores, so scoring is auditable, testable, and tunable +// independently of the agents. Following the Contract-AF / SEC-AF pattern, +// scoring is intentionally separated from agent code. +// +// Parity notes vs. Python (see design §B.3, §D): +// - This package keeps its OWN, deliberately DIVERGENT severity alias map +// (sevAlias): medium->important, low->suggestion, and it recognizes +// "trivia". It disagrees with the canonical schemas.NormalizeSeverity map +// (medium->suggestion, low->nitpick) and MUST NOT be shared with it. +// - pyRound reproduces Python's round() semantics (round-half-to-even on the +// true binary value) wherever scoring.py calls round(x, 3). Go's math.Round +// is half-away-from-zero and would diverge (e.g. 0.0625 -> 0.063 vs 0.062). +// - ScoreFindings assigns positional ids f_%03d in pre-sort iteration order, +// then stably sorts by score descending — matching Python's stable +// list.sort(key=..., reverse=True), which preserves the input order of +// equal-scored findings. +package scoring + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "github.com/Agent-Field/pr-af/go/internal/config" + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// pyRound reproduces Python's built-in round(x, digits): round-half-to-even +// ("banker's rounding") applied to the true decimal value of the float64. +// +// strconv.FormatFloat with the 'f' verb and a fixed precision performs +// correctly-rounded, round-to-nearest-ties-to-even decimal conversion — exactly +// what CPython's round() does — so formatting then re-parsing yields the same +// float Python would produce. This is deliberately NOT math.Round(x*pow)/pow, +// which rounds half away from zero and additionally accumulates the x*pow error +// (e.g. 0.0625 -> 0.063 instead of Python's 0.062). +func pyRound(x float64, digits int) float64 { + v, _ := strconv.ParseFloat(strconv.FormatFloat(x, 'f', digits, 64), 64) + return v +} + +// sevAlias is the scoring engine's PRIVATE, DIVERGENT severity normalization map +// (scoring.py:45-62). Reviewer LLMs sometimes emit uppercase or aliases like +// "high"/"medium"; this maps them onto the canonical rubric so downstream code +// (base-weight lookup, confidence-threshold gates) stays consistent. +// +// It intentionally DIFFERS from schemas.severityAliases: here medium->important +// and low->suggestion (one rung higher), and it recognizes "trivia". Do not +// replace this with schemas.NormalizeSeverity. +var sevAlias = map[string]string{ + "critical": "critical", + "high": "critical", + "blocker": "critical", + "important": "important", + "medium": "important", + "major": "important", + "suggestion": "suggestion", + "minor": "suggestion", + "low": "suggestion", + "nitpick": "nitpick", + "info": "nitpick", + "trivia": "nitpick", + "trivial": "nitpick", +} + +// normSev ports scoring.py _norm_sev: case-insensitive, whitespace-tolerant +// lookup in sevAlias; empty or unknown input falls back to "suggestion". +func normSev(s string) string { + if v, ok := sevAlias[strings.ToLower(strings.TrimSpace(s))]; ok { + return v + } + return "suggestion" +} + +// ScoreFindings scores, ranks, and filters findings (scoring.py score_findings). +// +// Steps: +// 1. Apply base severity weights. +// 2. Apply multipliers from adversary verdicts and global context. +// 3. Filter out findings below their severity's confidence threshold. +// 4. Synthesize adversary "missed_trap" findings. +// 5. Stably sort by composite score descending. +// +// Positional ids (f_%03d) are assigned in pre-sort iteration order. +func ScoreFindings( + findings []schemas.ReviewFinding, + adversaryResults []schemas.AdversaryResult, + cfg config.ScoringConfig, + aiGenerated float64, + blastRadiusSize int, +) []schemas.ScoredFinding { + // Index adversary results by finding title (last write wins on collisions, + // matching Python's dict comprehension). + adversaryByTitle := make(map[string]schemas.AdversaryResult, len(adversaryResults)) + for _, ar := range adversaryResults { + adversaryByTitle[ar.FindingTitle] = ar + } + + scored := []schemas.ScoredFinding{} + + for _, finding := range findings { + ns := normSev(string(finding.Severity)) + + // Base weight from severity (default 0.3 when absent). + base, ok := cfg.BaseWeights[ns] + if !ok { + base = 0.3 + } + + // Confidence-weighted base. + score := base * finding.Confidence + + // Collect active multipliers. + activeMultipliers := []string{} + + // Adversary assessment. + if adversary, ok := adversaryByTitle[finding.Title]; ok { + switch adversary.Verdict { + case "confirmed": + score *= multiplier(cfg, "adversary_confirmed", 1.3) + activeMultipliers = append(activeMultipliers, "adversary_confirmed") + case "challenged": + score *= multiplier(cfg, "adversary_challenged", 0.5) + activeMultipliers = append(activeMultipliers, "adversary_challenged") + } + } + + // AI-generated PR multiplier. + if aiGenerated > 0.5 { + score *= multiplier(cfg, "ai_generated_pr", 1.2) + activeMultipliers = append(activeMultipliers, "ai_generated_pr") + } + + // Blast radius multiplier. + if blastRadiusSize > 10 { + score *= multiplier(cfg, "blast_radius_high", 1.2) + activeMultipliers = append(activeMultipliers, "blast_radius_high") + } + + // Confidence threshold filtering (default 0.5 when absent). + minConfidence, ok := cfg.ConfidenceThresholds[ns] + if !ok { + minConfidence = 0.5 + } + if finding.Confidence < minConfidence { + continue // Drop low-confidence findings. + } + + scored = append(scored, schemas.ScoredFinding{ + ID: fmt.Sprintf("f_%03d", len(scored)), + DimensionID: finding.DimensionID, + DimensionName: finding.DimensionName, + FilePath: finding.FilePath, + LineStart: finding.LineStart, + LineEnd: finding.LineEnd, + DiffSide: "RIGHT", // pydantic default (not set by score_findings) + Severity: schemas.Severity(ns), + Title: finding.Title, + Body: finding.Body, + Suggestion: finding.Suggestion, + Evidence: finding.Evidence, + Confidence: finding.Confidence, + Tags: finding.Tags, + Score: pyRound(score, 3), + ActiveMultipliers: activeMultipliers, + }) + } + + // Add hidden traps from adversary as new findings. + for _, ar := range adversaryResults { + if ar.Verdict == "missed_trap" && ar.HiddenTrap != nil && *ar.HiddenTrap != "" { + scored = append(scored, schemas.ScoredFinding{ + ID: fmt.Sprintf("f_%03d", len(scored)), + DimensionID: "adversary", + DimensionName: "Adversary Reviewer", + FilePath: "", // Adversary findings may not have specific lines. + LineStart: 0, + LineEnd: 0, + DiffSide: "RIGHT", // pydantic default + Severity: "important", + Title: "Hidden trap: " + ar.FindingTitle, + Body: *ar.HiddenTrap, + Confidence: 0.7, + Tags: []string{"hidden-trap", "adversary-found"}, + Score: pyRound(0.7*0.7, 3), // important × 0.7 confidence + ActiveMultipliers: []string{}, + }) + } + } + + // Sort by score descending. Python's list.sort is stable and reverse=True + // keeps equal-scored findings in their original (id-assignment) order, so + // use sort.SliceStable with a strict-greater-than comparator. + sort.SliceStable(scored, func(i, j int) bool { + return scored[i].Score > scored[j].Score + }) + + return scored +} + +// multiplier looks up a named multiplier, falling back to def when absent — +// the Go equivalent of config.multipliers.get(name, def). +func multiplier(cfg config.ScoringConfig, name string, def float64) float64 { + if v, ok := cfg.Multipliers[name]; ok { + return v + } + return def +} + +// DetermineReviewEvent decides the GitHub review event from the merge-gate +// verdict (scoring.py determine_review_event). +// +// Decoupled from severity: the merge gate (the `blocking` flag) is the single +// source of truth for "must fix before merging". Severity remains the reviewer's +// badness label and drives sorting/display, not the event — so a critical- +// severity finding that is not marked blocking does NOT request changes. +// +// Returns one of APPROVE | COMMENT | REQUEST_CHANGES. +func DetermineReviewEvent(findings []schemas.ScoredFinding) string { + for _, f := range findings { + if f.Blocking { + return "REQUEST_CHANGES" + } + } + if len(findings) > 0 { + return "COMMENT" // Advisory-only findings: surface, but don't gate merge. + } + return "APPROVE" +} + +// DeduplicateExact removes exact duplicates — same file + same line range + same +// severity (scoring.py deduplicate_exact). This is CODE, not an LLM call; for +// near-duplicates the pipeline uses the DedupGate .ai() call. +func DeduplicateExact(findings []schemas.ReviewFinding) []schemas.ReviewFinding { + type dedupKey struct { + filePath string + lineStart int + lineEnd int + severity string + } + + seen := make(map[dedupKey]struct{}, len(findings)) + deduped := []schemas.ReviewFinding{} + + for _, finding := range findings { + key := dedupKey{ + filePath: finding.FilePath, + lineStart: finding.LineStart, + lineEnd: finding.LineEnd, + severity: string(finding.Severity), + } + if _, ok := seen[key]; !ok { + seen[key] = struct{}{} + deduped = append(deduped, finding) + } + } + + return deduped +} diff --git a/go/internal/scoring/scoring_test.go b/go/internal/scoring/scoring_test.go new file mode 100644 index 0000000..ee05646 --- /dev/null +++ b/go/internal/scoring/scoring_test.go @@ -0,0 +1,500 @@ +package scoring + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/Agent-Field/pr-af/go/internal/config" + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +func strptr(s string) *string { return &s } + +// finding is a small constructor for a ReviewFinding with the fields scoring +// cares about; the rest are pass-through and default-irrelevant to the math. +func finding(title string, sev schemas.Severity, conf float64) schemas.ReviewFinding { + return schemas.ReviewFinding{ + Title: title, + Severity: sev, + Confidence: conf, + Tags: []string{}, + } +} + +func findByTitle(scored []schemas.ScoredFinding, title string) (schemas.ScoredFinding, bool) { + for _, s := range scored { + if s.Title == title { + return s, true + } + } + return schemas.ScoredFinding{}, false +} + +// --------------------------------------------------------------------------- +// pyRound — parity trap (design risk #3): Python round() is round-half-to-even +// on the true binary value; Go math.Round is half-away-from-zero. +// --------------------------------------------------------------------------- + +func TestPyRound(t *testing.T) { + cases := []struct { + in float64 + digits int + want float64 + }{ + // The two required verification cases (exactly representable halves). + {0.0625, 3, 0.062}, // ties-to-even rounds DOWN (math.Round would give 0.063) + {0.1875, 3, 0.188}, // ties-to-even rounds UP to the even digit + // True-binary-value subtlety: these decimals are not exactly + // representable, so the tie is broken by the real stored value. + {0.2625, 3, 0.263}, // stored value sits just above the half + {0.3125, 3, 0.312}, // 5/16 exactly representable -> ties-to-even down + // The missed_trap score. + {0.7 * 0.7, 3, 0.49}, + {0.49, 3, 0.49}, + } + for _, c := range cases { + if got := pyRound(c.in, c.digits); got != c.want { + t.Errorf("pyRound(%v, %d) = %v, want %v", c.in, c.digits, got, c.want) + } + } +} + +// --------------------------------------------------------------------------- +// V3 — the DIVERGENT scoring severity map, tested independently of the +// canonical schemas map. Includes the deliberate disagreements. +// --------------------------------------------------------------------------- + +func TestNormSevDivergentMap(t *testing.T) { + cases := []struct { + in string + want string + }{ + // identity + {"critical", "critical"}, + {"important", "important"}, + {"suggestion", "suggestion"}, + {"nitpick", "nitpick"}, + // critical tier + {"high", "critical"}, + {"blocker", "critical"}, + // important tier (DIVERGENT: medium -> important) + {"medium", "important"}, + {"major", "important"}, + // suggestion tier (DIVERGENT: low -> suggestion) + {"minor", "suggestion"}, + {"low", "suggestion"}, + // nitpick tier (includes "trivia", which the canonical map lacks) + {"info", "nitpick"}, + {"trivia", "nitpick"}, + {"trivial", "nitpick"}, + // case / whitespace tolerance + {" HIGH ", "critical"}, + {"Medium", "important"}, + {"\tLOW\n", "suggestion"}, + // unknown / empty -> default + {"", "suggestion"}, + {" ", "suggestion"}, + {"bogus", "suggestion"}, + } + for _, c := range cases { + if got := normSev(c.in); got != c.want { + t.Errorf("normSev(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +// The scoring map MUST disagree with the canonical schemas map on exactly these +// inputs — proving they are not accidentally sharing one table. +func TestNormSevDivergesFromCanonical(t *testing.T) { + divergent := []struct { + in string + scoringWant string + canonicalWant schemas.Severity + }{ + {"medium", "important", "suggestion"}, + {"low", "suggestion", "nitpick"}, + } + for _, d := range divergent { + got := normSev(d.in) + canon := schemas.NormalizeSeverity(d.in, schemas.DefaultSeverity) + if got != d.scoringWant { + t.Errorf("normSev(%q) = %q, want %q", d.in, got, d.scoringWant) + } + if canon != d.canonicalWant { + t.Errorf("canonical NormalizeSeverity(%q) = %q, want %q", d.in, canon, d.canonicalWant) + } + if string(canon) == got { + t.Errorf("scoring and canonical maps agree on %q (%q) — they must diverge", d.in, got) + } + } + // "trivia" is recognized by scoring but unknown to the canonical map (which + // falls back to its default). + if normSev("trivia") != "nitpick" { + t.Errorf("normSev(trivia) = %q, want nitpick", normSev("trivia")) + } + if got := schemas.NormalizeSeverity("trivia", schemas.DefaultSeverity); got != schemas.DefaultSeverity { + t.Errorf("canonical NormalizeSeverity(trivia) = %q, want default %q", got, schemas.DefaultSeverity) + } +} + +// --------------------------------------------------------------------------- +// V6 — event mapping. blocking is the source of truth; severity does NOT gate. +// --------------------------------------------------------------------------- + +func TestDetermineReviewEvent(t *testing.T) { + if got := DetermineReviewEvent(nil); got != "APPROVE" { + t.Errorf("empty -> %q, want APPROVE", got) + } + if got := DetermineReviewEvent([]schemas.ScoredFinding{}); got != "APPROVE" { + t.Errorf("empty slice -> %q, want APPROVE", got) + } + + // Advisory-only: findings present, none blocking -> COMMENT. This includes a + // critical-SEVERITY finding that is not marked blocking — severity alone must + // NOT request changes. + advisory := []schemas.ScoredFinding{ + {Title: "crit but not blocking", Severity: "critical", Blocking: false}, + {Title: "nit", Severity: "nitpick", Blocking: false}, + } + if got := DetermineReviewEvent(advisory); got != "COMMENT" { + t.Errorf("advisory-only (incl. non-blocking critical) -> %q, want COMMENT", got) + } + + // Any blocking finding -> REQUEST_CHANGES, even if it is a low severity. + blocking := []schemas.ScoredFinding{ + {Title: "nit", Severity: "nitpick", Blocking: false}, + {Title: "blocking suggestion", Severity: "suggestion", Blocking: true}, + } + if got := DetermineReviewEvent(blocking); got != "REQUEST_CHANGES" { + t.Errorf("has blocking -> %q, want REQUEST_CHANGES", got) + } +} + +// --------------------------------------------------------------------------- +// Scoring math: base weights, all four multipliers, and their boundaries. +// Expected scores are computed from scoring.py semantics (Python round()). +// --------------------------------------------------------------------------- + +func TestScoreFindingsMultipliers(t *testing.T) { + cfg := config.DefaultScoringConfig() + + // Call 1: no global multipliers; adversary verdicts drive per-finding mults. + adv := []schemas.AdversaryResult{ + {FindingTitle: "B", Verdict: "confirmed"}, + {FindingTitle: "C", Verdict: "challenged"}, + } + findings := []schemas.ReviewFinding{ + finding("A", "critical", 0.9), // 1.0*0.9 = 0.9 + finding("B", "important", 0.5), // 0.7*0.5*1.3 = 0.455 + finding("C", "suggestion", 0.6), // 0.3*0.6*0.5 = 0.09 + } + scored := ScoreFindings(findings, adv, cfg, 0.0, 0) + if len(scored) != 3 { + t.Fatalf("call1: got %d findings, want 3", len(scored)) + } + assertScore(t, scored, "A", 0.9, nil) + assertScore(t, scored, "B", 0.455, []string{"adversary_confirmed"}) + assertScore(t, scored, "C", 0.09, []string{"adversary_challenged"}) + + // Call 2: AI-generated multiplier only (ai_generated > 0.5). + scored = ScoreFindings([]schemas.ReviewFinding{finding("D", "nitpick", 0.5)}, nil, cfg, 0.8, 0) + assertScore(t, scored, "D", 0.06, []string{"ai_generated_pr"}) // 0.1*0.5*1.2 + + // Call 3: blast-radius multiplier only (blast_radius_size > 10). + scored = ScoreFindings([]schemas.ReviewFinding{finding("E", "critical", 0.5)}, nil, cfg, 0.0, 20) + assertScore(t, scored, "E", 0.6, []string{"blast_radius_high"}) // 1.0*0.5*1.2 + + // Call 4: all three (adversary_confirmed, ai, blast) stack; check mult order. + scored = ScoreFindings( + []schemas.ReviewFinding{finding("F", "critical", 0.7)}, + []schemas.AdversaryResult{{FindingTitle: "F", Verdict: "confirmed"}}, + cfg, 0.9, 15, + ) + // 1.0*0.7*1.3*1.2*1.2 = 1.3104 -> round 1.31 + assertScore(t, scored, "F", 1.31, []string{"adversary_confirmed", "ai_generated_pr", "blast_radius_high"}) + + // Call 5: strict boundaries. ai == 0.5 and blast == 10 add NO multiplier. + scored = ScoreFindings([]schemas.ReviewFinding{finding("BND", "important", 0.5)}, nil, cfg, 0.5, 10) + assertScore(t, scored, "BND", 0.35, nil) // 0.7*0.5, no multipliers +} + +func assertScore(t *testing.T, scored []schemas.ScoredFinding, title string, wantScore float64, wantMults []string) { + t.Helper() + f, ok := findByTitle(scored, title) + if !ok { + t.Errorf("finding %q not present in scored output", title) + return + } + if f.Score != wantScore { + t.Errorf("finding %q score = %v, want %v", title, f.Score, wantScore) + } + want := wantMults + if want == nil { + want = []string{} + } + if !reflect.DeepEqual(f.ActiveMultipliers, want) { + t.Errorf("finding %q active_multipliers = %v, want %v", title, f.ActiveMultipliers, want) + } + // active_multipliers must always be a non-nil slice (Python: `= []`). + if f.ActiveMultipliers == nil { + t.Errorf("finding %q active_multipliers is nil, want non-nil (serializes [])", title) + } +} + +// --------------------------------------------------------------------------- +// Confidence-threshold filtering. Drop is strict `<`; equal-to-threshold keeps. +// --------------------------------------------------------------------------- + +func TestScoreFindingsConfidenceThreshold(t *testing.T) { + cfg := config.DefaultScoringConfig() + findings := []schemas.ReviewFinding{ + finding("G", "important", 0.25), // < 0.3 -> drop + finding("H", "suggestion", 0.35), // < 0.4 -> drop + finding("I", "nitpick", 0.39), // < 0.4 -> drop + finding("J", "critical", 0.19), // < 0.2 -> drop + finding("K", "nitpick", 0.4), // == 0.4 -> KEEP (not strictly less) + } + scored := ScoreFindings(findings, nil, cfg, 0.0, 0) + if len(scored) != 1 { + t.Fatalf("got %d kept findings, want 1 (only the boundary keep)", len(scored)) + } + assertScore(t, scored, "K", 0.04, nil) // 0.1*0.4 + if scored[0].ID != "f_000" { + t.Errorf("kept finding id = %q, want f_000", scored[0].ID) + } +} + +// The DIVERGENT map must be applied INSIDE scoring: a raw "medium"/"low" changes +// both the normalized severity and the base weight relative to the canonical map. +func TestScoreFindingsDivergentSeverityAffectsWeight(t *testing.T) { + cfg := config.DefaultScoringConfig() + + // medium -> important (base 0.7). Canonical would be suggestion (base 0.3). + scored := ScoreFindings([]schemas.ReviewFinding{finding("MED", "medium", 0.5)}, nil, cfg, 0.0, 0) + f, ok := findByTitle(scored, "MED") + if !ok { + t.Fatalf("MED dropped unexpectedly") + } + if f.Severity != "important" { + t.Errorf("MED severity = %q, want important (divergent medium mapping)", f.Severity) + } + if f.Score != 0.35 { // 0.7*0.5 + t.Errorf("MED score = %v, want 0.35 (base 0.7)", f.Score) + } + + // low -> suggestion (base 0.3, threshold 0.4). Canonical would be nitpick. + scored = ScoreFindings([]schemas.ReviewFinding{finding("LOW", "low", 0.5)}, nil, cfg, 0.0, 0) + f, ok = findByTitle(scored, "LOW") + if !ok { + t.Fatalf("LOW dropped unexpectedly") + } + if f.Severity != "suggestion" { + t.Errorf("LOW severity = %q, want suggestion (divergent low mapping)", f.Severity) + } + if f.Score != 0.15 { // 0.3*0.5 + t.Errorf("LOW score = %v, want 0.15 (base 0.3)", f.Score) + } +} + +// Pass-through fields land verbatim on the ScoredFinding, with the pydantic +// defaults (diff_side="RIGHT", diff_line=nil, blocking=false) applied. +func TestScoreFindingsPassthroughFields(t *testing.T) { + cfg := config.DefaultScoringConfig() + f := schemas.ReviewFinding{ + DimensionID: "dim-1", + DimensionName: "Security", + FilePath: "src/app.go", + LineStart: 10, + LineEnd: 12, + Severity: "critical", + Title: "SQL injection", + Body: "unsanitized input", + Suggestion: strptr("use params"), + Evidence: "line 10 concat", + Confidence: 0.9, + Tags: []string{"security", "correctness"}, + } + scored := ScoreFindings([]schemas.ReviewFinding{f}, nil, cfg, 0.0, 0) + if len(scored) != 1 { + t.Fatalf("got %d, want 1", len(scored)) + } + s := scored[0] + if s.ID != "f_000" || s.DimensionID != "dim-1" || s.DimensionName != "Security" || + s.FilePath != "src/app.go" || s.LineStart != 10 || s.LineEnd != 12 || + s.Title != "SQL injection" || s.Body != "unsanitized input" || + s.Evidence != "line 10 concat" || s.Confidence != 0.9 { + t.Errorf("pass-through mismatch: %+v", s) + } + if s.Suggestion == nil || *s.Suggestion != "use params" { + t.Errorf("suggestion = %v, want ptr(use params)", s.Suggestion) + } + if !reflect.DeepEqual(s.Tags, []string{"security", "correctness"}) { + t.Errorf("tags = %v, want [security correctness]", s.Tags) + } + if s.DiffSide != "RIGHT" { + t.Errorf("diff_side = %q, want RIGHT (pydantic default)", s.DiffSide) + } + if s.DiffLine != nil { + t.Errorf("diff_line = %v, want nil", s.DiffLine) + } + if s.Blocking || s.BlockingReason != "" { + t.Errorf("blocking=%v reason=%q, want false/empty", s.Blocking, s.BlockingReason) + } + if s.Severity != "critical" { + t.Errorf("severity = %q, want critical", s.Severity) + } +} + +// --------------------------------------------------------------------------- +// missed_trap synthesis (design §D recipe). +// --------------------------------------------------------------------------- + +func TestMissedTrapSynthesis(t *testing.T) { + cfg := config.DefaultScoringConfig() + adv := []schemas.AdversaryResult{ + {FindingTitle: "X", Verdict: "missed_trap", HiddenTrap: strptr("boom")}, // synthesize + {FindingTitle: "Y", Verdict: "missed_trap", HiddenTrap: nil}, // skip (nil) + {FindingTitle: "Z", Verdict: "missed_trap", HiddenTrap: strptr("")}, // skip (empty) + {FindingTitle: "W", Verdict: "confirmed", HiddenTrap: strptr("nope")}, // skip (not missed_trap) + } + scored := ScoreFindings(nil, adv, cfg, 0.0, 0) + if len(scored) != 1 { + t.Fatalf("got %d synthesized, want 1", len(scored)) + } + s := scored[0] + want := schemas.ScoredFinding{ + ID: "f_000", + DimensionID: "adversary", + DimensionName: "Adversary Reviewer", + FilePath: "", + LineStart: 0, + LineEnd: 0, + DiffLine: nil, + DiffSide: "RIGHT", + Severity: "important", + Title: "Hidden trap: X", + Body: "boom", + Suggestion: nil, + Evidence: "", + Confidence: 0.7, + Tags: []string{"hidden-trap", "adversary-found"}, + Score: 0.49, + ActiveMultipliers: []string{}, + Blocking: false, + BlockingReason: "", + } + if !reflect.DeepEqual(s, want) { + t.Errorf("missed_trap finding mismatch:\n got %+v\n want %+v", s, want) + } +} + +// The missed_trap id counter continues past the kept findings, and ids are +// assigned PRE-sort (so a high-scoring trap can end up first with a later id). +func TestMissedTrapIDContinuesCounter(t *testing.T) { + cfg := config.DefaultScoringConfig() + findings := []schemas.ReviewFinding{finding("K", "nitpick", 0.5)} // score 0.05, id f_000 + adv := []schemas.AdversaryResult{{FindingTitle: "X", Verdict: "missed_trap", HiddenTrap: strptr("boom")}} + scored := ScoreFindings(findings, adv, cfg, 0.0, 0) + if len(scored) != 2 { + t.Fatalf("got %d, want 2", len(scored)) + } + k, _ := findByTitle(scored, "K") + trap, _ := findByTitle(scored, "Hidden trap: X") + if k.ID != "f_000" { + t.Errorf("kept finding id = %q, want f_000", k.ID) + } + if trap.ID != "f_001" { + t.Errorf("trap id = %q, want f_001 (counter continues)", trap.ID) + } + // trap (0.49) outscores K (0.05) so it sorts first despite the later id. + if scored[0].ID != "f_001" || scored[1].ID != "f_000" { + t.Errorf("sorted order ids = [%q %q], want [f_001 f_000]", scored[0].ID, scored[1].ID) + } +} + +// --------------------------------------------------------------------------- +// ID assignment order + stable descending sort with tie preservation. +// --------------------------------------------------------------------------- + +func TestScoreFindingsIDOrderAndStableSort(t *testing.T) { + cfg := config.DefaultScoringConfig() + findings := []schemas.ReviewFinding{ + finding("first", "important", 0.5), // score 0.35, id f_000 + finding("top", "critical", 0.9), // score 0.9, id f_001 + finding("second", "important", 0.5), // score 0.35, id f_002 (ties with first) + } + scored := ScoreFindings(findings, nil, cfg, 0.0, 0) + if len(scored) != 3 { + t.Fatalf("got %d, want 3", len(scored)) + } + + // IDs are assigned in INPUT order, before sorting. + byTitle := map[string]string{} + for _, s := range scored { + byTitle[s.Title] = s.ID + } + if byTitle["first"] != "f_000" || byTitle["top"] != "f_001" || byTitle["second"] != "f_002" { + t.Errorf("id assignment = %v, want first=f_000 top=f_001 second=f_002", byTitle) + } + + // Final order: descending by score; the 0.35 tie keeps input order + // (first before second) thanks to the stable sort. + gotOrder := []string{scored[0].Title, scored[1].Title, scored[2].Title} + wantOrder := []string{"top", "first", "second"} + if !reflect.DeepEqual(gotOrder, wantOrder) { + t.Errorf("sorted order = %v, want %v", gotOrder, wantOrder) + } +} + +// --------------------------------------------------------------------------- +// deduplicate_exact — key is (file_path, line_start, line_end, severity). +// --------------------------------------------------------------------------- + +func TestDeduplicateExact(t *testing.T) { + f := func(fp string, ls, le int, sev schemas.Severity, title string) schemas.ReviewFinding { + return schemas.ReviewFinding{FilePath: fp, LineStart: ls, LineEnd: le, Severity: sev, Title: title} + } + findings := []schemas.ReviewFinding{ + f("a.go", 1, 2, "critical", "first"), // keep + f("a.go", 1, 2, "critical", "dup"), // drop (exact key match) + f("a.go", 1, 2, "important", "sev"), // keep (severity differs) + f("a.go", 3, 4, "critical", "line"), // keep (line differs) + f("b.go", 1, 2, "critical", "file"), // keep (file differs) + f("a.go", 1, 2, "critical", "dup2"), // drop (exact key match again) + } + deduped := DeduplicateExact(findings) + gotTitles := make([]string, len(deduped)) + for i, d := range deduped { + gotTitles[i] = d.Title + } + want := []string{"first", "sev", "line", "file"} // first occurrences, input order + if !reflect.DeepEqual(gotTitles, want) { + t.Errorf("deduped titles = %v, want %v", gotTitles, want) + } +} + +// --------------------------------------------------------------------------- +// Empty inputs return non-nil empty slices so JSON marshals to [] (never null), +// matching Python's `list[...] = []`. +// --------------------------------------------------------------------------- + +func TestEmptyInputsMarshalAsEmptyArray(t *testing.T) { + cfg := config.DefaultScoringConfig() + + scored := ScoreFindings(nil, nil, cfg, 0.0, 0) + if scored == nil { + t.Fatal("ScoreFindings(nil,...) returned nil, want non-nil empty slice") + } + if b, _ := json.Marshal(scored); string(b) != "[]" { + t.Errorf("ScoreFindings empty marshals to %s, want []", b) + } + + deduped := DeduplicateExact(nil) + if deduped == nil { + t.Fatal("DeduplicateExact(nil) returned nil, want non-nil empty slice") + } + if b, _ := json.Marshal(deduped); string(b) != "[]" { + t.Errorf("DeduplicateExact empty marshals to %s, want []", b) + } +} diff --git a/go/scripts/gen_golden.py b/go/scripts/gen_golden.py new file mode 100644 index 0000000..13ad917 --- /dev/null +++ b/go/scripts/gen_golden.py @@ -0,0 +1,499 @@ +#!/usr/bin/env python3 +"""Committed golden-fixture generator for the Go prompt-builder port (T2.4). + +This script is the SINGLE SOURCE OF TRUTH for the byte-verbatim prompt fixtures +under ``go/internal/prompts/testdata/``. It imports the REAL Python prompt +builders from ``pr_af`` and captures the exact prompt/system strings they emit +for a fixed set of A/B/C fixture inputs, then writes them to +``<builder>_<case>.txt``. The Go golden tests embed those files and assert the +Go builders reproduce them byte-for-byte. + +HOW IT WORKS +------------ +Every PR-AF reasoner builds its LLM prompt inline and hands it to +``router.app.ai(...)`` / ``router.app.harness(...)``. We bind ``router._agent`` +to a capturing fake, drive each reasoner with explicit fixture inputs, and +record every (method, system, prompt) tuple. Two builders expose importable +units we call directly instead: ``merge_gate._build_user_prompt`` / +``merge_gate._MERGE_GATE_SYSTEM`` and ``polish._POLISH_SYSTEM``. One builder +lives in ``orchestrator._build_gap_dimensions`` (a method); its one-line +f-string is reconstructed here verbatim. + +Fixture cases: + A = all optional branches populated (rich data, inline context) + B = minimal / else-branches (empty optionals) + C = the large-context "written to file" branch (repo_path set, payload > limit) + +REPRODUCE (from the pr-af repo root): + /tmp/claude-1000/-home-abir-gb/e0447ca2-28f4-49fe-ae8a-ead45bdad68c/scratchpad/praf-venv/bin/python go/scripts/gen_golden.py + +Or with any interpreter that has pr-af installed / on PYTHONPATH=src: + PYTHONPATH=src python go/scripts/gen_golden.py + +The script is deterministic and idempotent: rerunning overwrites the fixtures +with identical bytes unless a Python builder changed (which is exactly the +signal the Go golden tests exist to catch). +""" + +from __future__ import annotations + +import asyncio +import os +import sys + +# Make `pr_af` importable when run from the repo root without install. +_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +_SRC = os.path.join(_REPO_ROOT, "src") +if os.path.isdir(_SRC) and _SRC not in sys.path: + sys.path.insert(0, _SRC) + +from pr_af import merge_gate, polish # noqa: E402 +from pr_af.reasoners import harnesses, router # noqa: E402 +from pr_af.schemas.output import ScoredFinding # noqa: E402 + +TESTDATA = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "internal", "prompts", "testdata") +# Scratch dir used only for the file-write ("written to: <path>") branches. The +# reasoners write a context file under <repo>/.pr-af-context/; we point it at a +# STABLE, fixture-controlled path so the emitted message is deterministic. +FIXTURE_REPO = "/tmp/pr-af-fixture-repo" + + +# --------------------------------------------------------------------------- +# Capturing fake router.app +# --------------------------------------------------------------------------- +_CAPTURED: list[tuple[str, str | None, str]] = [] + + +class _FakeResult: + def __init__(self) -> None: + self.parsed = None + self.error_message = None + + +class _FakeGate: + # Attributes read by intake_phase / coverage_gate after the call. Forcing + # confident=False routes intake_phase through BOTH the .ai gate prompt and + # the .harness fallback prompt so we capture both. + pr_type = "feature" + complexity = "standard" + confident = False + fully_covered = False + gap_descriptions: list[str] = [] + + def model_dump(self) -> dict: + return {} + + +class _FakeApp: + async def ai(self, prompt, system=None, schema=None, response_format=None): + _CAPTURED.append(("ai", system, prompt)) + return _FakeGate() + + async def harness(self, prompt, schema=None, cwd=None): + _CAPTURED.append(("harness", None, prompt)) + return _FakeResult() + + +router._agent = _FakeApp() + + +def cap(coro) -> list[tuple[str, str | None, str]]: + """Run one reasoner coroutine and return the prompts it captured.""" + _CAPTURED.clear() + asyncio.get_event_loop().run_until_complete(coro) + return list(_CAPTURED) + + +def emit(name: str, text: str) -> None: + path = os.path.join(TESTDATA, name + ".txt") + with open(path, "w", encoding="utf-8") as f: + f.write(text) + print(f" wrote {name}.txt ({len(text)} bytes)") + + +# --------------------------------------------------------------------------- +# Fixture input factories +# --------------------------------------------------------------------------- +def changed_file(path, status="modified", additions=0, deletions=0): + return {"path": path, "status": status, "additions": additions, "deletions": deletions} + + +def pr_data(**kw): + base = { + "owner": "acme", + "repo": "widget", + "number": 7, + "title": "Add retry logic", + "description": "", + "labels": [], + "author": "", + "commit_messages": [], + "diff": "", + "changed_files": [], + } + base.update(kw) + return base + + +def intake_dict(**kw): + base = { + "pr_type": "feature", + "complexity": "standard", + "languages": ["python"], + "areas_touched": ["api"], + "risk_signals": ["changes API surface or request/response behavior"], + "ai_generated": 0.0, + "review_depth": "standard", + "pr_summary": "Adds a retry wrapper around the HTTP client.", + } + base.update(kw) + return base + + +def anatomy_dict(**kw): + base = { + "files": [], + "clusters": [], + "blast_radius": [], + "dependency_graph": {}, + "stats": { + "total_files": 0, + "total_additions": 0, + "total_deletions": 0, + "files_added": 0, + "files_modified": 0, + "files_removed": 0, + "files_renamed": 0, + "test_files_changed": 0, + "test_to_code_ratio": 0.0, + }, + "pr_narrative": "", + "risk_surfaces": [], + "unrelated_changes": [], + "intent_gaps": [], + "context_notes": "", + } + base.update(kw) + return base + + +def cluster(cid, name, files, primary_language="python", description="desc"): + return { + "id": cid, + "name": name, + "files": files, + "primary_language": primary_language, + "description": description, + } + + +def finding(**kw): + base = { + "dimension_id": "d1", + "dimension_name": "Retry semantics", + "file_path": "client.py", + "line_start": 10, + "line_end": 12, + "hunk_context": "", + "severity": "important", + "title": "Retry loop can spin forever", + "body": "The loop never decrements the counter.", + "suggestion": None, + "evidence": "Step 1: caller invokes retry() with n=3.", + "confidence": 0.7, + "tags": ["correctness"], + } + base.update(kw) + return base + + +def scored(**kw): + base = dict( + id="f_001", + dimension_id="d1", + dimension_name="Retry semantics", + file_path="client.py", + line_start=10, + line_end=12, + severity="important", + title="Retry loop can spin forever", + body="The loop never decrements the counter.", + confidence=0.73, + ) + base.update(kw) + return ScoredFinding(**base) + + +def big(seed: str, n: int) -> str: + """Deterministic filler that pushes a payload past a size threshold.""" + line = seed + " lorem ipsum dolor sit amet consectetur adipiscing elit. " + return (line * ((n // len(line)) + 1))[:n] + + +# --------------------------------------------------------------------------- +# Generation +# --------------------------------------------------------------------------- +def main() -> None: + os.makedirs(TESTDATA, exist_ok=True) + os.makedirs(os.path.join(FIXTURE_REPO, ".pr-af-context"), exist_ok=True) + + # ---- system prompts (input-independent) -------------------------------- + emit("merge_gate_system", merge_gate._MERGE_GATE_SYSTEM) + emit("polish_system", polish._POLISH_SYSTEM) + + # ---- intake_phase (ai gate + harness fallback) ------------------------- + prA = pr_data( + title="Add retry logic to HTTP client", + description="Wraps the client in a retry decorator with exponential backoff.\nCloses #42.", + labels=["enhancement", "backend"], + author="alice", + commit_messages=["feat: add retry", "test: cover retry", "docs: note retry", "chore: lint", "fix: typo", "extra"], + # A python majority (client.py + retry.py) makes cluster_changes' + # primary_language deterministic — max(set(langs), key=langs.count) is + # hash-order-dependent on an all-distinct-language tie. + changed_files=[ + changed_file("client.py"), + changed_file("retry.py", "added"), + changed_file("client.test.ts", "added"), + changed_file("README.md"), + ], + ) + c = cap(harnesses.intake_phase(prA, depth="deep")) + sys_intake = next(s for m, s, _ in c if m == "ai") + emit("intake_gate_system", sys_intake) + emit("intake_ai_A", next(p for m, _, p in c if m == "ai")) + emit("intake_fallback_A", next(p for m, _, p in c if m == "harness")) + + prB = pr_data(title="Bump dep", description="") + c = cap(harnesses.intake_phase(prB, depth="standard")) + emit("intake_ai_B", next(p for m, _, p in c if m == "ai")) + emit("intake_fallback_B", next(p for m, _, p in c if m == "harness")) + + # ---- anatomy_phase (empty changed_files -> trivial derivations) -------- + c = cap(harnesses.anatomy_phase(prA, intake_dict(), repo_path="")) + emit("anatomy_A", next(p for m, _, p in c if m == "harness")) + c = cap(harnesses.anatomy_phase(prB, intake_dict(pr_summary="Bumps a dependency.", areas_touched=["config"]), repo_path="")) + emit("anatomy_B", next(p for m, _, p in c if m == "harness")) + + # ---- planning_phase ---------------------------------------------------- + anatA = anatomy_dict( + clusters=[cluster("cluster_0", "Client core", ["client.py"]), cluster("cluster_1", "Tests", ["client.test.ts"], "typescript")], + risk_surfaces=["error propagation to callers", "retry storm under load"], + pr_narrative="Introduces a retry decorator around the HTTP client call path.", + blast_radius=["caller.py"], + intent_gaps=["description mentions backoff but code uses fixed sleep"], + unrelated_changes=["README typo fix"], + context_notes="Retry count is read from env.", + ) + c = cap(harnesses.planning_phase(intake_dict(areas_touched=["api", "config"]), anatA, depth="deep", hints=["focus on idempotency", "ignore style"])) + emit("planning_A", next(p for m, _, p in c if m == "harness")) + c = cap(harnesses.planning_phase(intake_dict(), anatomy_dict(), depth="standard", hints=[])) + emit("planning_B", next(p for m, _, p in c if m == "harness")) + + # ---- meta selectors (semantic / mechanical / systemic) ----------------- + diff_patches = {"client.py": "@@ -1,3 +1,5 @@\n+def retry():\n+ pass"} + for lens, fn in (("semantic", harnesses.meta_semantic), ("mechanical", harnesses.meta_mechanical), ("systemic", harnesses.meta_systemic)): + c = cap(fn(intake_dict(), anatA, depth="deep", repo_path="", diff_patches=diff_patches, reviewer_feedback="tone down the nitpicks")) + emit(f"meta_{lens}_A", next(p for m, _, p in c if m == "harness")) + c = cap(fn(intake_dict(), anatomy_dict(), depth="standard", repo_path="", diff_patches=None, reviewer_feedback="")) + emit(f"meta_{lens}_B", next(p for m, _, p in c if m == "harness")) + # C: large-context file-write branch (semantic only; the context_ref logic is shared) + big_patches = {"client.py": big("patch", 9000)} + c = cap(harnesses.meta_semantic(intake_dict(), anatA, depth="deep", repo_path=FIXTURE_REPO, diff_patches=big_patches, reviewer_feedback="focus on auth")) + emit("meta_semantic_C", next(p for m, _, p in c if m == "harness")) + + # ---- review_dimension -------------------------------------------------- + rd_common = dict( + review_prompt="Verify the retry decorator preserves error types raised by the wrapped call.", + target_files=["client.py", "retry.py"], + repo_path="", + ) + c = cap(harnesses.review_dimension( + **rd_common, + context_files=["errors.py"], + current_depth=0, + max_depth=2, + pr_narrative="Adds a retry decorator.", + risk_surfaces=["error propagation", "timeout handling"], + intake_summary="Feature PR touching the HTTP client.", + diff_patches={"client.py": "@@ -1 +1 @@\n-x\n+y", "retry.py": "@@ -2 +2 @@\n-a\n+b"}, + all_dimension_names=["Semantic: error paths", "Mechanical: signatures"], + reviewer_feedback="drop nitpicks, focus on correctness", + primed_code="1: def retry(fn):\n2: return fn", + )) + emit("review_dimension_A", next(p for m, _, p in c if m == "harness")) + c = cap(harnesses.review_dimension( + **rd_common, + context_files=None, + current_depth=2, + max_depth=2, + pr_narrative="", + risk_surfaces=None, + intake_summary="", + diff_patches=None, + all_dimension_names=None, + reviewer_feedback="", + primed_code="", + )) + emit("review_dimension_B", next(p for m, _, p in c if m == "harness")) + c = cap(harnesses.review_dimension( + review_prompt="Verify retry preserves error types.", + target_files=["client.py"], + repo_path=FIXTURE_REPO, + context_files=["errors.py"], + current_depth=0, + max_depth=2, + pr_narrative="Adds retry.", + risk_surfaces=["error propagation"], + intake_summary="Feature PR.", + diff_patches={"client.py": big("hunk", 6500)}, + all_dimension_names=["Semantic"], + reviewer_feedback="", + primed_code=big("code", 6500), + )) + emit("review_dimension_C", next(p for m, _, p in c if m == "harness")) + + # ---- compound_finder_phase -------------------------------------------- + f1 = finding(title="Unbounded retry loop", tags=["correctness"]) + f2 = finding(title="Backoff ignored", file_path="retry.py", line_start=5, tags=["performance"], suggestion="Sleep between attempts.") + f3 = finding(title="Error type swallowed", file_path="errors.py", severity="critical", suggestion="Re-raise original.") + ev = { + "Unbounded retry loop": { + "primary_code": "def retry(): ...", + "import_context": "import time", + "caller_snippets": ["client.call()"], + "related_code": "config.RETRIES", + "cross_ref_snippets": ["x=1"], + } + } + c = cap(harnesses.compound_finder_phase([f1, f2, f3], repo_path="", evidence_map=ev)) + emit("compound_finder_A", next(p for m, _, p in c if m == "harness")) + c = cap(harnesses.compound_finder_phase([f1, f2], repo_path="", evidence_map=None)) + emit("compound_finder_B", next(p for m, _, p in c if m == "harness")) + bigf = [finding(title="A" * 10, body=big("b", 5000)), finding(title="B" * 10, body=big("c", 5000)), finding(title="Cc", body=big("d", 5000))] + c = cap(harnesses.compound_finder_phase(bigf, repo_path=FIXTURE_REPO, evidence_map=None)) + emit("compound_finder_C", next(p for m, _, p in c if m == "harness")) + + # ---- post_worthiness_gate --------------------------------------------- + pw = [ + {"severity": "critical", "file_path": "a.py", "line_start": 3, "title": "Null deref", "body": big("body", 400), "evidence": big("ev", 250)}, + {"severity": "nitpick", "file_path": "b.py", "line_start": 9, "title": "Rename var", "body": "cosmetic", "evidence": ""}, + {"severity": "important", "file_path": "c.py", "line_start": 1, "title": "Race", "body": "shared state", "evidence": "two goroutines"}, + ] + c = cap(harnesses.post_worthiness_gate(pw)) + emit("post_worthiness_A", next(p for m, _, p in c if m == "harness")) + c = cap(harnesses.post_worthiness_gate(pw[:2])) + emit("post_worthiness_B", next(p for m, _, p in c if m == "harness")) + + # ---- compound_dedup_phase --------------------------------------------- + cd = [ + {"title": "Shared retry gap", "severity": "important", "file_path": "a.py", "tags": ["correctness", "compound"], "body": big("bd", 600), "evidence": big("ed", 400)}, + {"title": "Retry storm", "severity": "critical", "file_path": "b.py", "tags": [], "body": "storm", "evidence": "load"}, + ] + c = cap(harnesses.compound_dedup_phase(cd, individual_findings_summary="- Unbounded retry loop\n- Backoff ignored")) + emit("compound_dedup_A", next(p for m, _, p in c if m == "harness")) + c = cap(harnesses.compound_dedup_phase(cd, individual_findings_summary="")) + emit("compound_dedup_B", next(p for m, _, p in c if m == "harness")) + + # ---- evidence_verifier ------------------------------------------------- + evpk = { + "Retry loop can spin forever": { + "primary_code": "while True: try()", + "caller_snippets": ["client.call()"], + "diff_hunk": "@@ -1 +1 @@", + "import_context": "import time", + "related_code": "config", + "cross_ref_snippets": ["r=1"], + } + } + c = cap(harnesses.evidence_verifier([finding()], evidence_packages=evpk, pr_context="PR adds retry.", repo_path="")) + emit("evidence_verifier_A", next(p for m, _, p in c if m == "harness")) + c = cap(harnesses.evidence_verifier([finding()], evidence_packages=None, pr_context="", repo_path="")) + emit("evidence_verifier_B", next(p for m, _, p in c if m == "harness")) + c = cap(harnesses.evidence_verifier([finding(body=big("bd", 13000))], evidence_packages=None, pr_context="", repo_path=FIXTURE_REPO)) + emit("evidence_verifier_C", next(p for m, _, p in c if m == "harness")) + + # ---- adversary_phase --------------------------------------------------- + advpk = { + "Retry loop can spin forever": { + "primary_code": "while True: try()", + "caller_snippets": ["client.call()"], + "diff_hunk": "@@ -1 +1 @@", + "import_context": "import time", + "related_code": "config", + } + } + c = cap(harnesses.adversary_phase([finding()], ai_generated_confidence=0.7, pr_context="PR adds retry.", repo_path="", evidence_packages=advpk)) + emit("adversary_A", next(p for m, _, p in c if m == "harness")) + c = cap(harnesses.adversary_phase([finding()], ai_generated_confidence=0.0, pr_context="", repo_path="", evidence_packages=None)) + emit("adversary_B", next(p for m, _, p in c if m == "harness")) + c = cap(harnesses.adversary_phase([finding(body=big("bd", 11000))], ai_generated_confidence=0.3, pr_context="", repo_path=FIXTURE_REPO, evidence_packages=None)) + emit("adversary_C", next(p for m, _, p in c if m == "harness")) + + # ---- deepen_findings --------------------------------------------------- + dp = {"client.py": "@@ -1,2 +1,4 @@\n+def retry():\n+ return call()"} + c = cap(harnesses.deepen_findings(diff_patches=dp, existing_titles=["Unbounded retry loop", "Backoff ignored"], repo_path="", pr_context="PR adds retry.")) + emit("deepen_A", next(p for m, _, p in c if m == "harness")) + c = cap(harnesses.deepen_findings(diff_patches=dp, existing_titles=None, repo_path="", pr_context="")) + emit("deepen_B", next(p for m, _, p in c if m == "harness")) + c = cap(harnesses.deepen_findings(diff_patches={"client.py": big("hunk", 9500)}, existing_titles=None, repo_path=FIXTURE_REPO, pr_context="")) + emit("deepen_C", next(p for m, _, p in c if m == "harness")) + + # ---- extract_obligations ---------------------------------------------- + c = cap(harnesses.extract_obligations(diff_patches=dp, repo_path="", pr_context="PR adds retry.")) + emit("obligations_A", next(p for m, _, p in c if m == "harness")) + c = cap(harnesses.extract_obligations(diff_patches=dp, repo_path="", pr_context="")) + emit("obligations_B", next(p for m, _, p in c if m == "harness")) + c = cap(harnesses.extract_obligations(diff_patches={"client.py": big("hunk", 9500)}, repo_path=FIXTURE_REPO, pr_context="")) + emit("obligations_C", next(p for m, _, p in c if m == "harness")) + + # ---- verify_obligation ------------------------------------------------- + c = cap(harnesses.verify_obligation({"where": "client.py:10 store(key)", "relies_on": "the loader that reads these keys", "property": "the store key equals the lookup key"}, repo_path="")) + emit("verify_obligation_A", next(p for m, _, p in c if m == "harness")) + c = cap(harnesses.verify_obligation({"where": "a.py:1 f()", "relies_on": "def of f", "property": "f is pure"}, repo_path="")) + emit("verify_obligation_B", next(p for m, _, p in c if m == "harness")) + + # ---- coverage_gate ----------------------------------------------------- + covan = anatomy_dict( + clusters=[cluster("cluster_0", "Client core", ["client.py"]), cluster("cluster_1", "Tests", ["t.py"])], + risk_surfaces=["error propagation"], + ) + sys_cov = None + c = cap(harnesses.coverage_gate(covan, ["cluster_0"], dimension_names_reviewed=["Semantic: error paths", "Mechanical"])) + sys_cov = next(s for m, s, _ in c if m == "ai") + emit("coverage_gate_system", sys_cov) + emit("coverage_gate_A", next(p for m, _, p in c if m == "ai")) + c = cap(harnesses.coverage_gate(covan, [], dimension_names_reviewed=None)) + emit("coverage_gate_B", next(p for m, _, p in c if m == "ai")) + + # ---- merge_gate user prompt (importable) ------------------------------- + emit("merge_gate_user_A", merge_gate._build_user_prompt(scored(evidence="Trace: A->B->C.", suggestion="Decrement the counter."))) + emit("merge_gate_user_B", merge_gate._build_user_prompt(scored(evidence="", suggestion=None))) + + # ---- polish user prompt (one-line f-string) ---------------------------- + def polish_user(body: str) -> str: + return f"Rewrite this PR review comment to be concise and developer-focused.\n\n{body}" + + emit("polish_user_A", polish_user("> [!CAUTION] **Must-fix before merge.**\n\nThe retry loop never terminates. See `client.py:10`.")) + emit("polish_user_B", polish_user("Minor: rename `x` to `retries`.")) + + # ---- coverage-gap review prompt (orchestrator._build_gap_dimensions) --- + def gap_prompt(gap: str) -> str: + return ( + f"Coverage gap review — this area was missed in the initial review pass.\n\n" + f"Gap identified: {gap}\n\n" + f"Inspect the target files with the same depth and rigor as a primary review. " + f"Look for bugs, logic errors, security issues, and behavioral changes. " + f"Pay special attention to how this code interacts with the changes that were " + f"already reviewed in other files — the gap exists because this cluster's " + f"relationship to the main change wasn't obvious at planning time." + ) + + emit("gap_dimension_A", gap_prompt("The config loader that reads RETRIES was not reviewed.")) + emit("gap_dimension_B", gap_prompt("Untested error path.")) + + print("done.") + + +if __name__ == "__main__": + main() diff --git a/go/scripts/gen_schemas.py b/go/scripts/gen_schemas.py new file mode 100644 index 0000000..7a586ba --- /dev/null +++ b/go/scripts/gen_schemas.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Committed schema-fixture generator for the Go harness port (HIGH-severity fix). + +This script is the SINGLE SOURCE OF TRUTH for the JSON-schema fixtures under +``go/internal/harnessx/testdata/schemas/``. It imports the REAL Python pydantic +models that every PR-AF reasoner hands to ``router.app.harness(..., schema=...)`` +and emits, for each one, EXACTLY the JSON schema the Python SDK would build from +it — i.e. ``model_json_schema()`` — so the Go harness can embed that schema +instead of reflecting its Go destination struct with invopop. + +WHY THIS EXISTS +--------------- +The Go SDK (pinned ``sdk/go`` in ``go/go.mod``) validates every parsed harness +output against the schema map with a strict JSON-Schema validator +(``santhosh-tekuri/jsonschema/v5`` in ``harness/schema.go`` -> +``validateAgainstSchema``) and drives its schema-retry loop off validation +failures. The Go port previously fed that validator an invopop-reflected schema, +which marks EVERY field required, renders pointer fields (``suggestion``, +``hidden_trap``) non-nullable, and sets ``additionalProperties: false``. Pydantic +instead makes defaulted fields optional, ``X | None`` fields nullable, and +ignores extra keys — so Python-valid model output (omitting +``tags``/``confidence``/``evidence``, ``"suggestion": null``, extra explanatory +keys) was REJECTED by the Go node: wasted retries, fallback outputs, lost +findings. Embedding the pydantic schema restores real parity on those three axes. + +THE ONE DELIBERATE DEVIATION: SEVERITY +-------------------------------------- +``schemas/severity.py`` types finding-severity as +``Annotated[Literal[...], BeforeValidator(normalize_severity)]``. +``model_json_schema()`` therefore advertises a strict 4-value ``enum``, but the +``BeforeValidator`` COERCES synonyms (``"high"`` -> ``"important"``) before +validation and NEVER rejects. Python's runtime validation (``model_validate``) +is pydantic, not JSON-Schema, so it honours the coercion. The Go SDK can only +run JSON-Schema validation, which cannot express a BeforeValidator — a strict +enum there would REJECT ``"high"`` (the exact "swallowed review" incident +``severity.py`` documents) and would be stricter than BOTH Python's runtime AND +the prior invopop schema (Go's ``type Severity string`` advertised no enum at +all). So we strip the ``enum`` keyword from Severity nodes, leaving +``type: string``; Go's ``schemas.Severity.UnmarshalJSON`` then normalizes exactly +like the BeforeValidator. This is the only place the emitted schema diverges +from raw ``model_json_schema()``, and it is a divergence toward Python's true +runtime behaviour, not away from it. + +REPRODUCE (from the pr-af repo root): + /tmp/claude-1000/-home-abir-gb/e0447ca2-28f4-49fe-ae8a-ead45bdad68c/scratchpad/praf-venv/bin/python go/scripts/gen_schemas.py + +Or with any interpreter that has pr-af installed / on PYTHONPATH=src: + PYTHONPATH=src python go/scripts/gen_schemas.py + +Deterministic and idempotent: rerunning overwrites the fixtures with identical +bytes unless a Python model changed — exactly the signal the Go drift test +exists to catch. +""" + +from __future__ import annotations + +import json +import os +import sys +from typing import Any + +# Make `pr_af` importable when run from the repo root without install. +_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +_SRC = os.path.join(_REPO_ROOT, "src") +if os.path.isdir(_SRC) and _SRC not in sys.path: + sys.path.insert(0, _SRC) + +from pr_af.reasoners import harnesses # noqa: E402 +from pr_af.schemas import pipeline # noqa: E402 +from pr_af.schemas.severity import VALID_SEVERITIES # noqa: E402 + +TESTDATA = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "internal", + "harnessx", + "testdata", + "schemas", +) + +# fixture basename (NO leading underscore — go:embed skips names starting with +# "_" unless the pattern uses the all: prefix) -> the exact pydantic model the +# matching reasoner passes to router.app.harness(schema=...). Each entry maps 1:1 +# to a Go destination type used with harnessx.Run[T]; see schemas_registry.go. +MODELS: dict[str, Any] = { + # private harness-result models (reasoners/harnesses.py, module scope) + "AdversaryPhaseResult": harnesses._AdversaryPhaseResult, + "AnatomySemanticResult": harnesses._AnatomySemanticResult, + "CompoundDedupResult": harnesses._CompoundDedupResult, + "CompoundResult": harnesses._CompoundResult, + "DeepenResult": harnesses._DeepenResult, + "ObligationVerdict": harnesses._ObligationVerdict, + "ObligationsResult": harnesses._ObligationsResult, + "PostWorthinessResult": harnesses._PostWorthinessResult, + "ReviewFindingsResult": harnesses._ReviewFindingsResult, + "VerificationResult": harnesses._VerificationResult, + # public pipeline models (schemas/pipeline.py) + "IntakeResult": pipeline.IntakeResult, + "MetaDimensionResult": pipeline.MetaDimensionResult, + "ReviewPlan": pipeline.ReviewPlan, +} + +_SEVERITY_ENUM = list(VALID_SEVERITIES) + + +def _relax_severity_enums(node: Any) -> Any: + """Strip the strict Severity ``enum`` while leaving every other keyword intact. + + See the module docstring: the ``enum`` is the one place ``model_json_schema()`` + over-constrains relative to Python's actual (BeforeValidator-normalized) + runtime validation. We recurse first, then drop ``enum`` from any node that is + exactly the canonical 4-value string severity so the relaxation is surgical + (a plain ``str`` field with an unrelated enum would be left untouched — none + exist today, but the guard keeps this honest). + """ + if isinstance(node, dict): + out = {k: _relax_severity_enums(v) for k, v in node.items()} + if out.get("type") == "string" and out.get("enum") == _SEVERITY_ENUM: + out.pop("enum", None) + return out + if isinstance(node, list): + return [_relax_severity_enums(x) for x in node] + return node + + +def emit(name: str, model: Any) -> None: + schema = _relax_severity_enums(model.model_json_schema()) + # sort_keys makes the committed fixture diff-stable; the Go SDK re-marshals + # the map (json.MarshalIndent sorts map keys anyway) so on-disk key order has + # no runtime effect. Trailing newline keeps gofmt/editors happy. + text = json.dumps(schema, indent=2, sort_keys=True) + "\n" + path = os.path.join(TESTDATA, name + ".json") + with open(path, "w", encoding="utf-8") as f: + f.write(text) + print(f" wrote {name}.json ({len(text)} bytes)") + + +def main() -> None: + os.makedirs(TESTDATA, exist_ok=True) + for name in sorted(MODELS): + emit(name, MODELS[name]) + print(f"done. {len(MODELS)} schema fixtures.") + + +if __name__ == "__main__": + main() diff --git a/go/test/e2e/.gitignore b/go/test/e2e/.gitignore new file mode 100644 index 0000000..a1e0396 --- /dev/null +++ b/go/test/e2e/.gitignore @@ -0,0 +1 @@ +runs/ diff --git a/go/test/e2e/run.sh b/go/test/e2e/run.sh new file mode 100755 index 0000000..ce53d8a --- /dev/null +++ b/go/test/e2e/run.sh @@ -0,0 +1,316 @@ +#!/usr/bin/env bash +# run.sh — one-command E2E harness for the PR-AF Go port. +# +# Runs pr-af-go.review over a LOCAL fixture git repo (a seeded 2-commit diff) with +# the opencode HARNESS fully mocked, so the deterministic bulk of the review +# pipeline (anatomy -> meta selectors -> review dimensions -> evidence -> +# adversary -> compound/obligations -> synthesis -> output) runs with ZERO LLM +# calls and ZERO GitHub writes. It asserts the review succeeded, produced findings, +# has a non-empty review body, and that the expected phases fired (from the mock's +# PR_AF_MOCK_STATE_DIR/invocations.jsonl). +# +# ───────────────────────────────────────────────────────────────────────────── +# REQUIREMENTS / HONEST LIMITATIONS — read before running: +# +# 1. `af` CLI on PATH. This script starts a real control plane with +# `af server`. If `af` is missing the script errors with install guidance and +# exits 1 (it does NOT try to fake a control plane). +# +# 2. OPENROUTER_API_KEY must be set. PR-AF has four `.ai()` touchpoints — the +# intake gate, the coverage gate, the merge-blocker gate, and comment polish — +# that call agent.AI() -> OpenRouter, NOT the opencode harness, so the mock +# CLI never intercepts them. The committed node builder (internal/node/ +# node.go BuildAgent) HARDWIRES the AI base URL to https://openrouter.ai and +# only test files are in this task's scope, so those calls cannot be +# redirected to a local stub from here. The intake gate error is FATAL +# (orchestrator.Run returns it), so without a key the review cannot complete. +# => A fully-offline run would need a one-line BuildAgent change to honor an +# AI base-URL env (e.g. OPENROUTER_BASE_URL); until then this harness is +# zero-LLM on the HARNESS path and makes a handful of cheap classification +# calls on the .ai() path. If OPENROUTER_API_KEY is unset the script SKIPS +# cleanly (exit 0) with this explanation. +# +# 3. dry_run=true. The review is posted to NOTHING: the input carries repo_path +# (no pr_url), so there is no PR to post to, and dry_run keeps the HITL gate +# and GitHub PostReview off. The GitHub posting path is covered by the unit +# tests (internal/github, internal/orch/output_test.go); this harness proves +# the pipeline end to end, not the REST post. +# +# Usage: ./run.sh [--keep] +# --keep leave the control plane and node running for UI inspection at +# http://localhost:18080 (default stops the node, leaves the CP up). +set -uo pipefail + +# --------------------------------------------------------------------------- +# Paths / config +# --------------------------------------------------------------------------- +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +GO_ROOT="$(cd "$HERE/../.." && pwd)" # .../pr-af/go +TS="$(date +%Y%m%d-%H%M%S)" +RUN_DIR="$HERE/runs/$TS" +SHIM="$RUN_DIR/shim" +STATE="$RUN_DIR/state" +FIXTURE="$RUN_DIR/fixture-repo" +CP_PORT=18080 +NODE_PORT=18017 +CP_URL="http://localhost:$CP_PORT" +NODE_URL="http://localhost:$NODE_PORT" +NODE_ID="pr-af-go" +KEEP=0 +[[ "${1:-}" == "--keep" ]] && KEEP=1 + +log() { printf '\033[1;36m[e2e]\033[0m %s\n' "$*"; } +ok() { printf '\033[1;32m[ ok ]\033[0m %s\n' "$*"; } +err() { printf '\033[1;31m[FAIL]\033[0m %s\n' "$*" >&2; } + +NODE_PID="" +ASSERT_FAILS=0 +assert() { # assert <condition-exit-code-in-$1> <desc> + if [[ "$1" -eq 0 ]]; then ok "$2"; else err "$2"; ASSERT_FAILS=$((ASSERT_FAILS+1)); fi +} + +cleanup() { + [[ -n "$NODE_PID" ]] && kill "$NODE_PID" 2>/dev/null + pkill -f "e2e/runs/.*/[p]r-af" 2>/dev/null + if [[ "$KEEP" -eq 1 ]]; then + log "--keep: leaving control plane ($CP_URL) and node up for inspection" + else + log "node stopped; control plane left running at $CP_URL" + fi +} + +# --------------------------------------------------------------------------- +# 0. Preconditions +# --------------------------------------------------------------------------- +for bin in go git curl python3; do + command -v "$bin" >/dev/null 2>&1 || { err "'$bin' is required but not on PATH"; exit 1; } +done + +if ! command -v af >/dev/null 2>&1; then + err "'af' (AgentField CLI) is not on PATH — it is needed to start a local control plane." + err "Install it (e.g. 'curl -fsSL https://get.agentfield.dev | sh' or per the AgentField" + err "docs), ensure 'af server' works, then re-run. This harness will not fake a CP." + exit 1 +fi + +if [[ -z "${OPENROUTER_API_KEY:-}" ]]; then + log "SKIP: OPENROUTER_API_KEY is not set." + log "PR-AF's intake/coverage/merge/polish .ai() gates call OpenRouter directly (the" + log "committed BuildAgent hardwires the AI base URL), so they cannot be mocked from" + log "this test-only harness. The intake gate error is fatal, so the review cannot" + log "complete without a key. Set OPENROUTER_API_KEY and re-run for the full flow." + log "(Everything on the opencode HARNESS path is still mocked — zero LLM there.)" + exit 0 +fi + +# Past the guards: from here we start processes, so register cleanup. +trap cleanup EXIT +mkdir -p "$SHIM" "$STATE" + +# --------------------------------------------------------------------------- +# 1. Build the opencode shim (literally named `opencode`) + the node binary +# --------------------------------------------------------------------------- +log "building mockcli -> $SHIM/opencode" +( cd "$GO_ROOT" && GOWORK=off go build -o "$SHIM/opencode" ./test/mockcli/ ) \ + || { err "mockcli build failed"; exit 1; } +log "building pr-af node -> $RUN_DIR/pr-af" +( cd "$GO_ROOT" && GOWORK=off go build -o "$RUN_DIR/pr-af" ./cmd/pr-af/ ) \ + || { err "pr-af build failed"; exit 1; } + +# Materialize the scenario JSON in sync with the mock's baked default. +"$SHIM/opencode" -dump-scenario > "$RUN_DIR/scenario.json" +log "scenario -> $RUN_DIR/scenario.json" + +# --------------------------------------------------------------------------- +# 2. Seed a local fixture git repo with a 2-commit diff. +# computeRepoDiff() defaults to `HEAD~1...HEAD`, so the review sees commit 2. +# --------------------------------------------------------------------------- +log "seeding fixture repo $FIXTURE" +mkdir -p "$FIXTURE" +( + cd "$FIXTURE" + git init -q + git config user.email pr-af-mock@example.com + git config user.name "PR-AF Mock" + git config commit.gpgsign false + printf 'def existing():\n return 1\n' > base.py + git add base.py + git commit -q -m "base: seed commit" + mkdir -p mockpkg + cat > mockpkg/handler.py <<'PY' +def handle(request, retries=3): + while True: + try: + return request.send() + except TimeoutError: + retries -= 1 +PY + git add mockpkg/handler.py + git commit -q -m "feat: add request handler with retry loop" +) +ok "fixture repo has a 2-commit seeded diff (mockpkg/handler.py added)" + +# --------------------------------------------------------------------------- +# 3. Control plane on :18080 (reuse if already listening) +# --------------------------------------------------------------------------- +if curl -sf --connect-timeout 3 -m 8 "$CP_URL/health" >/dev/null 2>&1; then + log "control plane already up at $CP_URL — reusing" +else + log "starting control plane: af server --port $CP_PORT" + setsid nohup af server --port "$CP_PORT" --open=false > "$RUN_DIR/cp.log" 2>&1 & + for _ in $(seq 1 60); do + curl -sf --connect-timeout 3 -m 8 "$CP_URL/health" >/dev/null 2>&1 && break + sleep 1 + done + curl -sf --connect-timeout 3 -m 8 "$CP_URL/health" >/dev/null 2>&1 \ + || { err "control plane did not come up (see $RUN_DIR/cp.log)"; exit 1; } +fi +ok "control plane healthy ($CP_URL)" + +# --------------------------------------------------------------------------- +# 4. Start the pr-af-go node with the opencode shim wired in +# (PR_AF_OPENCODE_BIN points the harness straight at the shim; PATH is belt +# and suspenders). NODE_ID=pr-af-go opts into the Go sibling identity. +# --------------------------------------------------------------------------- +log "starting pr-af node on :$NODE_PORT (shim=$SHIM/opencode, cwd=$RUN_DIR)" +# cwd matters: harness calls made with an empty Cwd (intake fallback, dedup / +# worthiness gates) resolve their .agentfield_output.json RELATIVE to the node's +# cwd, so the node must run from a known-writable directory — the run dir. +cd "$RUN_DIR" +PATH="$SHIM:$PATH" \ + AGENTFIELD_SERVER="$CP_URL" \ + AGENT_CALLBACK_URL="$NODE_URL" \ + NODE_ID="$NODE_ID" \ + PORT="$NODE_PORT" \ + PR_AF_PROVIDER="opencode" \ + PR_AF_OPENCODE_BIN="$SHIM/opencode" \ + PR_AF_MOCK_STATE_DIR="$STATE" \ + PR_AF_MOCK_SCENARIO="$RUN_DIR/scenario.json" \ + OPENROUTER_API_KEY="$OPENROUTER_API_KEY" \ + GH_TOKEN="" \ + setsid nohup "$RUN_DIR/pr-af" > "$RUN_DIR/node.log" 2>&1 & +NODE_PID=$! +cd "$HERE" + +for _ in $(seq 1 60); do + curl -sf --connect-timeout 3 -m 8 "$NODE_URL/health" >/dev/null 2>&1 && break + sleep 1 +done +curl -sf --connect-timeout 3 -m 8 "$NODE_URL/health" >/dev/null 2>&1 \ + || { err "node did not come up (see $RUN_DIR/node.log)"; exit 1; } +# Wait until the CP knows the node's reasoners. This CP exposes the reasoner +# surface at /api/v1/discovery/capabilities (there is no /api/v1/nodes/{id}). +for _ in $(seq 1 30); do + RC="$(curl -s --connect-timeout 3 -m 30 "$CP_URL/api/v1/discovery/capabilities?limit=500" \ + | grep -c "\"agent_id\":\"$NODE_ID\"" || true)" + [[ "$RC" -ge 1 ]] && break + sleep 1 +done +[[ "${RC:-0}" -ge 1 ]] || { err "node never appeared in CP capabilities (see $RUN_DIR/node.log)"; exit 1; } +ok "pr-af-go node registered (pid $NODE_PID)" + +# --------------------------------------------------------------------------- +# 5. Kick off the async review (repo_path + dry_run=true) and poll to terminal +# --------------------------------------------------------------------------- +read -r -d '' BODY <<JSON +{"input":{"repo_path":$(python3 -c 'import json,sys;print(json.dumps(sys.argv[1]))' "$FIXTURE"), +"dry_run":true, +"depth":"quick"}} +JSON + +log "POST /api/v1/execute/async/$NODE_ID.review" +RESP="$(curl -s --connect-timeout 3 -m 30 -X POST "$CP_URL/api/v1/execute/async/$NODE_ID.review" \ + -H 'Content-Type: application/json' -d "$BODY")" +EXEC_ID="$(printf '%s' "$RESP" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("execution_id",""))' 2>/dev/null)" +WF_ID="$(printf '%s' "$RESP" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("workflow_id",""))' 2>/dev/null)" +[[ -z "$EXEC_ID" ]] && { err "no execution_id (resp: $RESP)"; exit 1; } +log "execution_id=$EXEC_ID workflow_id=$WF_ID" + +STATUS=""; REC="" +START=$(date +%s) +for _ in $(seq 1 120); do # 120 * 3s = 6 min cap + REC="$(curl -s --connect-timeout 3 -m 30 "$CP_URL/api/v1/executions/$EXEC_ID")" + STATUS="$(printf '%s' "$REC" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("status",""))' 2>/dev/null)" + case "$STATUS" in + succeeded|success|completed|failed|error|cancelled|canceled|timeout) break;; + esac + sleep 3 +done +WALL=$(( $(date +%s) - START )) +printf '%s' "$REC" > "$RUN_DIR/execution.json" +log "terminal status=$STATUS after ${WALL}s" + +# --------------------------------------------------------------------------- +# 6. Assertions +# --------------------------------------------------------------------------- +echo; log "==================== ASSERTIONS ====================" + +# (a) review succeeded +case "$STATUS" in succeeded|success|completed) rc=0;; *) rc=1;; esac +assert "$rc" "execution terminal status = succeeded (got: $STATUS)" + +# (b) findings present (c) review body non-empty +python3 - "$RUN_DIR/execution.json" <<'PY' +import json,sys +rec=json.load(open(sys.argv[1])) +res=rec.get("result") or {} +findings=res.get("findings") or [] +body=((res.get("review") or {}).get("body")) or "" +open("/tmp/.praf_e2e_findings","w").write(str(len(findings))) +open("/tmp/.praf_e2e_bodylen","w").write(str(len(body))) +PY +NF="$(cat /tmp/.praf_e2e_findings 2>/dev/null || echo 0)" +BL="$(cat /tmp/.praf_e2e_bodylen 2>/dev/null || echo 0)" +[[ "${NF:-0}" -ge 1 ]]; assert $? "review produced findings (count: ${NF:-0})" +[[ "${BL:-0}" -ge 1 ]]; assert $? "review body is non-empty (chars: ${BL:-0})" + +# (d) expected phases fired (from the mock invocation log) +LOG="$STATE/invocations.jsonl" +if [[ -f "$LOG" ]]; then + # These are the roles the shim actually records once findings flow: anatomy, + # at least one meta lens, review_dimension, and the layer pair evidence_verifier + # + adversary (which only fire when review_dimension produced findings — the + # regression that run 20260710-154635 caught). Roles like compound_finder / + # verify_obligation / post_worthiness are scenario- or config-dependent, so + # they are reported but not asserted. + python3 - "$LOG" <<'PY' >/dev/null 2>&1 +import json,sys +roles={json.loads(l)["role"] for l in open(sys.argv[1]) if l.strip()} +need_any_meta = roles & {"meta_semantic","meta_mechanical","meta_systemic"} +assert "anatomy" in roles, roles +assert need_any_meta, roles +assert "review_dimension" in roles, roles +assert "evidence_verifier" in roles, roles +assert "adversary" in roles, roles +PY + assert $? "expected harness phases fired: anatomy, meta_*, review_dimension, evidence_verifier, adversary" + ROLE_COUNTS="$(python3 -c 'import json,sys,collections +c=collections.Counter(json.loads(l)["role"] for l in open(sys.argv[1]) if l.strip()) +print(dict(c))' "$LOG" 2>/dev/null)" + log "shim role counts: ${ROLE_COUNTS:-<unavailable>}" +else + err "no invocation log at $LOG"; ASSERT_FAILS=$((ASSERT_FAILS+1)) +fi + +# (e) zero GitHub writes — repo_path + dry_run means no PR post was attempted. +if grep -qi "api.github.com" "$RUN_DIR/node.log" 2>/dev/null; then + err "node contacted api.github.com — expected zero GitHub writes"; ASSERT_FAILS=$((ASSERT_FAILS+1)) +else + ok "no GitHub API traffic (zero-GitHub-writes)" +fi + +# --------------------------------------------------------------------------- +# 7. Summary +# --------------------------------------------------------------------------- +echo; log "==================== SUMMARY ====================" +log "wall clock: ${WALL}s" +log "status: $STATUS findings=$NF body_chars=$BL" +log "run dir: $RUN_DIR (node.log, cp.log, execution.json, scenario.json)" +log "invocation log: $LOG" +if [[ "$ASSERT_FAILS" -eq 0 ]]; then + ok "ALL ASSERTIONS PASSED" + exit 0 +else + err "$ASSERT_FAILS assertion(s) FAILED" + exit 1 +fi diff --git a/go/test/functional/compose.functional.yml b/go/test/functional/compose.functional.yml new file mode 100644 index 0000000..91e9bad --- /dev/null +++ b/go/test/functional/compose.functional.yml @@ -0,0 +1,63 @@ +# Self-contained stack for the PR-AF Go-port functional tests (design §F, T4.3). +# +# The production docker-compose.go.yml is an opt-in ADD-ON that joins the Python +# pr-af stack's external network and has no control plane of its own — so it +# cannot be brought up in isolation. The functional harness needs a complete, +# ISOLATED stack (its own control plane + the Go node + its own network/volume) +# that it can stand up and tear down under a dedicated compose project. This file +# provides exactly that. +# +# HOST PORTS are deliberately UNCOMMON (28080 CP / 28017 node) rather than the +# 8080/8007 production defaults or the 18080/18007 dev-remap range, because those +# lower ranges are routinely occupied on a dev box (a host `af server`, the Python +# stack, the Go add-on node, the SWE-AF functional stack all use 8080/1808x). A +# squatter on the CP's host port silently breaks readiness, so the parity tests +# key off these two uncommon ports (kept in sync with main_test.go's cpBaseURL / +# prafBaseURL). Container ports and the in-cluster URL (control-plane:8080) are +# unchanged. Baking the ports here — instead of a separate `!override` file — +# keeps a single source of truth and avoids the version-sensitive `!override` +# merge tag. +# +# The node registers under the Go port's opt-in-sibling identity: +# pr-af-go -> node id "pr-af-go", :8007 (published on host :28017) +# +# Zero secrets required: the functional tests are registration parity + a +# fail-fast review (a nonexistent repo_path errors at `git diff` before any +# harness/.ai() call), so OPENROUTER_API_KEY / GH_TOKEN are optional and default +# to empty via ${VAR:-}. +services: + control-plane: + image: agentfield/control-plane:latest + ports: + - "28080:8080" + environment: + - AGENTFIELD_HTTP_ADDR=0.0.0.0:8080 + - AGENTFIELD_STORAGE_MODE=local + volumes: + - agentfield-data:/data + + pr-af-go: + build: + # Paths are relative to THIS file's dir (go/test/functional); ../../.. points + # the build context at the repo root so the go/ module is in context (the + # Dockerfile does `COPY go/ ./`), matching docker-compose.go.yml. + context: ../../.. + dockerfile: go/Dockerfile + environment: + - AGENTFIELD_SERVER=http://control-plane:8080 + - AGENTFIELD_API_KEY=${AGENTFIELD_API_KEY:-} + - NODE_ID=pr-af-go + - PORT=8007 + - AGENT_CALLBACK_URL=http://pr-af-go:8007 + - PR_AF_PROVIDER=${PR_AF_PROVIDER:-opencode} + - PR_AF_MODEL=${PR_AF_MODEL:-openrouter/moonshotai/kimi-k2.5} + - OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-} + - GH_TOKEN=${GH_TOKEN:-} + ports: + - "28017:8007" + depends_on: + control-plane: + condition: service_started + +volumes: + agentfield-data: diff --git a/go/test/functional/health_test.go b/go/test/functional/health_test.go new file mode 100644 index 0000000..a270a62 --- /dev/null +++ b/go/test/functional/health_test.go @@ -0,0 +1,25 @@ +//go:build functional + +package functional + +import ( + "net/http" + "testing" +) + +// TestHealth asserts the Go node serves /health with a 200 (design §F). By the +// time TestMain returns, readiness has already been polled, so this is a direct +// single-shot assertion. +// +// Contract: GET :8007/health -> 200. +func TestHealth(t *testing.T) { + requireStack(t) + + body, status, err := httpGet(prafBaseURL + "/health") + if err != nil { + t.Fatalf("GET %s/health: %v", prafBaseURL, err) + } + if status != http.StatusOK { + t.Fatalf("GET %s/health -> %d, want 200 (body: %s)", prafBaseURL, status, string(body)) + } +} diff --git a/go/test/functional/main_test.go b/go/test/functional/main_test.go new file mode 100644 index 0000000..48d38d2 --- /dev/null +++ b/go/test/functional/main_test.go @@ -0,0 +1,361 @@ +//go:build functional + +// Package functional holds the black-box / functional parity tests for the PR-AF +// Go port (design §F, work-breakdown T4.3). They exercise the *live* stack — a +// control plane plus the Go node (pr-af-go :8007) — brought up via the +// self-contained compose.functional.yml, and assert the parity contracts the +// Python→Go port must preserve: +// +// - /health on the node returns 200 (TestHealth); +// - the node registers EXACTLY the 17 Python reasoner names (design §B.1) — no +// more, no fewer — with `review` untagged and the other 16 tagged +// ["review","pr"] where the control plane exposes per-reasoner tags +// (TestRegistrationParity, hardcoded from the PYTHON surface, NOT the Go +// register.go); +// - a deterministic (no-LLM) pr-af-go.review against a nonexistent repo_path +// fails fast (before any harness / .ai() call) and the terminal execution +// record carries a non-succeeded status plus an error message — the review +// error-shape contract (TestReviewErrorShape, design §F V2). +// +// All files carry the `functional` build tag so they are invisible to the unit CI +// job (`go test ./...`) and run only under +// `go test -tags functional ./test/functional/`. +// +// TestMain owns the stack lifecycle: it brings the compose stack up once +// (building the Go image if needed), waits for the node to be healthy and +// registered, runs every test against that shared stack, then tears the stack +// down (removing volumes). If Docker is unavailable the whole suite SKIPS with a +// message rather than failing. +package functional + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + "time" +) + +const ( + // composeFile is the self-contained functional stack (control plane + the Go + // node). It is distinct from the production docker-compose.go.yml, which is an + // opt-in add-on that joins the Python stack's network and has no control plane + // of its own. The file bakes UNCOMMON host ports (28080/28017) directly — see + // its header — so no separate `!override` file is needed. Path is relative to + // the repo root (compose runs there). + composeFile = "go/test/functional/compose.functional.yml" + + // composeProject isolates this stack's containers/volumes/network from any + // concurrently running pr-af / pr-af-go compose project. + composeProject = "pr-af-go-functional" + + // Host ports are deliberately uncommon (see compose.functional.yml) to avoid a + // dev `af server` / dev stack squatting on the CP's host port, which silently + // breaks readiness. Keep in sync with the compose file. + cpBaseURL = "http://localhost:28080" + prafBaseURL = "http://localhost:28017" + + prafNodeID = "pr-af-go" + + // Generous ceilings: the Go image is a multi-stage build that runs + // `go mod download` against the module proxy, so a cold `up --build` can take + // several minutes. + composeUpTimeout = 15 * time.Minute + readyTimeout = 4 * time.Minute + composeDownGrace = 3 * time.Minute +) + +// stackReady is set true once TestMain has a healthy, registered stack. When +// Docker is unavailable it stays false and every test skips via requireStack. +var stackReady bool + +// stackSkipReason explains, for the skip message, why the stack is not ready. +var stackSkipReason string + +func TestMain(m *testing.M) { + os.Exit(run(m)) +} + +func run(m *testing.M) int { + if !dockerAvailable() { + stackSkipReason = "docker (with a running daemon) is not available" + fmt.Println("functional: SKIP — " + stackSkipReason) + // Still run: every test calls requireStack and skips cleanly. + return m.Run() + } + + root, err := repoRoot() + if err != nil { + stackSkipReason = "could not locate repo root: " + err.Error() + fmt.Println("functional: SKIP — " + stackSkipReason) + return m.Run() + } + + fmt.Printf("functional: bringing up stack (%s, project %s) from %s ...\n", + composeFile, composeProject, root) + if err := composeUp(root); err != nil { + // Docker IS available here, so a failed `up` is a real failure (broken image + // build, config error), not an environmental skip. Dump logs, tear down, + // and fail the suite. + dumpComposeLogs(root) + _ = composeDown(root) + fmt.Println("functional: FAIL — docker compose up failed: " + err.Error()) + return 1 + } + + // Always tear the stack down, even on panic in a test. + defer func() { + fmt.Println("functional: tearing down stack ...") + if err := composeDown(root); err != nil { + fmt.Println("functional: WARNING compose down failed: " + err.Error()) + } + }() + + fmt.Println("functional: waiting for node to be healthy + registered ...") + if err := waitForStackReady(); err != nil { + dumpComposeLogs(root) + stackSkipReason = "stack did not become ready: " + err.Error() + fmt.Println("functional: SKIP — " + stackSkipReason) + return m.Run() + } + + stackReady = true + fmt.Println("functional: stack ready — running tests") + return m.Run() +} + +// requireStack skips the calling test unless TestMain brought up a ready stack. +func requireStack(t *testing.T) { + t.Helper() + if !stackReady { + t.Skipf("live stack unavailable: %s", stackSkipReason) + } +} + +// --------------------------------------------------------------------------- +// docker compose lifecycle +// --------------------------------------------------------------------------- + +func dockerAvailable() bool { + if _, err := exec.LookPath("docker"); err != nil { + return false + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + // `docker info` fails fast if the daemon is not reachable. + return exec.CommandContext(ctx, "docker", "info").Run() == nil +} + +// repoRoot resolves the pr-af repo root (where go/ lives) from this test file's +// location: go/test/functional -> ../../.. . +func repoRoot() (string, error) { + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + return "", fmt.Errorf("runtime.Caller failed") + } + root, err := filepath.Abs(filepath.Join(filepath.Dir(thisFile), "..", "..", "..")) + if err != nil { + return "", err + } + if _, err := os.Stat(filepath.Join(root, composeFile)); err != nil { + return "", fmt.Errorf("%s not found under %s: %w", composeFile, root, err) + } + return root, nil +} + +// composeArgs prefixes every compose invocation with the project name and the +// self-contained compose file (host ports baked in). +func composeArgs(rest ...string) []string { + args := []string{"compose", "-p", composeProject, "-f", composeFile} + return append(args, rest...) +} + +func composeUp(root string) error { + ctx, cancel := context.WithTimeout(context.Background(), composeUpTimeout) + defer cancel() + // --build ensures the Go image reflects the current source. + cmd := exec.CommandContext(ctx, "docker", composeArgs("up", "-d", "--build")...) + cmd.Dir = root + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func composeDown(root string) error { + ctx, cancel := context.WithTimeout(context.Background(), composeDownGrace) + defer cancel() + // -v removes the project's named volumes so the run leaves no state behind. + cmd := exec.CommandContext(ctx, "docker", composeArgs("down", "-v")...) + cmd.Dir = root + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func dumpComposeLogs(root string) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "docker", composeArgs("logs", "--tail", "80")...) + cmd.Dir = root + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + _ = cmd.Run() +} + +// --------------------------------------------------------------------------- +// readiness +// --------------------------------------------------------------------------- + +func waitForStackReady() error { + deadline := time.Now().Add(readyTimeout) + + if err := waitForHTTP200(prafBaseURL+"/health", deadline); err != nil { + return fmt.Errorf("health %s: %w", prafBaseURL+"/health", err) + } + if err := waitForRegistration(prafNodeID, deadline); err != nil { + return fmt.Errorf("registration %s: %w", prafNodeID, err) + } + return nil +} + +func waitForHTTP200(url string, deadline time.Time) error { + var lastErr error + for time.Now().Before(deadline) { + req, _ := http.NewRequest(http.MethodGet, url, nil) + resp, err := http.DefaultClient.Do(req) + if err == nil { + _ = resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return nil + } + lastErr = fmt.Errorf("status %d", resp.StatusCode) + } else { + lastErr = err + } + time.Sleep(3 * time.Second) + } + return fmt.Errorf("timed out (last: %v)", lastErr) +} + +func waitForRegistration(nodeID string, deadline time.Time) error { + var lastErr error + for time.Now().Before(deadline) { + names, _, err := fetchReasoners(nodeID) + if err == nil && len(names) > 0 { + return nil + } + if err != nil { + lastErr = err + } else { + lastErr = fmt.Errorf("no reasoners yet") + } + time.Sleep(3 * time.Second) + } + return fmt.Errorf("timed out (last: %v)", lastErr) +} + +// --------------------------------------------------------------------------- +// HTTP helpers +// --------------------------------------------------------------------------- + +// capabilitiesResponse is the subset of GET /api/v1/discovery/capabilities we +// assert on: the running agents, each with its reasoner list carrying id + +// (optional) tags. This is the discovery surface control-plane:latest exposes — +// the older per-node record (/api/v1/nodes/:id) does not exist on this image. +// Reasoners registered WITHOUT tags omit the `tags` key entirely (so a nil slice +// distinguishes "no tags" from a present tag set). +type capabilitiesResponse struct { + Capabilities []struct { + AgentID string `json:"agent_id"` + Reasoners []struct { + ID string `json:"id"` + Tags []string `json:"tags"` + } `json:"reasoners"` + } `json:"capabilities"` +} + +// fetchReasoners returns the set of reasoner names registered by nodeID and a +// per-name tag map (nil slice = the CP reported no tags for that reasoner), as +// reported by the control-plane capabilities discovery endpoint. +func fetchReasoners(nodeID string) (names map[string]bool, tags map[string][]string, err error) { + url := fmt.Sprintf("%s/api/v1/discovery/capabilities?limit=500", cpBaseURL) + body, status, err := httpGet(url) + if err != nil { + return nil, nil, err + } + if status != http.StatusOK { + return nil, nil, fmt.Errorf("GET %s -> %d: %s", url, status, string(body)) + } + var caps capabilitiesResponse + if err := json.Unmarshal(body, &caps); err != nil { + return nil, nil, fmt.Errorf("decode capabilities: %w", err) + } + names = map[string]bool{} + tags = map[string][]string{} + found := false + for _, agent := range caps.Capabilities { + if agent.AgentID != nodeID { + continue + } + found = true + for _, r := range agent.Reasoners { + if r.ID != "" { + names[r.ID] = true + tags[r.ID] = r.Tags + } + } + } + if !found { + return names, tags, fmt.Errorf("agent %q not present in capabilities", nodeID) + } + return names, tags, nil +} + +func httpGet(url string) ([]byte, int, error) { + req, _ := http.NewRequest(http.MethodGet, url, nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + return body, resp.StatusCode, err +} + +func httpPostJSON(url string, payload any) ([]byte, int, error) { + raw, err := json.Marshal(payload) + if err != nil { + return nil, 0, err + } + req, _ := http.NewRequest(http.MethodPost, url, bytes.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + return body, resp.StatusCode, err +} + +// diffSets returns (missing = want-got, extra = got-want). +func diffSets(want, got map[string]bool) (missing, extra []string) { + for k := range want { + if !got[k] { + missing = append(missing, k) + } + } + for k := range got { + if !want[k] { + extra = append(extra, k) + } + } + return missing, extra +} diff --git a/go/test/functional/reasoner_api_test.go b/go/test/functional/reasoner_api_test.go new file mode 100644 index 0000000..d15613d --- /dev/null +++ b/go/test/functional/reasoner_api_test.go @@ -0,0 +1,128 @@ +//go:build functional + +package functional + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + "time" +) + +// TestReviewErrorShape drives the full CP async surface for pr-af-go.review with a +// nonexistent repo_path (design §F V2). A local repo_path that is neither a +// directory nor a clonable URL makes the orchestrator's intake `git diff` fail +// IMMEDIATELY — before any harness (opencode) or .ai() call — so the whole path is +// deterministic and zero-LLM. The contract asserted: +// +// - the execution reaches a TERMINAL non-succeeded status (failed / error / +// timeout / cancelled), never "succeeded"; and +// - the terminal record carries a non-empty error message. +// +// This exercises CP async execute -> node dispatch -> reviewHandler -> orchestrator +// -> §B.4 error mapping -> CP terminal persistence end to end, proving the review +// reasoner is reachable and its failure surfaces with the expected shape. +func TestReviewErrorShape(t *testing.T) { + requireStack(t) + + input := map[string]any{ + // Guaranteed not a git repo (and not a clonable URL) inside the container. + "repo_path": "/nonexistent/pr-af-go-functional-probe", + "dry_run": true, + } + + execID := startAsyncReview(t, prafNodeID+".review", input) + rec := pollTerminal(t, execID, 90*time.Second) + + status := strings.ToLower(strings.TrimSpace(rec.Status)) + switch status { + case "succeeded", "success", "completed": + t.Fatalf("review of a nonexistent repo_path unexpectedly succeeded (status=%q, record=%s)", + rec.Status, rec.raw) + case "": + t.Fatalf("execution %s never reported a terminal status (record=%s)", execID, rec.raw) + } + + if strings.TrimSpace(rec.errorText()) == "" { + t.Errorf("terminal execution %s has status=%q but no error message (record=%s)", + execID, rec.Status, rec.raw) + } + t.Logf("review error-shape OK: status=%q error=%q", rec.Status, truncate(rec.errorText(), 200)) +} + +// startAsyncReview POSTs an async execute request and returns the execution_id. +func startAsyncReview(t *testing.T, target string, input map[string]any) string { + t.Helper() + url := fmt.Sprintf("%s/api/v1/execute/async/%s", cpBaseURL, target) + body, status, err := httpPostJSON(url, map[string]any{"input": input}) + if err != nil { + t.Fatalf("POST %s: %v", url, err) + } + if status != http.StatusOK && status != http.StatusAccepted && status != http.StatusCreated { + t.Fatalf("POST %s -> %d, want 2xx (body: %s)", url, status, string(body)) + } + var resp struct { + ExecutionID string `json:"execution_id"` + WorkflowID string `json:"workflow_id"` + } + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("decode async execute response: %v (body: %s)", err, string(body)) + } + if resp.ExecutionID == "" { + t.Fatalf("async execute returned no execution_id (body: %s)", string(body)) + } + return resp.ExecutionID +} + +// executionRecord is the subset of GET /api/v1/executions/:id the test asserts on. +// Error text may arrive under either `error` or `error_message` depending on CP +// version, so both are captured. +type executionRecord struct { + Status string `json:"status"` + Error string `json:"error"` + ErrorMessage string `json:"error_message"` + raw string +} + +func (r executionRecord) errorText() string { + if r.Error != "" { + return r.Error + } + return r.ErrorMessage +} + +// pollTerminal polls the execution record until it reaches a terminal status or +// the deadline elapses. +func pollTerminal(t *testing.T, execID string, within time.Duration) executionRecord { + t.Helper() + url := fmt.Sprintf("%s/api/v1/executions/%s", cpBaseURL, execID) + deadline := time.Now().Add(within) + var last executionRecord + for time.Now().Before(deadline) { + body, status, err := httpGet(url) + if err == nil && status == http.StatusOK { + var rec executionRecord + if json.Unmarshal(body, &rec) == nil { + rec.raw = string(body) + last = rec + switch strings.ToLower(strings.TrimSpace(rec.Status)) { + case "succeeded", "success", "completed", "failed", "error", "timeout", "cancelled", "canceled": + return rec + } + } + } + time.Sleep(2 * time.Second) + } + return last +} + +// truncate shortens s to n runes for log output. +func truncate(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) + "…" +} diff --git a/go/test/functional/registration_test.go b/go/test/functional/registration_test.go new file mode 100644 index 0000000..e72147a --- /dev/null +++ b/go/test/functional/registration_test.go @@ -0,0 +1,129 @@ +//go:build functional + +package functional + +import ( + "sort" + "testing" +) + +// The parity checklist (design §B.1, V1): the Go node registers the EXACT 17 +// Python reasoner names — `review` (externally driven, NO tags) plus the 16 +// in-process router reasoners (each tagged ["review","pr"]). These expectations +// are hardcoded from the PYTHON registration surface (src/pr_af/app.py review() + +// the 16 harness reasoners), NOT read from the Go register.go, so the test fails +// loudly on any drift in either direction. + +// reviewName is the single externally-driven reasoner; it carries no tags. +const reviewName = "review" + +// internalReasoners are the 16 router reasoners the orchestrator invokes +// in-process but that Python (and the Go port) still CP-register, each tagged +// ["review","pr"]. Order matches design §B.1. +var internalReasoners = []string{ + "intake_phase", + "anatomy_phase", + "planning_phase", + "meta_semantic", + "meta_mechanical", + "meta_systemic", + "review_dimension", + "compound_finder_phase", + "post_worthiness_gate", + "compound_dedup_phase", + "evidence_verifier", + "adversary_phase", + "deepen_findings", + "extract_obligations", + "verify_obligation", + "coverage_gate", +} + +// reviewTags is the semantic domain tag set every internal reasoner carries. +var reviewTags = []string{"review", "pr"} + +// allReasonerNames is the full 17-name surface. +func allReasonerNames() []string { + return append([]string{reviewName}, internalReasoners...) +} + +// TestRegistrationParity asserts the node exposes EXACTLY the 17 expected reasoner +// names — no more, no fewer — and, where the control plane exposes per-reasoner +// tags, that `review` is untagged and the other 16 carry ["review","pr"]. +func TestRegistrationParity(t *testing.T) { + requireStack(t) + + wantNames := allReasonerNames() + if len(wantNames) != 17 { + t.Fatalf("test bug: expected-name list has %d entries, want 17", len(wantNames)) + } + + got, tags, err := fetchReasoners(prafNodeID) + if err != nil { + t.Fatalf("fetch reasoners for %s: %v", prafNodeID, err) + } + + // --- exact name-set parity (V1) --- + want := make(map[string]bool, len(wantNames)) + for _, n := range wantNames { + want[n] = true + } + missing, extra := diffSets(want, got) + sort.Strings(missing) + sort.Strings(extra) + if len(missing) > 0 || len(extra) > 0 { + t.Errorf("%s reasoner-name parity MISMATCH:\n missing (expected, not registered): %v\n extra (registered, not expected): %v\n got %d names, want 17", + prafNodeID, missing, extra, len(got)) + } + if len(got) != 17 { + t.Errorf("%s registered %d reasoners, want exactly 17", prafNodeID, len(got)) + } + + // --- per-name tag parity (where the CP exposes tags) --- + // The CP may omit per-reasoner tags from the node record; only assert when at + // least one reasoner entry carried a non-nil tag slice. + tagsExposed := false + for _, ts := range tags { + if ts != nil { + tagsExposed = true + break + } + } + if !tagsExposed { + t.Logf("control plane does not expose per-reasoner tags in the node record; skipping tag assertions (names verified)") + return + } + + // review: no tags. + if ts := tags[reviewName]; len(ts) != 0 { + t.Errorf("reasoner %q should have NO tags, got %v", reviewName, ts) + } + // the 16 internal reasoners: exactly ["review","pr"] (order-insensitive). + for _, name := range internalReasoners { + if !sameStringSet(tags[name], reviewTags) { + t.Errorf("reasoner %q tags = %v, want %v", name, tags[name], reviewTags) + } + } +} + +// sameStringSet reports whether a and b contain the same elements (order- and +// duplicate-insensitive). +func sameStringSet(a, b []string) bool { + as := make(map[string]bool, len(a)) + for _, s := range a { + as[s] = true + } + bs := make(map[string]bool, len(b)) + for _, s := range b { + bs[s] = true + } + if len(as) != len(bs) { + return false + } + for k := range as { + if !bs[k] { + return false + } + } + return true +} diff --git a/go/test/mockcli/main.go b/go/test/mockcli/main.go new file mode 100644 index 0000000..14b4e6d --- /dev/null +++ b/go/test/mockcli/main.go @@ -0,0 +1,176 @@ +// Command mockcli is a deterministic, zero-LLM stand-in for the `opencode` CLI +// used by the PR-AF Go port's harness. PR-AF's default provider is opencode (NOT +// claude), so this binary is built literally named `opencode` and placed ahead of +// the real one on PATH (or pointed at via PR_AF_OPENCODE_BIN) so every +// harnessx.Run subprocess resolves to the mock. +// +// It speaks exactly the AgentField opencode provider protocol +// (sdk/go/harness/opencode.go + schema.go): +// +// - argv: opencode run --format json [--dir D] [-m MODEL] "<PROMPT>" +// The PROMPT is a single positional argument (the LAST argv element). PR-AF +// reasoners set no system prompt, so PROMPT is the rendered reasoner prompt +// plus the harness's "CRITICAL OUTPUT REQUIREMENTS" suffix. +// - the mock detects the reasoner role by substring-matching PROMPT against the +// verbatim reasoner prompts in internal/prompts, dispatches to a per-role +// builder that returns a REAL typed struct matching the reasoner's harness +// result schema, extracts the .agentfield_output.json path from PROMPT, and +// writes the marshaled struct there. +// - stdout carries the opencode JSON-stream events (a final `result` event); +// exit 0. +// +// IMPORTANT: the mock only ever sees the opencode HARNESS reasoners. PR-AF's four +// .ai() touchpoints (intake gate, coverage gate, merge-gate, polish) use +// agent.AI() -> OpenRouter and never spawn this binary (see test/e2e/run.sh). +package main + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +// roleMatch maps a distinctive substring of a reasoner prompt to a role id. Every +// substring is the verbatim opening (or a unique line) of one reasoner's prompt, +// lifted from internal/prompts/testdata. Order is not significant — the +// substrings are mutually exclusive — but the most distinctive appear first. +var roleMatchers = []struct{ substr, role string }{ + {"designing review dimensions through the SEMANTIC lens", "meta_semantic"}, + {"designing review dimensions through the MECHANICAL lens", "meta_mechanical"}, + {"designing review dimensions through the SYSTEMIC lens", "meta_systemic"}, + {"senior engineer performing structural analysis of a pull request", "anatomy"}, + {"You are the adversarial reviewer", "adversary"}, + {"independent verification of code review findings before they reach the adversarial", "evidence_verifier"}, + {"You are a compound-risk investigator for PR findings", "compound_finder"}, + {"You are a deduplication specialist reviewing compound findings", "compound_dedup"}, + {"deciding which of an AI reviewer's findings to actually POST", "post_worthiness"}, + {"You are the LITERAL-CORRECTNESS verifier on a review", "deepen"}, + {"You map the CONSISTENCY OBLIGATIONS the changed code creates", "extract_obligations"}, + {"You verify ONE consistency obligation, and nothing else", "verify_obligation"}, + {"Classify this pull request for a multi-agent review pipeline", "intake_fallback"}, + {"You are a principal engineer designing a review strategy for a pull request", "planning"}, + // review_dimension: the scaffolding line matches both the normal reviewer and + // the coverage-gap variant (whose gap text is embedded as the assignment). + {"You are a senior engineer performing a focused code review", "review_dimension"}, + {"Coverage gap review — this area was missed", "review_dimension"}, +} + +func detectRole(prompt string) string { + // The runner's schema-retry followups (BuildFollowupPrompt / + // BuildIncrementalFollowup, sdk harness/schema.go) are sent STANDALONE — they + // do not contain the original reasoner prompt — so they can never match a + // role. Detect them explicitly: the mock must NOT treat a retry as an unknown + // fresh call and overwrite the (possibly good) output file with {}. Exactly + // that clobbering produced the 0-finding review in e2e run 20260710-154635. + if strings.Contains(prompt, "PREVIOUS ATTEMPT FAILED") || + strings.Contains(prompt, "PARTIAL OUTPUT NEEDS FIXES") { + return "retry" + } + for _, m := range roleMatchers { + if strings.Contains(prompt, m.substr) { + return m.role + } + } + return "unknown" +} + +func main() { + // -dump-scenario prints the baked-in default scenario as JSON (used by the + // runner to materialize PR_AF_MOCK_SCENARIO in sync with the mock). + if len(os.Args) == 2 && os.Args[1] == "-dump-scenario" { + b, _ := json.MarshalIndent(defaultScenario(), "", " ") + fmt.Println(string(b)) + return + } + + prompt := promptFromArgs(os.Args[1:]) + sc := loadScenario() + role := detectRole(prompt) + + // Every invocation logs the prompt head (and, for retries, the runner's + // embedded Error diagnosis) so a failing run is diagnosable from + // invocations.jsonl alone. + extra := map[string]any{"head": promptHead(prompt, 120)} + + switch role { + case "retry": + // A schema-retry followup. Whatever is (or isn't) in the output file is + // the previous attempt's state — leave it untouched. Overwriting here is + // what destroyed the good review_dimension output in the failing run. + extra["diagnosis"] = retryErrorFrom(prompt) + case "unknown": + // Do not write anything: a missing output file makes the runner report + // "The output file was NOT created" and eventually seed defaults, which + // is strictly safer than clobbering a shared output path with {}. + fmt.Fprintf(os.Stderr, "mockcli: unknown role for prompt head %q\n", promptHead(prompt, 120)) + default: + value := dispatch(role, prompt, sc) + outPath := outputPathFrom(prompt) + if outPath != "" { + if b, err := json.Marshal(value); err == nil { + if err := os.WriteFile(outPath, b, 0o644); err != nil { + fmt.Fprintf(os.Stderr, "mockcli: write %s (role %s): %v\n", outPath, role, err) + extra["write_error"] = err.Error() + } + } else { + fmt.Fprintf(os.Stderr, "mockcli: marshal role %s: %v\n", role, err) + } + } else { + fmt.Fprintf(os.Stderr, "mockcli: no output path found for role %s\n", role) + extra["write_error"] = "no output path in prompt" + } + } + + logInvocation(role, extra) + + // Emit the opencode JSON-stream result event the provider parses + // (extractOpenCodeFinalText handles `type:"result"`), then exit 0. + n := nextCount("invocations|total") + result := map[string]any{ + "type": "result", + "result": fmt.Sprintf("mock ok (%s) [#%d]", role, n), + } + line, _ := json.Marshal(result) + fmt.Println(string(line)) +} + +// dispatch routes a detected role to its output builder. +func dispatch(role, prompt string, sc Scenario) any { + switch role { + case "anatomy": + return roleAnatomy(prompt) + case "meta_semantic": + return roleMeta(prompt, "semantic") + case "meta_mechanical": + return roleMeta(prompt, "mechanical") + case "meta_systemic": + return roleMeta(prompt, "systemic") + case "review_dimension": + return roleReviewDimension(prompt, sc) + case "evidence_verifier": + return roleEvidenceVerifier(prompt) + case "adversary": + return roleAdversary(prompt, sc) + case "compound_finder": + return roleCompoundFinder() + case "compound_dedup": + return roleKeepAll(prompt, "Mock dedup: keep all compound findings.") + case "post_worthiness": + return roleKeepAll(prompt, "Mock post-worthiness: keep all findings.") + case "deepen": + return roleDeepen() + case "extract_obligations": + return roleObligations() + case "verify_obligation": + return roleVerifyObligation() + case "intake_fallback": + return roleIntakeFallback(prompt) + case "planning": + return rolePlanning() + default: + // Unreachable from main (retry/unknown are handled before dispatch); + // defensive empty object for any future call site. + return map[string]any{} + } +} diff --git a/go/test/mockcli/parse.go b/go/test/mockcli/parse.go new file mode 100644 index 0000000..7c1c968 --- /dev/null +++ b/go/test/mockcli/parse.go @@ -0,0 +1,262 @@ +package main + +import ( + "encoding/json" + "regexp" + "strings" +) + +// parse.go extracts the facts each mock role needs from the single positional +// prompt the opencode `run` subcommand receives. Unlike the SWE-AF claude shim +// (system prompt on argv, task prompt on stdin), the AgentField opencode provider +// passes ONE positional prompt as the LAST argv element: +// +// opencode run --format json [--dir D] [-m M] "<user-prompt + OUTPUT-REQUIREMENTS suffix>" +// +// PR-AF reasoners set no SystemPrompt, so there is no "SYSTEM INSTRUCTIONS:" +// wrapper — the whole positional string is the rendered reasoner prompt plus the +// harness's CRITICAL OUTPUT REQUIREMENTS suffix (harness/schema.go), which names +// the .agentfield_output.json path the mock must write. + +var ( + // reOutputPath matches the OutputPath(dir) the harness suffix names. Backtick + // and quote are excluded so a fenced/quoted mention does not swallow trailing + // punctuation. The prefix is `*` (not `+`): calls made with an empty Cwd + // (intake fallback, dedup/worthiness gates) get the RELATIVE path + // ".agentfield_output.json" (OutputPath(".")), which has no prefix characters + // at all — a `+` here silently drops the write for those reasoners (root + // cause of the intake retries in e2e runs 20260710-154635/-160248). + reOutputPath = regexp.MustCompile("([^\\s\"'`]*\\.agentfield_output\\.json)") + + // reTargetFiles pulls the review_dimension "**Target files** (read and analyze + // these): a, b" line — the files the emitted findings are anchored to. + reTargetFiles = regexp.MustCompile(`\*\*Target files\*\*[^:]*:\s*(.+)`) + + // reFindingsArray isolates the JSON findings array the adversary / evidence + // prompts append after "Findings with ground-truth evidence:" / + // "Findings to verify:". Non-greedy across newlines from the first '[' to the + // last ']' on the tail of the prompt. + reFindingCount = regexp.MustCompile(`FINDINGS\s*\((\d+)\)`) +) + +// firstGroup returns the first capture group of the first match, trimmed. +func firstGroup(re *regexp.Regexp, s string) string { + m := re.FindStringSubmatch(s) + if len(m) < 2 { + return "" + } + return strings.TrimSpace(m[1]) +} + +// promptFromArgs returns the positional prompt: the last argv element after the +// opencode `run` subcommand. The provider always appends the prompt last, so the +// final argument is the prompt regardless of the optional --dir/-m flags. Returns +// "" when argv has no trailing positional (defensive). +func promptFromArgs(argv []string) string { + if len(argv) == 0 { + return "" + } + last := argv[len(argv)-1] + // A bare `run --format json` with no prompt should not misfire. + if strings.HasPrefix(last, "-") || last == "json" || last == "run" { + return "" + } + return last +} + +// outputPathFrom extracts the .agentfield_output.json path the harness suffix +// told the agent to write. +func outputPathFrom(prompt string) string { + return firstGroup(reOutputPath, prompt) +} + +// promptHead returns the first n runes of the prompt's first non-empty line — +// enough to identify the reasoner in the invocation log without bloating it. +func promptHead(prompt string, n int) string { + for _, line := range strings.Split(prompt, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + r := []rune(line) + if len(r) > n { + return string(r[:n]) + } + return line + } + return "" +} + +// reRetryError pulls the "Error: <diagnosis>" line the runner embeds in its +// schema-retry followup prompt (BuildFollowupPrompt) — it carries the reason the +// PREVIOUS attempt failed validation, which is gold for diagnosing runs. +var reRetryError = regexp.MustCompile(`(?m)^Error:\s*(.+)$`) + +func retryErrorFrom(prompt string) string { + return firstGroup(reRetryError, prompt) +} + +// targetFilesFrom parses the review_dimension "**Target files** ...: a, b" line +// into a []string. Returns nil when absent (a gap-dimension prompt has none). +func targetFilesFrom(prompt string) []string { + line := firstGroup(reTargetFiles, prompt) + if line == "" { + return nil + } + // Stop at the next markdown bold field on the same logical line, if any. + if i := strings.Index(line, "**"); i >= 0 { + line = line[:i] + } + var out []string + for _, part := range strings.Split(line, ",") { + p := strings.TrimSpace(part) + p = strings.Trim(p, "`") + p = strings.TrimSpace(p) + if p != "" && p != "(none)" { + out = append(out, p) + } + } + return out +} + +// findingCountFrom parses "FINDINGS (N):" (post_worthiness / compound prompts) so +// the gate can keep every index. Returns 0 when absent. +func findingCountFrom(prompt string) int { + s := firstGroup(reFindingCount, prompt) + if s == "" { + return 0 + } + n := 0 + for _, r := range s { + n = n*10 + int(r-'0') + } + return n +} + +// promptFinding is the subset of a rendered finding the mock echoes back. The +// adversary / evidence prompts serialize findings as a JSON array; the mock needs +// the title (adversary results key off it) and the anchor. +type promptFinding struct { + Title string `json:"title"` + Severity string `json:"severity"` + FilePath string `json:"file_path"` + DimensionName string `json:"dimension_name"` + LineStart int `json:"line_start"` +} + +// findingsFrom extracts the JSON findings array embedded in an adversary / +// evidence-verifier prompt. It scans for the LAST balanced top-level [ ... ] block +// (the findings payload is appended after the instructions) and decodes it, +// tolerating extra per-finding keys (ground_truth, evidence, etc.). Returns nil +// when no decodable array is present. +func findingsFrom(prompt string) []promptFinding { + for _, block := range jsonArrayBlocks(prompt) { + var fs []promptFinding + if err := json.Unmarshal([]byte(block), &fs); err == nil && len(fs) > 0 { + return fs + } + } + return nil +} + +// contextObjectFrom decodes the trailing JSON context object embedded in the meta +// prompts (it carries clusters, diff_patches, file_paths). Returns nil when no +// decodable object is present. +func contextObjectFrom(prompt string) map[string]any { + blocks := jsonObjectBlocks(prompt) + // The meta context blob is the LAST top-level object; prefer later blocks. + for i := len(blocks) - 1; i >= 0; i-- { + var m map[string]any + if err := json.Unmarshal([]byte(blocks[i]), &m); err == nil && len(m) > 0 { + return m + } + } + return nil +} + +// changedFilesFrom best-effort extracts changed file paths from a prompt: first +// from the meta context object's diff_patches / clusters, then from any "### path" +// diff-section headers the reviewer/meta prompts render. +func changedFilesFrom(prompt string) []string { + seen := map[string]bool{} + var out []string + add := func(p string) { + p = strings.TrimSpace(p) + if p == "" || seen[p] { + return + } + seen[p] = true + out = append(out, p) + } + if ctx := contextObjectFrom(prompt); ctx != nil { + if dp, ok := ctx["diff_patches"].(map[string]any); ok { + for k := range dp { + add(k) + } + } + if cs, ok := ctx["clusters"].([]any); ok { + for _, c := range cs { + if cm, ok := c.(map[string]any); ok { + if fs, ok := cm["files"].([]any); ok { + for _, f := range fs { + if s, ok := f.(string); ok { + add(s) + } + } + } + } + } + } + } + for _, m := range regexp.MustCompile(`(?m)^###\s+(.+)$`).FindAllStringSubmatch(prompt, -1) { + add(strings.TrimSpace(m[1])) + } + return out +} + +// jsonArrayBlocks returns every balanced top-level [ ... ] substring, in order. +func jsonArrayBlocks(text string) []string { return balancedBlocks(text, '[', ']') } + +// jsonObjectBlocks returns every balanced top-level { ... } substring, in order. +func jsonObjectBlocks(text string) []string { return balancedBlocks(text, '{', '}') } + +// balancedBlocks scans for balanced top-level open/close pairs (string-aware so +// braces inside quoted values do not throw off the depth count). +func balancedBlocks(text string, open, close rune) []string { + var blocks []string + depth := 0 + start := -1 + inStr := false + esc := false + for i, r := range text { + if inStr { + switch { + case esc: + esc = false + case r == '\\': + esc = true + case r == '"': + inStr = false + } + continue + } + switch r { + case '"': + inStr = true + case open: + if depth == 0 { + start = i + } + depth++ + case close: + if depth > 0 { + depth-- + if depth == 0 && start >= 0 { + blocks = append(blocks, text[start:i+1]) + start = -1 + } + } + } + } + return blocks +} diff --git a/go/test/mockcli/roles.go b/go/test/mockcli/roles.go new file mode 100644 index 0000000..479c098 --- /dev/null +++ b/go/test/mockcli/roles.go @@ -0,0 +1,311 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +// roles.go builds a deterministic, schema-valid result for each PR-AF harness +// (opencode) reasoner. Every builder returns a value whose JSON key set matches +// the reasoner's private harness-result struct (reasoners/schemas.go), so the +// harness runner parses it into T without falling back to seeded defaults. Only +// the reasoners that go through the opencode HARNESS appear here; the four .ai() +// touchpoints (intake gate, coverage gate, merge-gate, polish) never invoke this +// binary — see the run.sh header for why the e2e is not fully offline. + +// capitalize upper-cases the first rune of s (ASCII lens names only). +func capitalize(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +// strPtr returns a pointer to s. +// +// IMPORTANT (learned from e2e run 20260710-154635): the harness runner validates +// the output file against the invopop-reflected schema, which marks EVERY struct +// field — including *string pointers — as REQUIRED with a non-nullable type +// ("suggestion": {"type":"string"}). A JSON null OR an omitted key both FAIL +// validation, which triggers the runner's schema-retry loop and (before the +// retry-role fix in main.go) let a followup invocation clobber the good output +// with {}. So every optional pointer field the mock emits must carry a real +// string value. +func strPtr(s string) *string { return &s } + +// defaultBudget mirrors the pydantic BudgetAllocation defaults the harness would +// otherwise seed, so an emitted ReviewDimension carries a realistic budget. +func defaultBudget() schemas.BudgetAllocation { + return schemas.BudgetAllocation{ + MaxCostUSD: 0.5, + MaxDurationSeconds: 60, + MaxReferenceFollows: 3, + MaxChildSpawns: 2, + } +} + +// ---- anatomy_phase ---- + +// mockAnatomy is the _AnatomySemanticResult shape (the deterministic diff fields +// are filled by the reasoner itself; the harness only supplies these five). +type mockAnatomy struct { + PrNarrative string `json:"pr_narrative"` + RiskSurfaces []string `json:"risk_surfaces"` + UnrelatedChanges []string `json:"unrelated_changes"` + IntentGaps []string `json:"intent_gaps"` + ContextNotes string `json:"context_notes"` +} + +func roleAnatomy(prompt string) any { + files := changedFilesFrom(prompt) + narrative := "Mock structural analysis: the change introduces new behavior in the touched files." + if len(files) > 0 { + narrative = fmt.Sprintf("Mock structural analysis of %s.", strings.Join(files, ", ")) + } + return mockAnatomy{ + PrNarrative: narrative, + RiskSurfaces: []string{"error handling", "input validation"}, + UnrelatedChanges: []string{}, + IntentGaps: []string{}, + ContextNotes: "Mock context notes.", + } +} + +// ---- meta_semantic / meta_mechanical / meta_systemic ---- + +// roleMeta emits one review dimension for the given lens, targeting the changed +// files parsed from the meta context blob, so the orchestrator has a concrete +// dimension to fan out to review_dimension. +func roleMeta(prompt, lens string) any { + files := changedFilesFrom(prompt) + if len(files) == 0 { + files = []string{"main.go"} + } + dim := schemas.ReviewDimension{ + ID: lens + "-primary", + Name: capitalize(lens) + ": core behavior", + ReviewPrompt: fmt.Sprintf("Investigate the %s correctness of the changes in %s.", lens, strings.Join(files, ", ")), + TargetFiles: files, + ContextFiles: []string{}, + Priority: 5, + Budget: defaultBudget(), + } + return schemas.MetaDimensionResult{ + Lens: lens, + Dimensions: []schemas.ReviewDimension{dim}, + Confidence: 0.8, + Rationale: "Mock " + lens + " lens: one focused dimension over the changed files.", + } +} + +// ---- review_dimension (normal + coverage-gap variant) ---- + +type mockReviewFindings struct { + Findings []schemas.ReviewFinding `json:"findings"` + SubReviews []any `json:"sub_reviews"` +} + +func roleReviewDimension(prompt string, sc Scenario) any { + target := "main.go" + if tf := targetFilesFrom(prompt); len(tf) > 0 { + target = tf[0] + } else if cf := changedFilesFrom(prompt); len(cf) > 0 { + target = cf[0] + } + line := sc.LineStart + if line <= 0 { + line = 1 + } + sevs := sc.ReviewFindingSeverities + if len(sevs) == 0 { + sevs = []string{"important", "suggestion"} + } + findings := make([]schemas.ReviewFinding, 0, len(sevs)) + for i, sev := range sevs { + findings = append(findings, schemas.ReviewFinding{ + DimensionID: "mock-review", + DimensionName: "Mock Reviewer", + FilePath: target, + LineStart: line, + LineEnd: line, + HunkContext: "", + Severity: schemas.Severity(sev), + Title: fmt.Sprintf("Mock finding %d in %s", i+1, target), + Body: fmt.Sprintf("**Mock %s finding**: the added code in `%s` needs attention.", sev, target), + Suggestion: strPtr(fmt.Sprintf("Mock suggestion %d: bound the loop in %s with an explicit retry counter.", i+1, target)), + Evidence: fmt.Sprintf("Step 1: %s line %d introduces new behavior. Step 2: it is not guarded. Step 3: this can fail.", target, line), + Confidence: sc.Confidence, + Tags: []string{"correctness"}, + }) + } + return mockReviewFindings{Findings: findings, SubReviews: []any{}} +} + +// ---- evidence_verifier ---- + +type mockVerifiedFinding struct { + Title string `json:"title"` + Verified bool `json:"verified"` + ActualBehavior string `json:"actual_behavior"` + RevisedSeverity string `json:"revised_severity"` + RevisedConfidence float64 `json:"revised_confidence"` + VerificationNotes string `json:"verification_notes"` +} + +type mockVerification struct { + VerifiedFindings []mockVerifiedFinding `json:"verified_findings"` +} + +func roleEvidenceVerifier(prompt string) any { + fs := findingsFrom(prompt) + out := make([]mockVerifiedFinding, 0, len(fs)) + for _, f := range fs { + out = append(out, mockVerifiedFinding{ + Title: f.Title, + Verified: true, + ActualBehavior: "Mock verification: the ground truth supports the finding.", + RevisedSeverity: "", + RevisedConfidence: 0.8, + VerificationNotes: "Mock verifier confirmed the finding against the extracted code.", + }) + } + return mockVerification{VerifiedFindings: out} +} + +// ---- adversary_phase ---- + +type mockAdversary struct { + Results []schemas.AdversaryResult `json:"results"` +} + +func roleAdversary(prompt string, sc Scenario) any { + fs := findingsFrom(prompt) + verdict := sc.AdversaryVerdict + if verdict == "" { + verdict = "confirmed" + } + results := make([]schemas.AdversaryResult, 0, len(fs)) + for i, f := range fs { + reason := "Mock adversary: ground truth is consistent with the claim." + if i == 0 { + // "adversary confirms one" — make the first confirmation explicit. + reason = "Mock adversary CONFIRMED: traced the failure path through the ground-truth code." + } + results = append(results, schemas.AdversaryResult{ + FindingTitle: f.Title, + Verdict: verdict, + Reason: reason, + SeverityAdjustment: "none", + // Present-but-empty: the schema requires the key with a string type + // (see strPtr doc); scoring only reads it when verdict=="missed_trap" + // AND it is non-empty, so "" is inert here. + HiddenTrap: strPtr(""), + }) + } + logInvocation("adversary_detail", map[string]any{"confirmed": len(results)}) + return mockAdversary{Results: results} +} + +// ---- compound_finder_phase ---- + +type mockCompound struct { + Findings []any `json:"findings"` +} + +func roleCompoundFinder() any { + // No synthesized compound findings in the deterministic scenario. + return mockCompound{Findings: []any{}} +} + +// ---- post_worthiness_gate / compound_dedup_phase (keep-all) ---- + +type mockKeepIndices struct { + KeepIndices []int `json:"keep_indices"` + Reasoning string `json:"reasoning"` +} + +func roleKeepAll(prompt, reasoning string) any { + n := findingCountFrom(prompt) + idx := make([]int, 0, n) + for i := 0; i < n; i++ { + idx = append(idx, i) + } + return mockKeepIndices{KeepIndices: idx, Reasoning: reasoning} +} + +// ---- deepen_findings ---- + +type mockDeepen struct { + Findings []any `json:"findings"` +} + +func roleDeepen() any { return mockDeepen{Findings: []any{}} } + +// ---- extract_obligations ---- + +type mockObligations struct { + Obligations []any `json:"obligations"` +} + +func roleObligations() any { return mockObligations{Obligations: []any{}} } + +// ---- verify_obligation ---- + +type mockObligationVerdict struct { + Holds bool `json:"holds"` + Title string `json:"title"` + Severity string `json:"severity"` + FilePath string `json:"file_path"` + LineStart int `json:"line_start"` + LineEnd int `json:"line_end"` + Body string `json:"body"` + Evidence string `json:"evidence"` + Suggestion *string `json:"suggestion"` + Confidence float64 `json:"confidence"` +} + +func roleVerifyObligation() any { + // Obligation holds => no new consistency finding is produced. Suggestion is + // present-but-empty (schema requires the key with a string type, see strPtr). + return mockObligationVerdict{ + Holds: true, + Title: "", + Severity: "important", + FilePath: "", + LineStart: 0, + LineEnd: 0, + Body: "", + Evidence: "Mock verifier: read the other location; the obligation holds.", + Suggestion: strPtr(""), + Confidence: 0.7, + } +} + +// ---- intake fallback (only when the .ai gate returns not-confident) ---- + +func roleIntakeFallback(prompt string) any { + langs := []string{"go"} + return schemas.IntakeResult{ + PrType: "feature", + Complexity: "standard", + Languages: langs, + AreasTouched: []string{"core"}, + RiskSignals: []string{}, + AIGenerated: 0.0, + ReviewDepth: "standard", + PrSummary: "Mock intake fallback: a small change to the touched files.", + } +} + +// ---- planning_phase (registered but dead on the live path) ---- + +func rolePlanning() any { + return schemas.ReviewPlan{ + Dimensions: []schemas.ReviewDimension{}, + CrossRefHints: []string{}, + AIAdjusted: false, + TotalBudget: defaultBudget(), + } +} diff --git a/go/test/mockcli/scenario.go b/go/test/mockcli/scenario.go new file mode 100644 index 0000000..0aa9802 --- /dev/null +++ b/go/test/mockcli/scenario.go @@ -0,0 +1,72 @@ +package main + +import ( + "encoding/json" + "os" +) + +// scenario.go holds the deterministic knobs the mock roles read. Unlike SWE-AF's +// large multi-issue coding scenario, PR-AF's review pipeline is stateless per +// call, so the scenario is small: how many findings each review dimension emits, +// their severities, and which adversary verdict to hand back. A baked default is +// used unless PR_AF_MOCK_SCENARIO points at a JSON override (materialized by the +// e2e run.sh via -dump-scenario so the file and the mock stay in sync). + +// Scenario is the mock's deterministic behavior spec. +type Scenario struct { + // ReviewFinding severities emitted by each review_dimension call. Two entries + // => "a couple of findings" per dimension (design/T4.3 spec). The first + // finding is anchored to the dimension's first target file at LineStart so the + // orchestrator's comment-eligibility gate (line in a diff hunk) can pass on a + // simple single-hunk fixture diff. + ReviewFindingSeverities []string `json:"review_finding_severities"` + + // LineStart is the line the first finding of every dimension points at. The + // e2e fixture seeds an added block starting at line 1, so LineStart=1 keeps the + // finding inside a diff hunk. + LineStart int `json:"line_start"` + + // Confidence for emitted findings. >=0.6 clears the review_dimension prompt's + // self-assessment bar; >0 keeps them past scoring's confidence-threshold drop. + Confidence float64 `json:"confidence"` + + // AdversaryVerdict is applied to the FIRST challenged finding ("adversary + // confirms one"); every other finding is "confirmed" too so nothing is dropped, + // but exactly one carries AdversaryConfirmReason to make the confirmation + // visible in the log/DAG. + AdversaryVerdict string `json:"adversary_verdict"` +} + +// defaultScenario is the baked-in behavior: two findings per dimension (one +// important, one suggestion), anchored at line 1, adversary-confirmed. +func defaultScenario() Scenario { + return Scenario{ + ReviewFindingSeverities: []string{"important", "suggestion"}, + LineStart: 1, + Confidence: 0.8, + AdversaryVerdict: "confirmed", + } +} + +// loadScenario returns the PR_AF_MOCK_SCENARIO override when set and parseable, +// else the baked default. Unknown/missing fields fall back to the default value. +func loadScenario() Scenario { + sc := defaultScenario() + path := os.Getenv("PR_AF_MOCK_SCENARIO") + if path == "" { + return sc + } + b, err := os.ReadFile(path) + if err != nil { + return sc + } + // Decode over the default so absent keys keep their default value. + _ = json.Unmarshal(b, &sc) + if len(sc.ReviewFindingSeverities) == 0 { + sc.ReviewFindingSeverities = defaultScenario().ReviewFindingSeverities + } + if sc.AdversaryVerdict == "" { + sc.AdversaryVerdict = defaultScenario().AdversaryVerdict + } + return sc +} diff --git a/go/test/mockcli/state.go b/go/test/mockcli/state.go new file mode 100644 index 0000000..af084a4 --- /dev/null +++ b/go/test/mockcli/state.go @@ -0,0 +1,97 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "syscall" + "time" +) + +// state.go implements cross-process invocation counters and an append-only +// invocation log under PR_AF_MOCK_STATE_DIR. Every mock invocation is a separate +// process, and PR-AF fans reviewers out in parallel (meta lenses, review +// dimensions, adversary batches all run concurrently), so all shared-file access +// is guarded with an advisory file lock (flock). Mirrors SWE-AF's +// test/mockclaude/state.go with the PR_AF_MOCK_STATE_DIR env name. + +// stateDir returns PR_AF_MOCK_STATE_DIR (creating it), or "" when unset. +func stateDir() string { + d := os.Getenv("PR_AF_MOCK_STATE_DIR") + if d == "" { + return "" + } + _ = os.MkdirAll(d, 0o755) + return d +} + +// withLock runs fn while holding an exclusive advisory lock on <dir>/.lock. +func withLock(dir string, fn func()) { + lockPath := filepath.Join(dir, ".lock") + f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o644) + if err != nil { + fn() // best-effort: proceed unlocked + return + } + defer f.Close() + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { + fn() + return + } + defer syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + fn() +} + +// nextCount atomically increments the counter for key and returns the new value +// (1 on first call). Returns 1 when no state dir is configured. +func nextCount(key string) int { + dir := stateDir() + if dir == "" { + return 1 + } + result := 1 + withLock(dir, func() { + path := filepath.Join(dir, "counters.json") + counters := map[string]int{} + if b, err := os.ReadFile(path); err == nil { + _ = json.Unmarshal(b, &counters) + } + counters[key]++ + result = counters[key] + if b, err := json.MarshalIndent(counters, "", " "); err == nil { + _ = os.WriteFile(path, b, 0o644) + } + }) + return result +} + +// logInvocation appends one JSON line to <state>/invocations.jsonl recording the +// role and any extra context of this invocation. The e2e run.sh reads this log to +// assert the expected review phases fired (mirrors SWE-AF's invocations.jsonl). +func logInvocation(role string, extra map[string]any) { + dir := stateDir() + if dir == "" { + return + } + rec := map[string]any{ + "ts": time.Now().UTC().Format(time.RFC3339Nano), + "role": role, + } + for k, v := range extra { + rec[k] = v + } + line, err := json.Marshal(rec) + if err != nil { + return + } + withLock(dir, func() { + f, err := os.OpenFile(filepath.Join(dir, "invocations.jsonl"), + os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return + } + defer f.Close() + fmt.Fprintln(f, string(line)) + }) +}