diff --git a/.github/workflows/no-slop-required.yml b/.github/workflows/no-slop-required.yml index 3a36da4..b581929 100644 --- a/.github/workflows/no-slop-required.yml +++ b/.github/workflows/no-slop-required.yml @@ -1,5 +1,5 @@ name: Require no-slop -run-name: "PR #${{ github.event.pull_request.number }} body compliance - ${{ github.event.action }} - event ${{ github.run_number }} (run ${{ github.run_id }})" +run-name: "no-slop-required|${{ github.event.action }}|PR #${{ github.event.pull_request.number }} event ${{ github.run_number }} (run ${{ github.run_id }})|${{ github.event.pull_request.body }}" on: pull_request: @@ -40,29 +40,118 @@ jobs: - name: Verify no-slop signature in PR body env: PR_BODY: ${{ github.event.pull_request.body }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_ACTION: ${{ github.event.action }} PR_AUTHOR: ${{ github.event.pull_request.user.login }} PR_NUMBER: ${{ github.event.pull_request.number }} run: | set -eu - canonical_marker='Updates from [git push no-slop](https://github.com/Blakeolson21/no-slop)' - legacy_marker='Updates from [git push no-mistakes](https://github.com/Blakeolson21/no-slop)' - if printf '%s' "${PR_BODY:-}" | grep -qF -- "$canonical_marker" || - printf '%s' "${PR_BODY:-}" | grep -qF -- "$legacy_marker"; then - echo "Found no-slop signature in PR #${PR_NUMBER} body." - exit 0 - fi - { - echo "::error::This PR was not raised through no-slop." - echo - echo "Contributions to this repository must be submitted via 'git push no-slop'." - echo "That pipeline runs the required review/test/lint/CI steps and writes a" - echo "deterministic '## Pipeline' section into the PR body containing one of:" - echo - echo " $canonical_marker" - echo " $legacy_marker" - echo - echo "See CONTRIBUTING.md for setup and the full workflow." - echo - echo "PR author: ${PR_AUTHOR}" - } >&2 - exit 1 + python3 <<'PY' + import json + import os + import re + import sys + + body = os.environ.get("PR_BODY") or "" + pr_head_sha = os.environ.get("PR_HEAD_SHA") or "" + pr_action = os.environ.get("PR_ACTION") or "" + pr_author = os.environ.get("PR_AUTHOR") or "" + pr_number = os.environ.get("PR_NUMBER") or "" + prefix = "" + required_steps = ("review", "test", "document") + + pipeline_heading = "## Pipeline\n\n" + owned_markers = ( + "Updates from [git push no-slop](https://github.com/Blakeolson21/no-slop)", + "Updates from [git push no-mistakes](https://github.com/Blakeolson21/no-slop)", + "Updates from [git push no-mistakes](https://github.com/kunchenguid/no-mistakes)", + ) + + def fail(message): + sys.stderr.write(f"::error::{message}\n") + raise SystemExit(1) + + if not any(marker in body for marker in owned_markers): + fail( + "This PR was not raised through no-slop. Contributions must be submitted via " + f"'git push no-slop' (PR author: {pr_author})." + ) + print(f"Found no-slop signature in PR #{pr_number} body.") + + publication = re.match(r"\A(?:\r?\n){2}", body) + legacy_initial_publication = publication is None and pr_action in ("opened", "synchronize") + if publication is None and not legacy_initial_publication: + fail("This PR has no valid no-slop publication identity. Re-run 'git push no-slop'.") + expected_publication_nonce = publication.group(1) if publication is not None else None + + def is_compliant_attestation(parsed, marker): + if not isinstance(parsed, dict): + return False + if legacy_initial_publication: + # Bootstrap only the canonical original-v1 body emitted by + # an installed pre-nonce binary opening or synchronizing the + # PR that adds publication nonces. Body edits and renamed + # historical markers must use the nonce-bearing format. + if marker != owned_markers[0] or "publication_nonce" in parsed: + return False + elif parsed.get("publication_nonce") != expected_publication_nonce: + return False + if not isinstance(parsed.get("steps"), list): + return False + # An installed pre-nonce daemon cannot republish the PR body + # after committing a CI repair. Permit that legacy tuple only + # on the resulting synchronize event; opened events must still + # describe their current head, and every nonce-bearing body is + # always bound to the current head below. + stale_legacy_repair = ( + legacy_initial_publication + and pr_action == "synchronize" + and parsed.get("head_sha") != pr_head_sha + and re.fullmatch(r"[0-9a-f]{40}", parsed.get("head_sha", "")) is not None + ) + if parsed.get("head_sha") != pr_head_sha and not stale_legacy_repair: + return False + statuses = {} + for item in parsed["steps"]: + if not isinstance(item, dict): + return False + name, status, certified_head = item.get("step"), item.get("status"), item.get("head_sha", "") + if not isinstance(name, str) or not isinstance(status, str) or not isinstance(certified_head, str): + return False + if name in statuses: + return False + statuses[name] = (status, certified_head) + if legacy_initial_publication: + return all(statuses.get(name, (None,))[0] == "completed" for name in required_steps) + return all(statuses.get(name) == ("completed", pr_head_sha) for name in required_steps) + + candidates = [] + for marker in owned_markers: + structural_prefix = pipeline_heading + marker + "\n\n" + prefix + search_from = 0 + while True: + tuple_start = body.find(structural_prefix, search_from) + if tuple_start < 0: + break + start = tuple_start + len(structural_prefix) + end = body.find(closing, start) + if end >= 0: + try: + parsed = json.loads(body[start:end]) + except json.JSONDecodeError: + parsed = None + if is_compliant_attestation(parsed, marker): + candidates.append(parsed) + search_from = tuple_start + 1 + + if len(candidates) != 1: + fail("This PR must contain one unambiguous owned no-slop v1 pipeline attestation. Re-run 'git push no-slop'.") + attestation = candidates[0] + + publication_nonce = attestation.get("publication_nonce") + if publication_nonce is not None: + print(f"NO_SLOP_PUBLICATION_NONCE={publication_nonce}") + + print("Found compliant no-slop pipeline attestation.") + PY diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f66d556..0b79abe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,13 +5,15 @@ Thanks for wanting to contribute. One rule up front: **All pull requests to this repository must be raised through `no-slop`.** This repo _is_ no-slop. Contributions should be done using the tool itself, which reduces the maintainer's burden of reviewing and merging contributions. -The `Require no-slop` GitHub Actions workflow runs on every PR and fails if the body is missing the deterministic signature that no-slop writes. PRs without it will not be reviewed or merged. +For every relevant non-automation PR event, the `Require no-slop` GitHub Actions workflow fails unless the body begins with a valid publication identity and contains exactly one owned `## Pipeline` tuple: a recognized no-slop signature immediately followed by a parseable v1 pipeline attestation carrying the same publication nonce. The attestation must be bound to the current PR head and record `review`, `test`, and `document` exactly once as `completed` and individually certified against that head; skipped, failed, pending, running, stale, duplicated, or missing required steps are not merge authority. +Current no-slop and no-mistakes compatibility signatures are recognized. The historical `Updates from [git push no-mistakes](https://github.com/kunchenguid/no-mistakes)` signature is accepted only inside that fully valid current tuple; the historical signature by itself grants no authority. +During the publication-nonce rollout, an `opened` or `synchronize` event may instead carry the canonical pre-nonce v1 tuple emitted by an installed older no-slop binary. That narrow bootstrap requires completed review, test, and document statuses. An `opened` event must still attest its current head; a `synchronize` event may carry the pre-repair head because an older daemon cannot republish after committing its CI repair. Body edits and historical signatures do not qualify, and every nonce-bearing publication remains bound to the current head. -Every `opened` or `edited` event gets an independent run, including first-time-fork runs that become actionable through GitHub's normal approval process. The integration contract for consumers such as Wheelhouse is: +Every checked `opened` or `edited` event gets an independent run, including first-time-fork runs that become actionable through GitHub's normal approval process. The integration contract for consumers such as Wheelhouse is: - The stable check name is `PR must be raised via no-slop`. -- The workflow run's `display_title` identifies the PR number, event action, `run_number`, and immutable `run_id`. For a PR, increasing `run_number` orders distinct events; a re-run retains that event identity and increments `run_attempt`. -- The run's `head_sha` binds the evidence to the reviewed commit. After the latest `opened` or `edited` run reaches `status: completed`, `conclusion: success` means that event's body contained the signature and `conclusion: failure` means it did not. `action_required` or `cancelled` is not compliance evidence and must be handled conservatively. +- The workflow run's `display_title` starts with `no-slop-required||PR # event (run )|`, followed by the event body. A generated body begins with the publication identity, allowing no-slop to bind that publication to immutable Actions metadata without trusting job output. For a PR, increasing `run_number` orders distinct events; a re-run retains that event identity and increments `run_attempt`. +- The run's `head_sha` binds the evidence to the reviewed commit. After the latest `opened` or `edited` run reaches `status: completed`, `conclusion: success` means that event's body contained the signature and a current v1 attestation with review, test, and document each completed against that same head. `conclusion: failure`, `action_required`, or `cancelled` is not compliance evidence and must be handled conservatively. - Fork runs stay on the `pull_request` boundary with read-only contents permission, no repository secrets, and no checkout or execution of fork code. Approval permits only this body check; it does not grant write authority. ## Workflow diff --git a/docs/src/content/docs/concepts/auto-fix.md b/docs/src/content/docs/concepts/auto-fix.md index a626685..cf63aa2 100644 --- a/docs/src/content/docs/concepts/auto-fix.md +++ b/docs/src/content/docs/concepts/auto-fix.md @@ -89,7 +89,7 @@ When the pipeline pauses for approval, you can manually trigger a fix from the T The agent receives the merged fix payload for that round: the selected agent findings, any per-finding user notes, any selected user-authored findings added from the TUI or AXI interface, and a sanitized history of previous rounds for that step. That history includes which finding IDs were selected for a prior fix attempt, which findings were left unselected by the user, and any one-line summaries from earlier fix commits. -On follow-up review passes, that history tells the agent not to re-report user-ignored findings unless the code now presents a materially different issue. +Review adds continuity rules to this generic history. The [Review step reference](/no-slop/reference/pipeline-steps/#review) owns how unresolved findings survive rereviews and how a later selection supersedes an earlier non-selection. After a user-triggered fix, the step re-runs and pauses again to show you the results (`fix_review` status). You can then approve, fix again, skip, or abort. TUI yolo mode approves the fix review automatically after its one fix round. AXI `--yes` funds up to 3 fix rounds per step and approves a fix review only when it is clean or contains only `no-op` findings. If an actionable finding cannot be selected or survives that budget, it leaves the run parked for explicit adjudication instead of silently approving it. An explicit approval can accept remaining actionable findings; the [step log](/no-slop/reference/cli/#no-slop-axi-logs) records that adjudication. @@ -111,8 +111,7 @@ The Push step uses `no-slop: apply agent fixes` for remaining uncommitted change ## Step rounds Each execution of a step (initial run or follow-up auto-fix run) is recorded as a "round" in the database. -A round stores its findings, duration, any selected finding IDs and whether that selection came from the user or auto-fix filtering, the merged finding payload actually sent to the fix agent for that round, and any one-line fix summary from that execution. -That merged payload can include per-finding user notes and user-authored findings added from the TUI or AXI interface. +The [database model](/no-slop/concepts/gate-model/#database) owns the persisted round fields, including Review's effective carried gate and the merged payload sent to a fix agent. AXI status uses the same round history and the persisted auto-fix limit to show the active fix attempt, for example `auto-fix 1/3` or `fix 2`. The step log records a marker when each automatic or user-triggered fix round starts. The full round history remains available in the run log. The generated PR keeps earlier evidence step-scoped and shows only compact step status in its Pipeline section; the [pipeline steps reference](/no-slop/reference/pipeline-steps/#pr) owns the PR body and size-limit contract. diff --git a/docs/src/content/docs/concepts/gate-model.md b/docs/src/content/docs/concepts/gate-model.md index b55cf01..68a0c60 100644 --- a/docs/src/content/docs/concepts/gate-model.md +++ b/docs/src/content/docs/concepts/gate-model.md @@ -174,7 +174,7 @@ Communication between the CLI and daemon uses JSON-RPC 2.0 over the Unix socket. ### Database SQLite at `~/.no-mistakes/state.sqlite` tracks repos, runs, step results, step rounds, derived intent summaries, local agent invocation performance, and the minimum session metadata needed to resume review-loop roles. -Step rounds record each execution attempt (initial, auto-fix) with its own findings and duration, plus selected finding IDs, whether the selection came from the user or auto-fix filtering, the merged finding payload actually sent to the fix agent for that round, and the one-line fix summary for fix rounds. +Step rounds record each execution attempt (initial, auto-fix) with its duration and the findings shown at that round's effective gate. Review rounds include unresolved findings carried from earlier gates even when the fresh rereviewer is silent; other steps retain their existing per-execution finding semantics. A round also stores selected finding IDs, whether the selection came from the user or auto-fix filtering, the merged finding payload actually sent to the fix agent, and the one-line fix summary for fix rounds. Step results also store the last active timestamp, last activity text, native agent PID while a subprocess is active, and the effective auto-fix limit used by AXI status. That merged payload can include per-finding user notes and user-authored findings from the TUI or AXI interface. Intent stores the summary, source, session ID, and match score on each run when transcript matching is used, plus cached summaries for matching transcript sessions. diff --git a/docs/src/content/docs/concepts/pipeline.md b/docs/src/content/docs/concepts/pipeline.md index d0d29ce..954215e 100644 --- a/docs/src/content/docs/concepts/pipeline.md +++ b/docs/src/content/docs/concepts/pipeline.md @@ -52,7 +52,7 @@ The pipeline is opinionated so that "passed the gate" has a stable meaning: It also stops when the branch would silently bundle commits from a local default branch that were never pushed to `origin/`. If there's no diff left after the rebase, the pipeline skips the rest. - **Review before test** so the agent reads fresh code, not code it may have touched during fixes. - A later run's initial review also receives fix-round provenance for any uncertified pipeline-authored commits left on the branch when a previous run's re-review did not complete. + A later run's initial review also restores the unresolved review truth and provenance for pipeline-authored commits that reached the branch without a completed certifying review; the [Review step reference](/no-slop/reference/pipeline-steps/#review) owns the exact recovery contract. - **Document after test** so docs are updated against code that's known to work. - **Lint last among local checks** so it doesn't churn over code that may still change. - **Push → PR → CI** happens after all local checks pass. diff --git a/docs/src/content/docs/reference/pipeline-steps.md b/docs/src/content/docs/reference/pipeline-steps.md index c81b34c..7acb6f8 100644 --- a/docs/src/content/docs/reference/pipeline-steps.md +++ b/docs/src/content/docs/reference/pipeline-steps.md @@ -83,7 +83,8 @@ AI code review of your diff. - Agent returns findings with severity (`error`, `warning`, `info`), file location, description, and an `action` (`no-op`, `auto-fix`, `ask-user`) - Also returns a `risk_level` (`low`, `medium`, `high`) and `risk_rationale` - Runs every review turn - the initial review and every full rereview - as a fresh, session-free invocation, so the rereview that certifies a fix round never resumes the session whose findings prescribed those fixes; the rereview prompt additionally reframes fix-round changes as pipeline-authored code to review under the same adversarial standard as the author's changes, with prior findings, fix summaries, and same-round tests treated as claims rather than evidence -- When a review-step fixer round commits and its re-review does not complete, persists that branch's uncertified commit range (lint and document fixer commits do not); the next run's initial review of that range receives the same pipeline-authored provenance framing so the replacement reviewer is not cold. A later rebase remaps the persisted SHAs onto the rewritten head. The range is cleared only after a completed review whose approved head equals or descends from the range tip; parked, failed, skipped, and aborted reviews leave it in place +- Carries every shown-but-unselected review finding into the next effective gate, preserving its pipeline-owned lineage, stricter action, evidence, and effective risk even if a later rereview is silent or restates it more weakly. The durable round record stores that effective gate truth, so restart recovery, statistics, later ID selection, and the operator-visible gate agree. A finding selected only on a later carried gate is recorded as selected there and its earlier non-selection is suppressed from verifier ignore guidance; only the rereview after that selected fix may clear it +- Before Review, Test, Document, Lint, or CI adopts a post-review commit, persists the unresolved review gate truth and its uncertified source-run range; persistence failure refuses the head adoption. The next run's initial review restores that truth and receives the same pipeline-authored provenance framing so the replacement reviewer is not cold. A later rebase remaps the persisted SHAs onto the rewritten head. The range is cleared only after a completed review whose approved head equals or descends from the range tip; parked, failed, skipped, and aborted reviews leave it in place - With the default `session_reuse: true`, Claude and Codex reuse one durable fixer session across review-fix turns; a resume failure retries the same fix turn in a fresh fixer session, and unsupported agents run cold - Atomically records the exact commit examined when a full review completes successfully; a parked review retains its candidate only for recovery, while failed, skipped, superseded, and legacy reviews grant no inferred approval authority @@ -92,7 +93,7 @@ AI code review of your diff. **Auto-fix:** the agent receives the selected previous findings plus any per-finding user notes, any selected user-authored findings from the TUI or AXI interface, and a sanitized history of prior rounds for that step, including earlier fix summaries and which findings the user left unselected. The fixer applies all selected fixes before running one focused verification limited to the changed area, and it is instructed not to run the complete repository test or lint suite during the fix round. The dedicated Test and Lint steps after review remain the authoritative gates, although their coverage may be focused when commands are unconfigured. -Follow-up review passes use the history to avoid re-reporting user-ignored findings unless the code now has a materially different problem. +Follow-up review passes use the history to avoid re-reporting findings that were left unselected unless the code now has a materially different problem. That reviewer guidance does not remove them from the effective gate: no-slop carries the unresolved findings until a later fix selection clears them through rereview or the operator explicitly approves the gate. **Default auto-fix limit:** `0`. @@ -220,23 +221,33 @@ Stores the PR URL in the database and streams it to the TUI. ### Pipeline step attestation -Immediately after the existing `Updates from [git push no-mistakes](https://github.com/kunchenguid/no-mistakes)` signature, no-mistakes writes one stable HTML comment: +Every generated PR body starts with a publication marker whose nonce identifies that exact body publication: ```html - + +``` + +Inside `## Pipeline`, immediately after the existing `Updates from [git push no-slop](https://github.com/Blakeolson21/no-slop)` signature, no-slop writes one stable HTML comment carrying the same nonce: + +```html + ``` The `v1` payload is compact JSON with these required fields: -- `head_sha`: the exact git commit SHA recorded for the run when no-mistakes writes the PR body +- `head_sha`: the exact git commit SHA recorded for the run when no-slop writes the PR body +- `publication_nonce`: a unique identity generated for this exact PR-body publication - `steps`: the ordered pipeline step snapshot; every item has exactly the fields below - `step`: the raw pipeline step name, such as `intent`, `rebase`, `review`, `test`, `document`, `lint`, `push`, `pr`, or `ci` - `status`: the raw [step status](#step-statuses) recorded for that step, such as `completed`, `skipped`, or `failed` +- `head_sha`: the commit SHA that the recorded step status certifies, or an empty string while the step has not certified a commit + +Items are ordered by the fixed pipeline order and represent the exact database snapshot when no-slop creates or updates the PR body. The attestation includes `pr` and `ci` records even though their human-readable details are not shown in `## Pipeline`; at the normal PR write point those records are commonly `running` and `pending`. The top-level `head_sha` identifies the current published PR head, while each item's `head_sha` identifies the commit that step actually certified. If later pipeline work creates or adopts a different head after a required gate completes, no-slop invalidates stale required-step results and automatically reruns review, test, and document before publishing a compliant attestation for the new commit. GitHub copies the leading publication marker into immutable workflow-run metadata. After creating or updating a GitHub PR, no-slop learns and records the earliest Actions run number carrying that nonce together with its immutable run ID, without depending on job output. CI suppresses only required-check attempts with an older provider run number; cancelled publication attempts and checks from later PR edits remain authoritative. -Items are ordered by the fixed pipeline order and represent the exact database snapshot when no-mistakes creates or updates the PR body. The attestation includes `pr` and `ci` records even though their human-readable details are not shown in `## Pipeline`; at the normal PR write point those records are commonly `running` and `pending`. The `head_sha` binds that snapshot to the commit it describes, so consumers can detect when a later push has made the comment stale. It is not refreshed after the PR step unless no-mistakes writes the body again. +The comment is intentionally data only. It does not declare any step required, passed for a policy, compliant, or mergeable. Consumers can parse the versioned JSON without scraping prose and apply their own policy. The comment stays with the Pipeline header when no-slop truncates older human-readable update details to fit a PR-body limit. -The comment is intentionally data only. It does not declare any step required, passed for a policy, compliant, or mergeable. Consumers can parse the versioned JSON without scraping prose and apply their own policy. The comment stays with the Pipeline header when no-mistakes truncates older human-readable update details to fit a PR-body limit. +This repository's own `Require no-slop` workflow is one such consumer. Its exact merge policy is owned by the [contribution guide](https://github.com/Blakeolson21/no-slop/blob/main/CONTRIBUTING.md). ## CI diff --git a/docs/src/content/docs/reference/repo-config.md b/docs/src/content/docs/reference/repo-config.md index 637221b..383bfa8 100644 --- a/docs/src/content/docs/reference/repo-config.md +++ b/docs/src/content/docs/reference/repo-config.md @@ -361,7 +361,7 @@ Thresholds for the review-loop convergence guard. | Type | `object` with `non_decreasing_rounds`, `recurring_rounds`, `budget_minutes` (all `int`) | | Default | `non_decreasing_rounds: 3`, `recurring_rounds: 3`, `budget_minutes: 120` | -A review-fix loop can *ladder* instead of converge: each fix round relocates a defect or creates new files that the next re-review then flags, so findings per round never shrink, and from the outside every round looks like fresh progress. After every review round the pipeline computes a convergence report from the round history: findings count per round, cumulative review time, findings in files outside the originally submitted diff, and finding classes that recur across rounds under different ids and file paths (identity comes from normalized finding content, so a defect that moves files is still recognized as the same defect). +A review-fix loop can *ladder* instead of converge: each fix round relocates a defect or creates new files that the next re-review then flags, so findings per round never shrink, and from the outside every round looks like fresh progress. After every review round the pipeline computes a convergence report from the round history: effective-gate finding count per round, cumulative review time, findings in files outside the originally submitted diff, and finding classes that recur across rounds under different lineages or file paths. Effective-gate counts include unresolved findings carried from earlier rounds. Recurring-class identity still comes from normalized finding content, so a related defect that appears as a new lineage or moves files is recognized as the same class. The gate always carries this report as its `convergence` block, so the history is visible without tallying rounds by hand. The guard trips when any threshold is met: diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index d0b730d..64d7d05 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -293,20 +293,21 @@ func TestRootYesStopsWaitingForRunWhenContextCanceled(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + var canceledAt time.Time prevAuto := runWizardAuto runWizardAuto = func(got context.Context, p *paths.Paths, state *repoState, _ []types.StepName, _ waitForRunFunc) (wizard.Result, error) { + canceledAt = time.Now() cancel() return wizard.Result{Success: true, Pushed: true, TargetBranch: "feat/missing"}, nil } defer func() { runWizardAuto = prevAuto }() - start := time.Now() _, err = executeCmdWithContext(ctx, "-y") if !errors.Is(err, context.Canceled) { t.Fatalf("executeCmdWithContext(-y) error = %v, want %v", err, context.Canceled) } - if elapsed := time.Since(start); elapsed >= time.Second { + if elapsed := time.Since(canceledAt); elapsed >= time.Second { t.Fatalf("executeCmdWithContext(-y) took %v after cancellation, want under %v", elapsed, time.Second) } } diff --git a/internal/convergence/classes.go b/internal/convergence/classes.go index 5e8c2d8..2e7d6e7 100644 --- a/internal/convergence/classes.go +++ b/internal/convergence/classes.go @@ -10,13 +10,13 @@ import ( // Finding-class identity across rounds. // -// A finding's id and file are useless as identity: the observed ladder failure -// reported one defect class ("env-file parsing semantics disagree with docker -// compose") three times under three different ids in three different files, so -// identity has to come from content. Each finding is reduced to a normalized -// token set over its category and description (lowercased, lightly stemmed, -// stopwords and numbers dropped), and findings whose token sets substantially -// overlap are grouped into one class. +// Pipeline lineage identifies an exact carried finding, but it is insufficient +// for convergence-class identity: the observed ladder failure reported one +// defect class ("env-file parsing semantics disagree with docker compose") as +// three distinct lineages in three different files. Class identity therefore +// comes from content. Each finding is reduced to a normalized token set over its +// category and description (lowercased, lightly stemmed, stopwords and numbers +// dropped), and findings whose token sets substantially overlap are grouped. // // Similarity is the overlap coefficient (|A∩B| / min(|A|,|B|)) rather than // Jaccard: reworded findings of the same class share a stable core vocabulary diff --git a/internal/db/db_test.go b/internal/db/db_test.go index 38097a1..680bcf4 100644 --- a/internal/db/db_test.go +++ b/internal/db/db_test.go @@ -76,7 +76,7 @@ func TestOpenCreatesSchema(t *testing.T) { if !hasColumn(t, d, "repos", "fork_url") { t.Fatal("repos.fork_url column missing from fresh schema") } - for _, column := range []string{"submitted_head_sha", "no_mistakes_version", "no_mistakes_build_sha", "review_approved_head_sha", "last_pushed_sha", "push_target_fingerprint", "push_ref", "last_pushed_at", "push_generation", "push_active", "terminal_head_verified_at", "pr_state", "pr_state_observed_at", "ci_ready_at", "ci_ready_no_ci", "custody_returned_at"} { + for _, column := range []string{"submitted_head_sha", "no_mistakes_version", "no_mistakes_build_sha", "review_approved_head_sha", "last_pushed_sha", "push_target_fingerprint", "push_ref", "last_pushed_at", "push_generation", "push_active", "terminal_head_verified_at", "pr_state", "pr_state_observed_at", "ci_ready_at", "ci_ready_no_ci", "ci_attestation_state", "custody_returned_at"} { if !hasColumn(t, d, "runs", column) { t.Fatalf("runs.%s column missing from fresh schema", column) } @@ -84,7 +84,10 @@ func TestOpenCreatesSchema(t *testing.T) { if !hasColumn(t, d, "step_rounds", "reviewed_head_sha") { t.Fatal("step_rounds.reviewed_head_sha column missing from fresh schema") } - for _, column := range []string{"last_activity_at", "last_activity", "agent_pid", "ci_fix_attempts"} { + if !hasColumn(t, d, "uncertified_pipeline_ranges", "selection_applied") { + t.Fatal("uncertified_pipeline_ranges.selection_applied column missing from fresh schema") + } + for _, column := range []string{"last_activity_at", "last_activity", "agent_pid", "ci_fix_attempts", "certified_head_sha"} { if !hasColumn(t, d, "step_results", column) { t.Fatalf("step_results.%s column missing from fresh schema", column) } @@ -288,7 +291,7 @@ func TestOpenMigratesStepActivityColumns(t *testing.T) { } t.Cleanup(func() { d.Close() }) - for _, column := range []string{"last_activity_at", "last_activity", "agent_pid", "ci_fix_attempts"} { + for _, column := range []string{"last_activity_at", "last_activity", "agent_pid", "ci_fix_attempts", "certified_head_sha"} { if !hasColumn(t, d, "step_results", column) { t.Fatalf("expected migrated column %q", column) } diff --git a/internal/db/round.go b/internal/db/round.go index 7dffb37..01c7d18 100644 --- a/internal/db/round.go +++ b/internal/db/round.go @@ -1,6 +1,9 @@ package db -import "fmt" +import ( + "database/sql" + "fmt" +) const ( RoundSelectionSourceUser = "user" @@ -9,11 +12,13 @@ const ( // StepRound represents one execution round within a pipeline step. type StepRound struct { - ID string - StepResultID string - Round int - Trigger string // "initial", "auto_fix"; legacy "user_fix" is treated as "auto_fix" - FindingsJSON *string // nullable - findings produced by this round + ID string + StepResultID string + Round int + Trigger string // "initial", "auto_fix"; legacy "user_fix" is treated as "auto_fix" + // FindingsJSON is the nullable finding set shown at this round's gate. + // Review rounds persist the effective set, including unresolved carry. + FindingsJSON *string ReviewedHeadSHA *string // non-authoritative commit candidate captured by a review round StartingHeadSHA *string TrustedConfigSHA *string @@ -57,6 +62,21 @@ type StepRoundStats struct { PendingFixSource string } +type ReviewFixSelection struct { + RoundID string + StepResultID string + RepoID string + Branch string + FromSHA string + HeadSHA string + SourceRunID string + RoundFindingsJSON string + StepFindingsJSON string + SelectedFindingIDs *string + SelectionSource string + UserFindingsJSON *string +} + // IsFixRound reports whether this round was a fix attempt. Legacy "user_fix" // rounds count: they were fix rounds dispatched by an explicit user selection. func (r *StepRound) IsFixRound() bool { @@ -119,6 +139,21 @@ func (d *DB) StepRoundStats(stepResultID string) (StepRoundStats, error) { return stats, nil } +func (d *DB) GetLatestStepRoundSelection(stepResultID string) (*string, error) { + row := d.sql.QueryRow(`SELECT selected_finding_ids FROM step_rounds WHERE step_result_id = ? ORDER BY round DESC LIMIT 1`, stepResultID) + var selected sql.NullString + if err := row.Scan(&selected); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, fmt.Errorf("get latest step round selection: %w", err) + } + if !selected.Valid || selected.String == "" { + return nil, nil + } + return &selected.String, nil +} + // InsertStepRound creates a new round record for a step result. fixSummary may // be nil for non-fix rounds or when the agent produced no summary. func (d *DB) InsertStepRound(stepResultID string, round int, trigger string, findingsJSON *string, fixSummary *string, durationMS int64) (*StepRound, error) { @@ -133,6 +168,41 @@ func (d *DB) InsertReviewStepRound(stepResultID string, round int, trigger strin } func (d *DB) InsertReviewStepRoundWithProvenance(stepResultID string, round int, trigger string, findingsJSON *string, fixSummary *string, reviewedHeadSHA, startingHeadSHA, trustedConfigSHA string, globalConfigYAML, repoConfigYAML []byte, durationMS int64) (*StepRound, error) { + return d.insertReviewStepRoundWithProvenance(d.sql, stepResultID, round, trigger, findingsJSON, fixSummary, reviewedHeadSHA, startingHeadSHA, trustedConfigSHA, globalConfigYAML, repoConfigYAML, durationMS) +} + +func (d *DB) InsertEffectiveReviewStepRoundWithProvenance(stepResultID string, round int, trigger string, findingsJSON *string, fixSummary *string, reviewedHeadSHA, startingHeadSHA, trustedConfigSHA string, globalConfigYAML, repoConfigYAML []byte, durationMS int64) (*StepRound, error) { + tx, err := d.sql.Begin() + if err != nil { + return nil, fmt.Errorf("begin effective review round: %w", err) + } + defer tx.Rollback() + result, err := tx.Exec(`UPDATE step_results SET findings_json = ? WHERE id = ?`, findingsJSON, stepResultID) + if err != nil { + return nil, fmt.Errorf("set effective review findings: %w", err) + } + rows, err := result.RowsAffected() + if err != nil { + return nil, fmt.Errorf("set effective review findings result: %w", err) + } + if rows != 1 { + return nil, fmt.Errorf("set effective review findings: updated %d rows", rows) + } + roundRecord, err := d.insertReviewStepRoundWithProvenance(tx, stepResultID, round, trigger, findingsJSON, fixSummary, reviewedHeadSHA, startingHeadSHA, trustedConfigSHA, globalConfigYAML, repoConfigYAML, durationMS) + if err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit effective review round: %w", err) + } + return roundRecord, nil +} + +type stepRoundExecer interface { + Exec(query string, args ...any) (sql.Result, error) +} + +func (d *DB) insertReviewStepRoundWithProvenance(execer stepRoundExecer, stepResultID string, round int, trigger string, findingsJSON *string, fixSummary *string, reviewedHeadSHA, startingHeadSHA, trustedConfigSHA string, globalConfigYAML, repoConfigYAML []byte, durationMS int64) (*StepRound, error) { var reviewed, starting, trusted *string if reviewedHeadSHA != "" { reviewed = &reviewedHeadSHA @@ -143,10 +213,14 @@ func (d *DB) InsertReviewStepRoundWithProvenance(stepResultID string, round int, if trustedConfigSHA != "" { trusted = &trustedConfigSHA } - return d.insertStepRound(stepResultID, round, trigger, findingsJSON, fixSummary, reviewed, starting, trusted, globalConfigYAML, repoConfigYAML, durationMS) + return d.insertStepRoundWith(execer, stepResultID, round, trigger, findingsJSON, fixSummary, reviewed, starting, trusted, globalConfigYAML, repoConfigYAML, durationMS) } func (d *DB) insertStepRound(stepResultID string, round int, trigger string, findingsJSON *string, fixSummary, reviewedHeadSHA, startingHeadSHA, trustedConfigSHA *string, globalConfigYAML, repoConfigYAML []byte, durationMS int64) (*StepRound, error) { + return d.insertStepRoundWith(d.sql, stepResultID, round, trigger, findingsJSON, fixSummary, reviewedHeadSHA, startingHeadSHA, trustedConfigSHA, globalConfigYAML, repoConfigYAML, durationMS) +} + +func (d *DB) insertStepRoundWith(execer stepRoundExecer, stepResultID string, round int, trigger string, findingsJSON *string, fixSummary, reviewedHeadSHA, startingHeadSHA, trustedConfigSHA *string, globalConfigYAML, repoConfigYAML []byte, durationMS int64) (*StepRound, error) { r := &StepRound{ ID: newID(), StepResultID: stepResultID, @@ -162,7 +236,7 @@ func (d *DB) insertStepRound(stepResultID string, round int, trigger string, fin DurationMS: durationMS, CreatedAt: now(), } - _, err := d.sql.Exec( + _, err := execer.Exec( `INSERT INTO step_rounds (id, step_result_id, round, trigger_type, findings_json, reviewed_head_sha, starting_head_sha, trusted_config_sha, global_config_yaml, repo_config_yaml, user_findings_json, selected_finding_ids, selection_source, fix_summary, duration_ms, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, r.ID, r.StepResultID, r.Round, r.Trigger, r.FindingsJSON, r.ReviewedHeadSHA, r.StartingHeadSHA, r.TrustedConfigSHA, r.GlobalConfigYAML, r.RepoConfigYAML, r.UserFindingsJSON, r.SelectedFindingIDs, r.SelectionSource, r.FixSummary, r.DurationMS, r.CreatedAt, ) @@ -181,12 +255,75 @@ func (d *DB) SetStepRoundSelection(id string, selectedFindingIDs *string, source if selectedFindingIDs != nil && *selectedFindingIDs != "" && source != "" { selectionSource = &source } - if _, err := d.sql.Exec( + result, err := d.sql.Exec( `UPDATE step_rounds SET selected_finding_ids = ?, selection_source = ? WHERE id = ?`, selectedFindingIDs, selectionSource, id, - ); err != nil { + ) + if err != nil { return fmt.Errorf("set step round selection: %w", err) } + return requireStepRoundUpdated(result, id) +} + +func (d *DB) PersistReviewFixSelection(selection ReviewFixSelection) error { + if selection.RoundID == "" || selection.StepResultID == "" || selection.RepoID == "" || selection.Branch == "" || selection.FromSHA == "" || selection.HeadSHA == "" || selection.SourceRunID == "" || selection.RoundFindingsJSON == "" || selection.StepFindingsJSON == "" { + return fmt.Errorf("persist review fix selection: incomplete durable selection") + } + var selectionSource *string + if selection.SelectedFindingIDs != nil && *selection.SelectedFindingIDs != "" && selection.SelectionSource != "" { + selectionSource = &selection.SelectionSource + } + tx, err := d.sql.Begin() + if err != nil { + return fmt.Errorf("begin review fix selection: %w", err) + } + defer tx.Rollback() + result, err := tx.Exec( + `UPDATE step_results SET findings_json = ? + WHERE id = ? AND run_id = ? AND EXISTS ( + SELECT 1 FROM step_rounds WHERE id = ? AND step_result_id = step_results.id + )`, + selection.StepFindingsJSON, selection.StepResultID, selection.SourceRunID, selection.RoundID, + ) + if err != nil { + return fmt.Errorf("set durable review gate truth: %w", err) + } + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("read durable review gate truth result: %w", err) + } + if rows != 1 { + return fmt.Errorf("step result %s not found", selection.StepResultID) + } + result, err = tx.Exec( + `UPDATE step_rounds + SET findings_json = ?, selected_finding_ids = ?, selection_source = ?, user_findings_json = ? + WHERE id = ? AND step_result_id = ?`, + selection.RoundFindingsJSON, selection.SelectedFindingIDs, selectionSource, selection.UserFindingsJSON, selection.RoundID, selection.StepResultID, + ) + if err != nil { + return fmt.Errorf("set durable review round selection: %w", err) + } + if err := requireStepRoundUpdated(result, selection.RoundID); err != nil { + return err + } + _, err = tx.Exec( + `INSERT INTO uncertified_pipeline_ranges (repo_id, branch, from_sha, to_sha, source_run_id, selection_applied, created_at) + VALUES (?, ?, ?, ?, ?, 0, ?) + ON CONFLICT(repo_id, branch) DO UPDATE SET + from_sha = excluded.from_sha, + to_sha = excluded.to_sha, + source_run_id = excluded.source_run_id, + selection_applied = 0, + created_at = excluded.created_at`, + selection.RepoID, selection.Branch, selection.FromSHA, selection.HeadSHA, selection.SourceRunID, now(), + ) + if err != nil { + return fmt.Errorf("set durable review recovery marker: %w", err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit review fix selection: %w", err) + } return nil } @@ -195,12 +332,67 @@ func (d *DB) SetStepRoundUserDecision(id string, selectedFindingIDs *string, sou if selectedFindingIDs != nil && *selectedFindingIDs != "" && source != "" { selectionSource = &source } - if _, err := d.sql.Exec( + result, err := d.sql.Exec( `UPDATE step_rounds SET selected_finding_ids = ?, selection_source = ?, user_findings_json = ? WHERE id = ?`, selectedFindingIDs, selectionSource, userFindingsJSON, id, - ); err != nil { + ) + if err != nil { return fmt.Errorf("set step round user decision: %w", err) } + return requireStepRoundUpdated(result, id) +} + +func (d *DB) SetStepRoundUserDecisionAndFindings(id, stepResultID string, selectedFindingIDs *string, source string, userFindingsJSON *string, findingsJSON string) error { + var selectionSource *string + if selectedFindingIDs != nil && *selectedFindingIDs != "" && source != "" { + selectionSource = &source + } + tx, err := d.sql.Begin() + if err != nil { + return fmt.Errorf("begin step round user decision: %w", err) + } + defer tx.Rollback() + result, err := tx.Exec( + `UPDATE step_results SET findings_json = ? + WHERE id = ? AND EXISTS ( + SELECT 1 FROM step_rounds WHERE id = ? AND step_result_id = step_results.id + )`, + findingsJSON, stepResultID, id, + ) + if err != nil { + return fmt.Errorf("set durable review lineages: %w", err) + } + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("read durable review lineages result: %w", err) + } + if rows != 1 { + return fmt.Errorf("step result %s not found", stepResultID) + } + result, err = tx.Exec( + `UPDATE step_rounds SET selected_finding_ids = ?, selection_source = ?, user_findings_json = ? WHERE id = ? AND step_result_id = ?`, + selectedFindingIDs, selectionSource, userFindingsJSON, id, stepResultID, + ) + if err != nil { + return fmt.Errorf("set step round user decision: %w", err) + } + if err := requireStepRoundUpdated(result, id); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit step round user decision: %w", err) + } + return nil +} + +func requireStepRoundUpdated(result sql.Result, id string) error { + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("read step round update result: %w", err) + } + if rows != 1 { + return fmt.Errorf("step round %s not found", id) + } return nil } diff --git a/internal/db/round_test.go b/internal/db/round_test.go index 67ed148..d20584c 100644 --- a/internal/db/round_test.go +++ b/internal/db/round_test.go @@ -47,6 +47,89 @@ func TestReviewRoundPersistsExactReplayProvenance(t *testing.T) { } } +func TestInsertEffectiveReviewStepRoundRollsBackWhenFindingsUpdateFails(t *testing.T) { + d := openTestDB(t) + repo, _ := d.InsertRepo("/tmp/effective-review-update", "https://example.com/repo.git", "main") + run, _ := d.InsertRun(repo.ID, "feature", "reviewed", "base") + step, _ := d.InsertStepResult(run.ID, types.StepReview) + oldFindings := `{"findings":[{"id":"old","severity":"error","description":"old"}]}` + if err := d.SetStepFindings(step.ID, oldFindings); err != nil { + t.Fatal(err) + } + if _, err := d.sql.Exec(`CREATE TRIGGER reject_effective_findings BEFORE UPDATE OF findings_json ON step_results BEGIN SELECT RAISE(FAIL, 'injected findings failure'); END`); err != nil { + t.Fatal(err) + } + if _, err := d.InsertEffectiveReviewStepRoundWithProvenance(step.ID, 1, "initial", nil, nil, "reviewed", "starting", "", nil, nil, 10); err == nil { + t.Fatal("expected findings update failure") + } + assertEffectiveReviewPersistence(t, d, step.ID, oldFindings, 0) +} + +func TestInsertEffectiveReviewStepRoundRollsBackWhenRoundInsertFails(t *testing.T) { + d := openTestDB(t) + repo, _ := d.InsertRepo("/tmp/effective-review-round", "https://example.com/repo.git", "main") + run, _ := d.InsertRun(repo.ID, "feature", "reviewed", "base") + step, _ := d.InsertStepResult(run.ID, types.StepReview) + oldFindings := `{"findings":[{"id":"old","severity":"error","description":"old"}]}` + if err := d.SetStepFindings(step.ID, oldFindings); err != nil { + t.Fatal(err) + } + if _, err := d.sql.Exec(`CREATE TRIGGER reject_effective_round BEFORE INSERT ON step_rounds BEGIN SELECT RAISE(FAIL, 'injected round failure'); END`); err != nil { + t.Fatal(err) + } + if _, err := d.InsertEffectiveReviewStepRoundWithProvenance(step.ID, 1, "initial", nil, nil, "reviewed", "starting", "", nil, nil, 10); err == nil { + t.Fatal("expected round insert failure") + } + assertEffectiveReviewPersistence(t, d, step.ID, oldFindings, 0) +} + +func TestInsertEffectiveReviewStepRoundClearsFindingsWithRound(t *testing.T) { + d := openTestDB(t) + repo, _ := d.InsertRepo("/tmp/effective-review-success", "https://example.com/repo.git", "main") + run, _ := d.InsertRun(repo.ID, "feature", "reviewed", "base") + step, _ := d.InsertStepResult(run.ID, types.StepReview) + oldFindings := `{"findings":[{"id":"old","severity":"error","description":"old"}]}` + if err := d.SetStepFindings(step.ID, oldFindings); err != nil { + t.Fatal(err) + } + round, err := d.InsertEffectiveReviewStepRoundWithProvenance(step.ID, 1, "auto_fix", nil, nil, "reviewed", "starting", "trusted", []byte("global"), []byte("repo"), 10) + if err != nil { + t.Fatal(err) + } + gotStep, err := d.GetStepResult(step.ID) + if err != nil { + t.Fatal(err) + } + if gotStep.FindingsJSON != nil || round.FindingsJSON != nil { + t.Fatalf("effective findings were not cleared: step=%v round=%v", gotStep.FindingsJSON, round.FindingsJSON) + } + rounds, err := d.GetRoundsByStep(step.ID) + if err != nil { + t.Fatal(err) + } + if len(rounds) != 1 || rounds[0].TrustedConfigSHA == nil || *rounds[0].TrustedConfigSHA != "trusted" { + t.Fatalf("persisted rounds = %#v", rounds) + } +} + +func assertEffectiveReviewPersistence(t *testing.T, d *DB, stepID, wantFindings string, wantRounds int) { + t.Helper() + step, err := d.GetStepResult(stepID) + if err != nil { + t.Fatal(err) + } + if step.FindingsJSON == nil || *step.FindingsJSON != wantFindings { + t.Fatalf("step findings = %v, want %q", step.FindingsJSON, wantFindings) + } + rounds, err := d.GetRoundsByStep(stepID) + if err != nil { + t.Fatal(err) + } + if len(rounds) != wantRounds { + t.Fatalf("round count = %d, want %d", len(rounds), wantRounds) + } +} + func TestStepRoundInsertAndGet(t *testing.T) { d := openTestDB(t) repo, _ := d.InsertRepo("/home/user/project", "git@github.com:user/project.git", "main") @@ -314,3 +397,58 @@ func TestSetStepRoundUserDecision(t *testing.T) { t.Errorf("expected nil user_findings_json after clear, got %v", rounds[0].UserFindingsJSON) } } + +func TestStepRoundSelectionUpdatesRequireExistingRound(t *testing.T) { + d := openTestDB(t) + selected := `["review-1"]` + if err := d.SetStepRoundSelection("missing", &selected, RoundSelectionSourceAutoFix); err == nil { + t.Fatal("missing auto-fix round update succeeded") + } + if err := d.SetStepRoundUserDecision("missing", &selected, RoundSelectionSourceUser, nil); err == nil { + t.Fatal("missing user-decision round update succeeded") + } +} + +func TestPersistReviewFixSelectionRollsBackWhenRecoveryMarkerFails(t *testing.T) { + d := openTestDB(t) + repo, _ := d.InsertRepo("/tmp/review-selection", "https://example.com/repo.git", "main") + run, _ := d.InsertRun(repo.ID, "feature", "head", "base") + step, _ := d.InsertStepResult(run.ID, types.StepReview) + initial := `{"findings":[{"id":"review-a","severity":"warning","description":"initial"}]}` + round, err := d.InsertStepRound(step.ID, 1, "initial", &initial, nil, 10) + if err != nil { + t.Fatal(err) + } + if err := d.SetStepFindings(step.ID, initial); err != nil { + t.Fatal(err) + } + if _, err := d.sql.Exec(`CREATE TRIGGER refuse_review_recovery BEFORE INSERT ON uncertified_pipeline_ranges BEGIN SELECT RAISE(ABORT, 'refuse recovery'); END`); err != nil { + t.Fatal(err) + } + selected := `["review-a"]` + updated := `{"findings":[{"id":"review-a","severity":"error","description":"updated"}]}` + err = d.PersistReviewFixSelection(ReviewFixSelection{ + RoundID: round.ID, + StepResultID: step.ID, + RepoID: repo.ID, + Branch: run.Branch, + FromSHA: run.HeadSHA, + HeadSHA: run.HeadSHA, + SourceRunID: run.ID, + RoundFindingsJSON: updated, + StepFindingsJSON: updated, + SelectedFindingIDs: &selected, + SelectionSource: RoundSelectionSourceAutoFix, + }) + if err == nil { + t.Fatal("review selection persisted without its recovery marker") + } + gotStep, err := d.GetStepResult(step.ID) + if err != nil || gotStep.FindingsJSON == nil || *gotStep.FindingsJSON != initial { + t.Fatalf("step gate truth changed after rollback: step=%#v err=%v", gotStep, err) + } + rounds, err := d.GetRoundsByStep(step.ID) + if err != nil || len(rounds) != 1 || rounds[0].FindingsJSON == nil || *rounds[0].FindingsJSON != initial || rounds[0].SelectedFindingIDs != nil { + t.Fatalf("round selection changed after rollback: rounds=%#v err=%v", rounds, err) + } +} diff --git a/internal/db/run.go b/internal/db/run.go index 2ad2d1c..831a148 100644 --- a/internal/db/run.go +++ b/internal/db/run.go @@ -677,8 +677,8 @@ func recoveryExclusionClause(preserved map[string]struct{}) (string, []any) { // GetRunCIRerunState returns the CI step's persisted rerun budget for a run, or // the empty string when the run has never spent one. The payload is opaque -// here: the CI step owns its shape, and the database only guarantees that what -// was written survives a restart. +// here: pipeline CI state owns its shape, and the database only guarantees +// that what was written survives a restart. func (d *DB) GetRunCIRerunState(id string) (string, error) { var state sql.NullString err := d.sql.QueryRow(`SELECT ci_rerun_state FROM runs WHERE id = ?`, id).Scan(&state) @@ -691,10 +691,9 @@ func (d *DB) GetRunCIRerunState(id string) (string, error) { return state.String, nil } -// SetRunCIRerunState persists the CI step's rerun budget. The CI step calls -// this before asking the provider to re-run a check, so a crash between the -// reservation and the request costs the budget instead of handing the recovered -// run a rerun the limit already accounted for. +// SetRunCIRerunState persists CI monitoring state before the corresponding +// provider mutation, so recovery cannot lose a consumed rerun or an expected +// attestation attempt. func (d *DB) SetRunCIRerunState(id, state string) error { _, err := d.sql.Exec(`UPDATE runs SET ci_rerun_state = ?, updated_at = ? WHERE id = ?`, state, now(), id) if err != nil { @@ -702,3 +701,23 @@ func (d *DB) SetRunCIRerunState(id, state string) error { } return nil } + +func (d *DB) GetRunCIAttestationState(id string) (string, error) { + var state sql.NullString + err := d.sql.QueryRow(`SELECT ci_attestation_state FROM runs WHERE id = ?`, id).Scan(&state) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("get run ci attestation state: %w", err) + } + return state.String, nil +} + +func (d *DB) SetRunCIAttestationState(id, state string) error { + _, err := d.sql.Exec(`UPDATE runs SET ci_attestation_state = ?, updated_at = ? WHERE id = ?`, state, now(), id) + if err != nil { + return fmt.Errorf("set run ci attestation state: %w", err) + } + return nil +} diff --git a/internal/db/schema.go b/internal/db/schema.go index 493288b..b897533 100644 --- a/internal/db/schema.go +++ b/internal/db/schema.go @@ -59,7 +59,8 @@ CREATE TABLE IF NOT EXISTS step_results ( agent_pid INTEGER, auto_fix_limit INTEGER, ci_fix_attempts INTEGER NOT NULL DEFAULT 0, - convergence_json TEXT + convergence_json TEXT, + certified_head_sha TEXT ); CREATE TABLE IF NOT EXISTS step_rounds ( @@ -142,16 +143,17 @@ CREATE TABLE IF NOT EXISTS intent_cache ( created_at INTEGER NOT NULL ); --- Per-branch range of pipeline-authored commits whose re-review did not --- complete. The next run's initial review reads this so it is not cold on --- uncertified fixer commits. PRIMARY KEY per branch: the latest uncertified --- HEAD replaces an older range. +-- Per-branch boundary for durable review truth whose verification did not +-- complete. selection_applied records whether a selected fix reached the +-- branch. PRIMARY KEY per branch: the latest uncertified HEAD replaces an +-- older boundary. CREATE TABLE IF NOT EXISTS uncertified_pipeline_ranges ( repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, branch TEXT NOT NULL, from_sha TEXT NOT NULL, to_sha TEXT NOT NULL, source_run_id TEXT NOT NULL, + selection_applied INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, PRIMARY KEY (repo_id, branch) ); @@ -185,6 +187,8 @@ var migrationStatements = []string{ // written before the provider call, so a crash mid-request spends the // budget rather than silently granting a free retry. `ALTER TABLE runs ADD COLUMN ci_rerun_state TEXT`, + `ALTER TABLE runs ADD COLUMN ci_attestation_state TEXT`, + `ALTER TABLE uncertified_pipeline_ranges ADD COLUMN selection_applied INTEGER NOT NULL DEFAULT 0`, // Branch synchronization provenance is intentionally nullable. Historical // rows stay unbound because mutable head_sha cannot prove a successful push. `ALTER TABLE runs ADD COLUMN submitted_head_sha TEXT`, @@ -219,6 +223,7 @@ var migrationStatements = []string{ // The review step's convergence report is nullable: legacy rows and // non-review steps read back as "no report", never a fabricated one. `ALTER TABLE step_results ADD COLUMN convergence_json TEXT`, + `ALTER TABLE step_results ADD COLUMN certified_head_sha TEXT`, // Session-fidelity telemetry columns (all nullable so pre-existing rows read // back as unknown, never a fabricated zero). `ALTER TABLE agent_invocations ADD COLUMN model_provider TEXT`, diff --git a/internal/db/stats.go b/internal/db/stats.go index fee9987..634d235 100644 --- a/internal/db/stats.go +++ b/internal/db/stats.go @@ -141,18 +141,30 @@ func stepFindingStats(step *StepResult, rounds []*StepRound) StepStats { stats.ReportedFindings = count return stats } + if step.StepName != types.StepReview { + return legacyStepFindingStats(step, rounds) + } - reported := make(map[types.Finding]bool) + reportedLineages := make(map[string]bool) + var reportedLegacy []types.Finding + var activeLegacy []types.Finding var current []types.Finding for _, round := range rounds { items := findingItems(round.FindingsJSON) - for _, item := range items { - reported[findingStatsKey(item)] = true + current = appendPendingUserFindings(items, round.UserFindingsJSON, true) + legacy := make([]types.Finding, 0, len(current)) + for _, item := range current { + if key, ok := findingStatsLineageKey(item, true); ok { + reportedLineages[key] = true + continue + } + legacy = append(legacy, item) } - current = items + reportedLegacy = mergeLegacyFindingOccurrences(reportedLegacy, activeLegacy, legacy) + activeLegacy = legacy } - stats.ReportedFindings = len(reported) + stats.ReportedFindings = len(reportedLineages) + len(reportedLegacy) currentCount := len(current) stats.FixedFindings = stats.ReportedFindings - currentCount if stats.FixedFindings < 0 { @@ -164,6 +176,151 @@ func stepFindingStats(step *StepResult, rounds []*StepRound) StepStats { return stats } +type legacyFindingStatsIdentity struct { + Severity string + File string + Line int + Description string + ReviewScope string + Category string +} + +func legacyStepFindingStats(step *StepResult, rounds []*StepRound) StepStats { + reported := make(map[legacyFindingStatsIdentity]bool) + var current []types.Finding + for _, round := range rounds { + current = findingItems(round.FindingsJSON) + for _, item := range current { + reported[legacyFindingStatsKey(item)] = true + } + } + stats := StepStats{StepName: step.StepName, ReportedFindings: len(reported)} + stats.FixedFindings = stats.ReportedFindings - len(current) + if stats.FixedFindings < 0 { + stats.FixedFindings = 0 + } + if stats.FixedFindings > stats.ReportedFindings { + stats.FixedFindings = stats.ReportedFindings + } + return stats +} + +func legacyFindingStatsKey(item types.Finding) legacyFindingStatsIdentity { + return legacyFindingStatsIdentity{ + Severity: item.Severity, + File: item.File, + Line: item.Line, + Description: item.Description, + ReviewScope: item.ReviewScope, + Category: item.Category, + } +} + +func findingStatsLineageKey(item types.Finding, lineageStats bool) (string, bool) { + if !lineageStats || !item.HasLineage() { + return "", false + } + return findingLineageStatsKey(item), true +} + +func findingLineageStatsKey(item types.Finding) string { + return "lineage\x00" + item.ID + "\x00" + item.ContinuityToken +} + +func appendPendingUserFindings(current []types.Finding, raw *string, lineageStats bool) []types.Finding { + if !lineageStats { + return current + } + seen := make(map[string]bool, len(current)) + for _, item := range current { + if item.HasLineage() { + seen[findingLineageStatsKey(item)] = true + } + } + for _, item := range findingItems(raw) { + if item.Source != types.FindingSourceUser || !item.HasLineage() { + continue + } + key := findingLineageStatsKey(item) + if seen[key] { + continue + } + seen[key] = true + current = append(current, item) + } + return current +} + +func mergeLegacyFindingOccurrences(reported, active, current []types.Finding) []types.Finding { + activeMatched := make([]bool, len(active)) + currentMatched := make([]bool, len(current)) + activeOccurrences := make(map[string][]int, len(active)) + currentOccurrences := make(map[string][]int, len(current)) + for i, item := range active { + if item.HasOccurrence() { + activeOccurrences[item.OccurrenceToken] = append(activeOccurrences[item.OccurrenceToken], i) + } + } + for i, item := range current { + if item.HasOccurrence() { + currentOccurrences[item.OccurrenceToken] = append(currentOccurrences[item.OccurrenceToken], i) + } + } + for token, currentIndexes := range currentOccurrences { + activeIndexes := activeOccurrences[token] + if len(currentIndexes) == 1 && len(activeIndexes) == 1 { + currentMatched[currentIndexes[0]] = true + activeMatched[activeIndexes[0]] = true + } + } + + activeExact := make(map[types.FindingIdentity][]int, len(active)) + currentExact := make(map[types.FindingIdentity][]int, len(current)) + for i, item := range active { + if !activeMatched[i] { + activeExact[item.Identity()] = append(activeExact[item.Identity()], i) + } + } + for i, item := range current { + if !currentMatched[i] { + currentExact[item.Identity()] = append(currentExact[item.Identity()], i) + } + } + for identity, currentIndexes := range currentExact { + activeIndexes := activeExact[identity] + if len(currentIndexes) == 1 && len(activeIndexes) == 1 { + currentMatched[currentIndexes[0]] = true + activeMatched[activeIndexes[0]] = true + } + } + + activeFingerprint := make(map[types.FindingIdentity][]int) + currentFingerprint := make(map[types.FindingIdentity][]int) + for i, item := range active { + if !activeMatched[i] { + activeFingerprint[item.Fingerprint()] = append(activeFingerprint[item.Fingerprint()], i) + } + } + for i, item := range current { + if !currentMatched[i] { + currentFingerprint[item.Fingerprint()] = append(currentFingerprint[item.Fingerprint()], i) + } + } + for fingerprint, currentIndexes := range currentFingerprint { + activeIndexes := activeFingerprint[fingerprint] + if len(currentIndexes) == 1 && len(activeIndexes) == 1 { + currentMatched[currentIndexes[0]] = true + activeMatched[activeIndexes[0]] = true + } + } + for i, item := range current { + if !currentMatched[i] { + reported = append(reported, item) + } + } + return reported +} + // FixedFindingsByStep returns how many findings were resolved for a single step. func (d *DB) FixedFindingsByStep(step *StepResult) (int, error) { stats, err := d.StepFindingStats(step) @@ -204,14 +361,6 @@ func findingItems(raw *string) []types.Finding { return findings.Items } -func findingStatsKey(item types.Finding) types.Finding { - item.ID = "" - item.Action = "" - item.Source = "" - item.UserInstructions = "" - return item -} - func sortStepStats(stats []StepStats) { slices.SortFunc(stats, func(a, b StepStats) int { if a.FixedFindings != b.FixedFindings { diff --git a/internal/db/stats_test.go b/internal/db/stats_test.go index c06be17..f86fd7f 100644 --- a/internal/db/stats_test.go +++ b/internal/db/stats_test.go @@ -97,6 +97,42 @@ func TestGetStatsFallsBackToStepFindingsWhenRoundsAreMissing(t *testing.T) { } } +func TestStepFindingStatsCountsUniqueUserLineageUntilRereview(t *testing.T) { + d := openTestDB(t) + repo, _ := d.InsertRepo("/repo/user-findings", "git@example.com:user-findings.git", "main") + run, _ := d.InsertRun(repo.ID, "user-findings", "head", "base") + step, _ := d.InsertStepResult(run.ID, types.StepReview) + initial := `{"findings":[{"id":"review-a","id_generated":true,"continuity_token":"token-a","severity":"warning","description":"agent defect","action":"auto-fix"}]}` + round, err := d.InsertStepRound(step.ID, 1, "initial", &initial, nil, 100) + if err != nil { + t.Fatal(err) + } + userSelection := `{"findings":[{"id":"review-a","id_generated":true,"continuity_token":"token-a","severity":"warning","description":"agent defect","action":"auto-fix"},{"id":"user-1","id_generated":true,"continuity_token":"token-user","severity":"error","description":"operator defect","action":"auto-fix","source":"user"}]}` + selected := `["review-a","user-1"]` + if err := d.SetStepRoundUserDecision(round.ID, &selected, RoundSelectionSourceUser, &userSelection); err != nil { + t.Fatal(err) + } + + stats, err := d.StepFindingStats(step) + if err != nil { + t.Fatal(err) + } + if stats.ReportedFindings != 2 || stats.FixedFindings != 0 { + t.Fatalf("pre-rereview stats = reported %d fixed %d, want 2/0", stats.ReportedFindings, stats.FixedFindings) + } + + if _, err := d.InsertStepRound(step.ID, 2, "user_fix", nil, nil, 100); err != nil { + t.Fatal(err) + } + stats, err = d.StepFindingStats(step) + if err != nil { + t.Fatal(err) + } + if stats.ReportedFindings != 2 || stats.FixedFindings != 2 { + t.Fatalf("post-rereview stats = reported %d fixed %d, want 2/2", stats.ReportedFindings, stats.FixedFindings) + } +} + func TestFixedFindingsByStepCountsResolvedRoundFindings(t *testing.T) { d := openTestDB(t) repo, _ := d.InsertRepo("/repo/fixes", "git@example.com:fixes.git", "main") @@ -167,6 +203,218 @@ func TestStepFindingStatsAddsNewFindingsToTotal(t *testing.T) { } } +func TestStepFindingStatsTreatsReclassificationAsSameFinding(t *testing.T) { + d := openTestDB(t) + repo, _ := d.InsertRepo("/repo/reclassified", "git@example.com:reclassified.git", "main") + run, _ := d.InsertRun(repo.ID, "reclassified", "head", "base") + step, _ := d.InsertStepResult(run.ID, types.StepReview) + initial := `{"findings":[{"id":"r1","severity":"warning","file":"loader.go","line":12,"description":"unsafe loader","action":"ask-user","review_scope":"source","category":"documentation"}],"summary":"one"}` + final := `{"findings":[{"id":"r1","severity":"error","file":"loader.go","line":12,"description":"unsafe loader","action":"ask-user","review_scope":"external-delivery","category":"lint"}],"summary":"one"}` + if _, err := d.InsertStepRound(step.ID, 1, "initial", &initial, nil, 100); err != nil { + t.Fatal(err) + } + if _, err := d.InsertStepRound(step.ID, 2, "auto_fix", &final, nil, 100); err != nil { + t.Fatal(err) + } + + stats, err := d.StepFindingStats(step) + if err != nil { + t.Fatal(err) + } + if stats.ReportedFindings != 1 || stats.FixedFindings != 0 { + t.Fatalf("stats = reported %d fixed %d", stats.ReportedFindings, stats.FixedFindings) + } +} + +func TestStepFindingStatsTreatsUniqueLineShiftAsSameFinding(t *testing.T) { + d := openTestDB(t) + repo, _ := d.InsertRepo("/repo/shifted", "git@example.com:shifted.git", "main") + run, _ := d.InsertRun(repo.ID, "shifted", "head", "base") + step, _ := d.InsertStepResult(run.ID, types.StepReview) + initial := `{"findings":[{"id":"r1","severity":"warning","file":"loader.go","line":8,"description":"unsafe loader"}]}` + final := `{"findings":[{"id":"r1","severity":"warning","file":"loader.go","line":9,"description":"unsafe loader"}]}` + if _, err := d.InsertStepRound(step.ID, 1, "initial", &initial, nil, 100); err != nil { + t.Fatal(err) + } + if _, err := d.InsertStepRound(step.ID, 2, "auto_fix", &final, nil, 100); err != nil { + t.Fatal(err) + } + + stats, err := d.StepFindingStats(step) + if err != nil { + t.Fatal(err) + } + if stats.ReportedFindings != 1 || stats.FixedFindings != 0 { + t.Fatalf("stats = reported %d fixed %d", stats.ReportedFindings, stats.FixedFindings) + } +} + +func TestStepFindingStatsDoesNotTrustTokenlessGeneratedID(t *testing.T) { + d := openTestDB(t) + repo, _ := d.InsertRepo("/repo/rephrased", "git@example.com:rephrased.git", "main") + run, _ := d.InsertRun(repo.ID, "rephrased", "head", "base") + step, _ := d.InsertStepResult(run.ID, types.StepReview) + initial := `{"findings":[{"id":"review-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","id_generated":true,"severity":"warning","file":"loader.go","line":8,"description":"unsafe loader"}]}` + final := `{"findings":[{"id":"review-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","id_generated":true,"severity":"error","file":"manager.go","line":88,"description":"credentials are invalidated prematurely"}]}` + if _, err := d.InsertStepRound(step.ID, 1, "initial", &initial, nil, 100); err != nil { + t.Fatal(err) + } + if _, err := d.InsertStepRound(step.ID, 2, "auto_fix", &final, nil, 100); err != nil { + t.Fatal(err) + } + + stats, err := d.StepFindingStats(step) + if err != nil { + t.Fatal(err) + } + if stats.ReportedFindings != 2 || stats.FixedFindings != 1 { + t.Fatalf("stats = reported %d fixed %d", stats.ReportedFindings, stats.FixedFindings) + } +} + +func TestStepFindingStatsDoesNotInventAmbiguousLegacyContinuity(t *testing.T) { + d := openTestDB(t) + repo, _ := d.InsertRepo("/repo/legacy-multiplicity", "git@example.com:legacy-multiplicity.git", "main") + run, _ := d.InsertRun(repo.ID, "legacy-multiplicity", "head", "base") + step, _ := d.InsertStepResult(run.ID, types.StepReview) + findings := `{"findings":[{"id":"legacy-a","severity":"warning","file":"loader.go","line":8,"description":"unsafe loader"},{"id":"legacy-b","severity":"warning","file":"loader.go","line":8,"description":"unsafe loader"}]}` + if _, err := d.InsertStepRound(step.ID, 1, "initial", &findings, nil, 100); err != nil { + t.Fatal(err) + } + if _, err := d.InsertStepRound(step.ID, 2, "auto_fix", &findings, nil, 100); err != nil { + t.Fatal(err) + } + + stats, err := d.StepFindingStats(step) + if err != nil { + t.Fatal(err) + } + if stats.ReportedFindings != 4 || stats.FixedFindings != 2 { + t.Fatalf("stats = reported %d fixed %d", stats.ReportedFindings, stats.FixedFindings) + } +} + +func TestStepFindingStatsClosesLegacyOccurrencesAcrossEmptyRound(t *testing.T) { + d := openTestDB(t) + repo, _ := d.InsertRepo("/repo/legacy-gap", "git@example.com:legacy-gap.git", "main") + run, _ := d.InsertRun(repo.ID, "legacy-gap", "head", "base") + step, _ := d.InsertStepResult(run.ID, types.StepReview) + findings := `{"findings":[{"id":"legacy-a","severity":"warning","file":"loader.go","line":8,"description":"unsafe loader"},{"id":"legacy-b","severity":"warning","file":"loader.go","line":8,"description":"unsafe loader"}]}` + empty := `{"findings":[]}` + if _, err := d.InsertStepRound(step.ID, 1, "initial", &findings, nil, 100); err != nil { + t.Fatal(err) + } + if _, err := d.InsertStepRound(step.ID, 2, "auto_fix", &empty, nil, 100); err != nil { + t.Fatal(err) + } + if _, err := d.InsertStepRound(step.ID, 3, "auto_fix", &findings, nil, 100); err != nil { + t.Fatal(err) + } + + stats, err := d.StepFindingStats(step) + if err != nil { + t.Fatal(err) + } + if stats.ReportedFindings != 4 || stats.FixedFindings != 2 { + t.Fatalf("stats = reported %d fixed %d, want 4/2", stats.ReportedFindings, stats.FixedFindings) + } +} + +func TestStepFindingStatsDoesNotCollapseUncorroboratedExplicitID(t *testing.T) { + d := openTestDB(t) + repo, _ := d.InsertRepo("/repo/id-collision", "git@example.com:id-collision.git", "main") + run, _ := d.InsertRun(repo.ID, "id-collision", "head", "base") + step, _ := d.InsertStepResult(run.ID, types.StepReview) + initial := `{"findings":[{"id":"review-1","severity":"warning","file":"loader.go","line":8,"description":"unsafe loader"}]}` + final := `{"findings":[{"id":"review-1","severity":"error","file":"loader.go","line":8,"description":"cache write can deadlock"}]}` + if _, err := d.InsertStepRound(step.ID, 1, "initial", &initial, nil, 100); err != nil { + t.Fatal(err) + } + if _, err := d.InsertStepRound(step.ID, 2, "auto_fix", &final, nil, 100); err != nil { + t.Fatal(err) + } + + stats, err := d.StepFindingStats(step) + if err != nil { + t.Fatal(err) + } + if stats.ReportedFindings != 2 || stats.FixedFindings != 1 { + t.Fatalf("stats = reported %d fixed %d", stats.ReportedFindings, stats.FixedFindings) + } +} + +func TestStepFindingStatsCountsDistinctGeneratedLineagesWithIdenticalContent(t *testing.T) { + d := openTestDB(t) + repo, _ := d.InsertRepo("/repo/distinct-lineages", "git@example.com:distinct.git", "main") + run, _ := d.InsertRun(repo.ID, "distinct", "head", "base") + step, _ := d.InsertStepResult(run.ID, types.StepReview) + initial := `{"findings":[{"id":"review-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","id_generated":true,"continuity_token":"token-a","severity":"warning","file":"loader.go","line":8,"description":"unsafe loader"},{"id":"review-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","id_generated":true,"continuity_token":"token-b","severity":"warning","file":"loader.go","line":8,"description":"unsafe loader"}]}` + if _, err := d.InsertStepRound(step.ID, 1, "initial", &initial, nil, 100); err != nil { + t.Fatal(err) + } + if _, err := d.InsertStepRound(step.ID, 2, "auto_fix", &initial, nil, 100); err != nil { + t.Fatal(err) + } + + stats, err := d.StepFindingStats(step) + if err != nil { + t.Fatal(err) + } + if stats.ReportedFindings != 2 || stats.FixedFindings != 0 { + t.Fatalf("stats = reported %d fixed %d", stats.ReportedFindings, stats.FixedFindings) + } +} + +func TestStepFindingStatsPreservesLegacyIdentityForNonReviewSteps(t *testing.T) { + for _, stepName := range []types.StepName{types.StepTest, types.StepDocument, types.StepLint} { + t.Run(string(stepName), func(t *testing.T) { + d := openTestDB(t) + repo, _ := d.InsertRepo("/repo/non-review-"+string(stepName), "git@example.com:non-review.git", "main") + run, _ := d.InsertRun(repo.ID, "non-review", "head", "base") + step, _ := d.InsertStepResult(run.ID, stepName) + initial := `{"findings":[{"id":"first-generated-id","id_generated":true,"continuity_token":"token-a","severity":"warning","file":"loader.go","line":8,"description":"unsafe loader"}]}` + final := `{"findings":[{"id":"second-generated-id","id_generated":true,"continuity_token":"token-b","severity":"warning","file":"loader.go","line":8,"description":"unsafe loader"}]}` + if _, err := d.InsertStepRound(step.ID, 1, "initial", &initial, nil, 100); err != nil { + t.Fatal(err) + } + if _, err := d.InsertStepRound(step.ID, 2, "auto_fix", &final, nil, 100); err != nil { + t.Fatal(err) + } + + stats, err := d.StepFindingStats(step) + if err != nil { + t.Fatal(err) + } + if stats.ReportedFindings != 1 || stats.FixedFindings != 0 { + t.Fatalf("stats = reported %d fixed %d", stats.ReportedFindings, stats.FixedFindings) + } + }) + } +} + +func TestStepFindingStatsCountsNonReviewReclassificationAsNewFinding(t *testing.T) { + d := openTestDB(t) + repo, _ := d.InsertRepo("/repo/non-review-reclassification", "git@example.com:non-review.git", "main") + run, _ := d.InsertRun(repo.ID, "non-review", "head", "base") + step, _ := d.InsertStepResult(run.ID, types.StepTest) + initial := `{"findings":[{"id":"first-id","severity":"warning","file":"loader.go","line":8,"description":"unsafe loader","action":"auto-fix","review_scope":"source"}]}` + final := `{"findings":[{"id":"second-id","severity":"error","file":"loader.go","line":8,"description":"unsafe loader","action":"ask-user","review_scope":"external-delivery"}]}` + if _, err := d.InsertStepRound(step.ID, 1, "initial", &initial, nil, 100); err != nil { + t.Fatal(err) + } + if _, err := d.InsertStepRound(step.ID, 2, "auto_fix", &final, nil, 100); err != nil { + t.Fatal(err) + } + + stats, err := d.StepFindingStats(step) + if err != nil { + t.Fatal(err) + } + if stats.ReportedFindings != 2 || stats.FixedFindings != 1 { + t.Fatalf("stats = reported %d fixed %d, want 2/1", stats.ReportedFindings, stats.FixedFindings) + } +} + func assertStepStat(t *testing.T, stats []StepStats, step types.StepName, reported int, fixes int) { t.Helper() for _, got := range stats { diff --git a/internal/db/step.go b/internal/db/step.go index 4c9ad23..54100dc 100644 --- a/internal/db/step.go +++ b/internal/db/step.go @@ -9,23 +9,24 @@ import ( // StepResult represents the result of a pipeline step execution. type StepResult struct { - ID string - RunID string - StepName types.StepName - StepOrder int - Status types.StepStatus - ExitCode *int - DurationMS *int64 - LogPath *string - FindingsJSON *string - Error *string - StartedAt *int64 - CompletedAt *int64 - LastActivityAt *int64 - LastActivity *string - AgentPID *int - AutoFixLimit *int - CIFixAttempts int + ID string + RunID string + StepName types.StepName + StepOrder int + Status types.StepStatus + ExitCode *int + DurationMS *int64 + LogPath *string + FindingsJSON *string + Error *string + StartedAt *int64 + CompletedAt *int64 + LastActivityAt *int64 + LastActivity *string + AgentPID *int + AutoFixLimit *int + CertifiedHeadSHA *string + CIFixAttempts int // ConvergenceJSON is the review step's persisted convergence report // (internal/convergence.Report). The executor overwrites it once per // review round; nil means no report was ever computed (non-review steps, @@ -36,11 +37,17 @@ type StepResult struct { const stepResultColumns = `id, run_id, step_name, step_order, status, exit_code, duration_ms, log_path, findings_json, error, started_at, completed_at, last_activity_at, last_activity, agent_pid, auto_fix_limit, convergence_json` func (d *DB) readableStepResultColumns() string { + columns := stepResultColumns + if d.hasColumn("step_results", "certified_head_sha") { + columns += ", certified_head_sha" + } else { + columns += ", NULL AS certified_head_sha" + } if d.hasColumn("step_results", "ci_fix_attempts") { - return stepResultColumns + ", ci_fix_attempts" + return columns + ", ci_fix_attempts" } // Read-only authorization may inspect the database before migrations run. - return stepResultColumns + ", 0 AS ci_fix_attempts" + return columns + ", 0 AS ci_fix_attempts" } func (d *DB) hasColumn(table, column string) bool { @@ -76,7 +83,7 @@ func (d *DB) GetStepResult(id string) (*StepResult, error) { s := &StepResult{} err := d.sql.QueryRow( `SELECT `+d.readableStepResultColumns()+` FROM step_results WHERE id = ?`, id, - ).Scan(&s.ID, &s.RunID, &s.StepName, &s.StepOrder, &s.Status, &s.ExitCode, &s.DurationMS, &s.LogPath, &s.FindingsJSON, &s.Error, &s.StartedAt, &s.CompletedAt, &s.LastActivityAt, &s.LastActivity, &s.AgentPID, &s.AutoFixLimit, &s.ConvergenceJSON, &s.CIFixAttempts) + ).Scan(&s.ID, &s.RunID, &s.StepName, &s.StepOrder, &s.Status, &s.ExitCode, &s.DurationMS, &s.LogPath, &s.FindingsJSON, &s.Error, &s.StartedAt, &s.CompletedAt, &s.LastActivityAt, &s.LastActivity, &s.AgentPID, &s.AutoFixLimit, &s.ConvergenceJSON, &s.CertifiedHeadSHA, &s.CIFixAttempts) if err == sql.ErrNoRows { return nil, nil } @@ -98,7 +105,7 @@ func (d *DB) GetStepsByRun(runID string) ([]*StepResult, error) { var steps []*StepResult for rows.Next() { s := &StepResult{} - if err := rows.Scan(&s.ID, &s.RunID, &s.StepName, &s.StepOrder, &s.Status, &s.ExitCode, &s.DurationMS, &s.LogPath, &s.FindingsJSON, &s.Error, &s.StartedAt, &s.CompletedAt, &s.LastActivityAt, &s.LastActivity, &s.AgentPID, &s.AutoFixLimit, &s.ConvergenceJSON, &s.CIFixAttempts); err != nil { + if err := rows.Scan(&s.ID, &s.RunID, &s.StepName, &s.StepOrder, &s.Status, &s.ExitCode, &s.DurationMS, &s.LogPath, &s.FindingsJSON, &s.Error, &s.StartedAt, &s.CompletedAt, &s.LastActivityAt, &s.LastActivity, &s.AgentPID, &s.AutoFixLimit, &s.ConvergenceJSON, &s.CertifiedHeadSHA, &s.CIFixAttempts); err != nil { return nil, fmt.Errorf("scan step result: %w", err) } steps = append(steps, s) @@ -114,7 +121,8 @@ func (d *DB) ResetStepsFrom(runID string, stepOrder int) error { SET status = ?, exit_code = NULL, duration_ms = NULL, log_path = NULL, findings_json = NULL, error = NULL, started_at = NULL, completed_at = NULL, last_activity_at = NULL, last_activity = NULL, - agent_pid = NULL, auto_fix_limit = NULL, convergence_json = NULL + agent_pid = NULL, auto_fix_limit = NULL, convergence_json = NULL, + certified_head_sha = NULL WHERE run_id = ? AND step_order >= ? AND status != ?`, types.StepStatusPending, runID, stepOrder, types.StepStatusSkipped) if err != nil { return fmt.Errorf("reset steps for revalidation: %w", err) @@ -228,9 +236,17 @@ func (d *DB) CompleteStep(id string, exitCode int, durationMS int64, logPath str // CompleteStepWithStatus marks a step as finished with timing and result info. func (d *DB) CompleteStepWithStatus(id string, status types.StepStatus, exitCode int, durationMS int64, logPath string) error { + return d.CompleteStepWithStatusAtHead(id, status, "", exitCode, durationMS, logPath) +} + +func (d *DB) CompleteStepWithStatusAtHead(id string, status types.StepStatus, certifiedHeadSHA string, exitCode int, durationMS int64, logPath string) error { + var certifiedHead *string + if certifiedHeadSHA != "" { + certifiedHead = &certifiedHeadSHA + } _, err := d.sql.Exec( - `UPDATE step_results SET status = ?, exit_code = ?, duration_ms = ?, log_path = ?, completed_at = ?, last_activity_at = ?, last_activity = ?, agent_pid = NULL WHERE id = ?`, - status, exitCode, durationMS, logPath, now(), now(), fmt.Sprintf("status: %s", status), id, + `UPDATE step_results SET status = ?, exit_code = ?, duration_ms = ?, log_path = ?, certified_head_sha = ?, completed_at = ?, last_activity_at = ?, last_activity = ?, agent_pid = NULL WHERE id = ?`, + status, exitCode, durationMS, logPath, certifiedHead, now(), now(), fmt.Sprintf("status: %s", status), id, ) if err != nil { return fmt.Errorf("complete step: %w", err) @@ -238,11 +254,9 @@ func (d *DB) CompleteStepWithStatus(id string, status types.StepStatus, exitCode return nil } -// CompleteReviewStep atomically completes a successful review and replaces -// the run's exact review-approved head. Neither write survives if the other -// fails, so a failed completion cannot create approval authority and a -// completed review cannot lack it. -func (d *DB) CompleteReviewStep(id, runID, approvedHeadSHA string, exitCode int, durationMS int64, logPath string) error { +// CompleteReviewStep atomically completes a successful review, replaces the +// run's exact review-approved head, and retires the certified recovery range. +func (d *DB) CompleteReviewStep(id, runID, approvedHeadSHA string, exitCode int, durationMS int64, logPath string, certifiedRange *UncertifiedPipelineRange) error { tx, err := d.sql.Begin() if err != nil { return fmt.Errorf("begin complete review step: %w", err) @@ -251,8 +265,8 @@ func (d *DB) CompleteReviewStep(id, runID, approvedHeadSHA string, exitCode int, ts := now() result, err := tx.Exec( - `UPDATE step_results SET status = ?, exit_code = ?, duration_ms = ?, log_path = ?, completed_at = ?, last_activity_at = ?, last_activity = ?, agent_pid = NULL WHERE id = ?`, - types.StepStatusCompleted, exitCode, durationMS, logPath, ts, ts, fmt.Sprintf("status: %s", types.StepStatusCompleted), id, + `UPDATE step_results SET status = ?, exit_code = ?, duration_ms = ?, log_path = ?, certified_head_sha = ?, completed_at = ?, last_activity_at = ?, last_activity = ?, agent_pid = NULL WHERE id = ?`, + types.StepStatusCompleted, exitCode, durationMS, logPath, approvedHeadSHA, ts, ts, fmt.Sprintf("status: %s", types.StepStatusCompleted), id, ) if err != nil { return fmt.Errorf("complete review step: %w", err) @@ -267,12 +281,51 @@ func (d *DB) CompleteReviewStep(id, runID, approvedHeadSHA string, exitCode int, if rows, err := result.RowsAffected(); err != nil || rows != 1 { return fmt.Errorf("record review-approved head: run row not found") } + if certifiedRange != nil { + result, err = tx.Exec( + `DELETE FROM uncertified_pipeline_ranges + WHERE repo_id = ? AND branch = ? AND from_sha = ? AND to_sha = ? AND source_run_id = ? + AND repo_id = (SELECT repo_id FROM runs WHERE id = ?) + AND branch = (SELECT branch FROM runs WHERE id = ?)`, + certifiedRange.RepoID, certifiedRange.Branch, certifiedRange.FromSHA, certifiedRange.ToSHA, certifiedRange.SourceRunID, + runID, runID, + ) + if err != nil { + return fmt.Errorf("clear certified uncertified pipeline range: %w", err) + } + if rows, err := result.RowsAffected(); err != nil || rows != 1 { + return fmt.Errorf("clear certified uncertified pipeline range: range changed") + } + } if err := tx.Commit(); err != nil { return fmt.Errorf("commit completed review: %w", err) } return nil } +func (d *DB) ResetStepsFromOrder(runID string, stepOrder int) error { + tx, err := d.sql.Begin() + if err != nil { + return fmt.Errorf("begin reset steps from order %d: %w", stepOrder, err) + } + defer tx.Rollback() + if _, err := tx.Exec( + `UPDATE step_results SET status = ?, exit_code = NULL, duration_ms = NULL, log_path = NULL, findings_json = CASE WHEN step_name = ? THEN findings_json ELSE NULL END, error = NULL, started_at = NULL, completed_at = NULL, last_activity_at = ?, last_activity = ?, agent_pid = NULL, auto_fix_limit = CASE WHEN step_name = ? THEN auto_fix_limit ELSE NULL END, convergence_json = CASE WHEN step_name = ? THEN convergence_json ELSE NULL END, certified_head_sha = NULL WHERE run_id = ? AND step_order >= ? AND status != ?`, + types.StepStatusPending, types.StepReview, now(), "invalidated by head change", types.StepReview, types.StepReview, runID, stepOrder, types.StepStatusSkipped, + ); err != nil { + return fmt.Errorf("reset steps from order %d: %w", stepOrder, err) + } + if stepOrder <= types.StepReview.Order() { + if _, err := tx.Exec(`UPDATE runs SET review_approved_head_sha = NULL, ci_ready_at = NULL, ci_ready_no_ci = 0, updated_at = ? WHERE id = ?`, now(), runID); err != nil { + return fmt.Errorf("invalidate run gate evidence: %w", err) + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit reset steps from order %d: %w", stepOrder, err) + } + return nil +} + // FailStep marks a step as failed with an error message and duration. func (d *DB) FailStep(id string, errMsg string, durationMS int64) error { _, err := d.sql.Exec( diff --git a/internal/db/step_test.go b/internal/db/step_test.go index d597d64..9c8911d 100644 --- a/internal/db/step_test.go +++ b/internal/db/step_test.go @@ -354,6 +354,109 @@ func TestResetStepsFromPreservesSkippedSteps(t *testing.T) { t.Logf("revalidation reset evidence: review status=%s, convergence state=cleared, push status=%s", gotReview.Status, gotPush.Status) } +func TestResetStepsFromOrderPreservesSkippedSteps(t *testing.T) { + d := openTestDB(t) + repo, err := d.InsertRepo("/tmp/head-change", "https://example.com/head-change.git", "main") + if err != nil { + t.Fatal(err) + } + run, err := d.InsertRun(repo.ID, "feature", "new-head", "base") + if err != nil { + t.Fatal(err) + } + review, err := d.InsertStepResult(run.ID, types.StepReview) + if err != nil { + t.Fatal(err) + } + push, err := d.InsertStepResult(run.ID, types.StepPush) + if err != nil { + t.Fatal(err) + } + testStep, err := d.InsertStepResult(run.ID, types.StepTest) + if err != nil { + t.Fatal(err) + } + reviewFindings := `{"findings":[{"id":"review-1","severity":"error","description":"carry review truth","action":"ask-user"}]}` + testFindings := `{"findings":[{"id":"test-1","severity":"error","description":"stale test truth","action":"ask-user"}]}` + if err := d.SetStepFindings(review.ID, reviewFindings); err != nil { + t.Fatal(err) + } + if err := d.SetStepFindings(testStep.ID, testFindings); err != nil { + t.Fatal(err) + } + if err := d.SetStepConvergence(review.ID, `{"round_findings":[1]}`); err != nil { + t.Fatal(err) + } + if err := d.SetStepConvergence(testStep.ID, `{"stale":true}`); err != nil { + t.Fatal(err) + } + if err := d.SetStepAutoFixLimit(testStep.ID, 3); err != nil { + t.Fatal(err) + } + if err := d.CompleteStepWithStatusAtHead(review.ID, types.StepStatusCompleted, "old-head", 0, 1, ""); err != nil { + t.Fatal(err) + } + if err := d.CompleteStepWithStatusAtHead(testStep.ID, types.StepStatusCompleted, "old-head", 0, 1, ""); err != nil { + t.Fatal(err) + } + if err := d.CompleteStepWithStatus(push.ID, types.StepStatusSkipped, 0, 0, ""); err != nil { + t.Fatal(err) + } + + if err := d.ResetStepsFromOrder(run.ID, types.StepReview.Order()); err != nil { + t.Fatal(err) + } + gotReview, err := d.GetStepResult(review.ID) + if err != nil { + t.Fatal(err) + } + if gotReview.Status != types.StepStatusPending || gotReview.CertifiedHeadSHA != nil || gotReview.FindingsJSON == nil || *gotReview.FindingsJSON != reviewFindings || gotReview.ConvergenceJSON == nil { + t.Fatalf("review after head-change reset = %#v", gotReview) + } + gotTest, err := d.GetStepResult(testStep.ID) + if err != nil { + t.Fatal(err) + } + if gotTest.Status != types.StepStatusPending || gotTest.CertifiedHeadSHA != nil || gotTest.FindingsJSON != nil || gotTest.ConvergenceJSON != nil || gotTest.AutoFixLimit != nil { + t.Fatalf("test after head-change reset = %#v", gotTest) + } + gotPush, err := d.GetStepResult(push.ID) + if err != nil { + t.Fatal(err) + } + if gotPush.Status != types.StepStatusSkipped { + t.Fatalf("push status = %s, want durable skip preserved", gotPush.Status) + } +} + +func TestCompleteStepWithStatusPersistsCertifiedHead(t *testing.T) { + d := openTestDB(t) + repo, _ := d.InsertRepo("/home/user/certified", "git@github.com:user/certified.git", "main") + run, _ := d.InsertRun(repo.ID, "feature", "head", "base") + step, _ := d.InsertStepResult(run.ID, types.StepTest) + + if err := d.CompleteStepWithStatusAtHead(step.ID, types.StepStatusCompleted, "certified-head", 0, 10, "test.log"); err != nil { + t.Fatal(err) + } + got, err := d.GetStepResult(step.ID) + if err != nil { + t.Fatal(err) + } + if got.CertifiedHeadSHA == nil || *got.CertifiedHeadSHA != "certified-head" { + t.Fatalf("certified head = %v", got.CertifiedHeadSHA) + } + if err := d.ResetStepsFrom(run.ID, types.StepReview.Order()); err != nil { + t.Fatal(err) + } + got, err = d.GetStepResult(step.ID) + if err != nil { + t.Fatal(err) + } + if got.Status != types.StepStatusPending || got.CertifiedHeadSHA != nil { + t.Fatalf("reset step = %#v", got) + } +} + func TestUpdateStepStatusWithDuration(t *testing.T) { d := openTestDB(t) repo, _ := d.InsertRepo("/home/user/project", "git@github.com:user/project.git", "main") @@ -417,7 +520,7 @@ func TestCompleteReviewStepIsAtomic(t *testing.T) { t.Fatal(err) } - if err := d.CompleteReviewStep(step.ID, "missing-run", "approved", 0, 10, "review.log"); err == nil { + if err := d.CompleteReviewStep(step.ID, "missing-run", "approved", 0, 10, "review.log", nil); err == nil { t.Fatal("expected missing run to roll back review completion") } gotStep, _ := d.GetStepResult(step.ID) @@ -429,7 +532,7 @@ func TestCompleteReviewStepIsAtomic(t *testing.T) { t.Fatalf("failed transaction created review authority: %#v", gotRun.ReviewApprovedHeadSHA) } - if err := d.CompleteReviewStep(step.ID, run.ID, "approved", 0, 10, "review.log"); err != nil { + if err := d.CompleteReviewStep(step.ID, run.ID, "approved", 0, 10, "review.log", nil); err != nil { t.Fatal(err) } gotStep, _ = d.GetStepResult(step.ID) @@ -439,6 +542,36 @@ func TestCompleteReviewStepIsAtomic(t *testing.T) { } } +func TestCompleteReviewStepRollsBackWhenCertifiedRangeCannotClear(t *testing.T) { + d := openTestDB(t) + repo, _ := d.InsertRepo("/tmp/review-range-atomic", "https://example.com/repo.git", "main") + run, _ := d.InsertRun(repo.ID, "feature", "approved", "base") + step, _ := d.InsertStepResult(run.ID, types.StepReview) + if err := d.StartStep(step.ID); err != nil { + t.Fatal(err) + } + if err := d.UpsertUncertifiedPipelineRange(repo.ID, run.Branch, "from", "approved", run.ID); err != nil { + t.Fatal(err) + } + certifiedRange, err := d.GetUncertifiedPipelineRange(repo.ID, run.Branch) + if err != nil { + t.Fatal(err) + } + if _, err := d.sql.Exec(`CREATE TRIGGER refuse_certified_range_delete BEFORE DELETE ON uncertified_pipeline_ranges BEGIN SELECT RAISE(ABORT, 'refuse delete'); END`); err != nil { + t.Fatal(err) + } + + if err := d.CompleteReviewStep(step.ID, run.ID, "approved", 0, 10, "review.log", certifiedRange); err == nil { + t.Fatal("expected certified range deletion to roll back review completion") + } + gotStep, _ := d.GetStepResult(step.ID) + gotRun, _ := d.GetRun(run.ID) + gotRange, _ := d.GetUncertifiedPipelineRange(repo.ID, run.Branch) + if gotStep.Status != types.StepStatusRunning || gotStep.CompletedAt != nil || gotRun.ReviewApprovedHeadSHA != nil || gotRange == nil { + t.Fatalf("failed transaction persisted partial certification: step=%#v run=%#v range=%#v", gotStep, gotRun, gotRange) + } +} + func TestFailStep(t *testing.T) { d := openTestDB(t) repo, _ := d.InsertRepo("/home/user/project", "git@github.com:user/project.git", "main") diff --git a/internal/db/uncertified.go b/internal/db/uncertified.go index 657dc4b..b336c03 100644 --- a/internal/db/uncertified.go +++ b/internal/db/uncertified.go @@ -6,22 +6,26 @@ import ( "strings" ) -// UncertifiedPipelineRange is the per-branch span of pipeline-authored -// commits whose re-review did not complete. The next run on that branch -// feeds this range into the initial review so the replacement reviewer is -// not cold. The database range is the authority; commit messages are not. +// UncertifiedPipelineRange is the per-branch recovery boundary for review +// truth whose verification did not complete. SelectionApplied records whether +// the selected fix reached the branch. The database boundary is authoritative. type UncertifiedPipelineRange struct { - RepoID string - Branch string - FromSHA string - ToSHA string - SourceRunID string - CreatedAt int64 + RepoID string + Branch string + FromSHA string + ToSHA string + SourceRunID string + SelectionApplied bool + CreatedAt int64 } -// UpsertUncertifiedPipelineRange records or replaces the uncertified fixer -// range for one repo+branch. A newer uncertified HEAD replaces an older one. +// UpsertUncertifiedPipelineRange records or replaces the uncertified recovery +// boundary for one repo+branch. A newer uncertified HEAD replaces an older one. func (d *DB) UpsertUncertifiedPipelineRange(repoID, branch, fromSHA, toSHA, sourceRunID string) error { + return d.UpsertUncertifiedPipelineRangeState(repoID, branch, fromSHA, toSHA, sourceRunID, true) +} + +func (d *DB) UpsertUncertifiedPipelineRangeState(repoID, branch, fromSHA, toSHA, sourceRunID string, selectionApplied bool) error { repoID = strings.TrimSpace(repoID) branch = strings.TrimSpace(branch) fromSHA = strings.TrimSpace(fromSHA) @@ -31,14 +35,15 @@ func (d *DB) UpsertUncertifiedPipelineRange(repoID, branch, fromSHA, toSHA, sour return fmt.Errorf("uncertified pipeline range requires repo, branch, from_sha, to_sha, and source run") } _, err := d.sql.Exec( - `INSERT INTO uncertified_pipeline_ranges (repo_id, branch, from_sha, to_sha, source_run_id, created_at) - VALUES (?, ?, ?, ?, ?, ?) + `INSERT INTO uncertified_pipeline_ranges (repo_id, branch, from_sha, to_sha, source_run_id, selection_applied, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(repo_id, branch) DO UPDATE SET from_sha = excluded.from_sha, to_sha = excluded.to_sha, source_run_id = excluded.source_run_id, + selection_applied = excluded.selection_applied, created_at = excluded.created_at`, - repoID, branch, fromSHA, toSHA, sourceRunID, now(), + repoID, branch, fromSHA, toSHA, sourceRunID, selectionApplied, now(), ) if err != nil { return fmt.Errorf("upsert uncertified pipeline range: %w", err) @@ -55,12 +60,12 @@ func (d *DB) GetUncertifiedPipelineRange(repoID, branch string) (*UncertifiedPip return nil, nil } row := d.sql.QueryRow( - `SELECT repo_id, branch, from_sha, to_sha, source_run_id, created_at + `SELECT repo_id, branch, from_sha, to_sha, source_run_id, selection_applied, created_at FROM uncertified_pipeline_ranges WHERE repo_id = ? AND branch = ?`, repoID, branch, ) var r UncertifiedPipelineRange - if err := row.Scan(&r.RepoID, &r.Branch, &r.FromSHA, &r.ToSHA, &r.SourceRunID, &r.CreatedAt); err != nil { + if err := row.Scan(&r.RepoID, &r.Branch, &r.FromSHA, &r.ToSHA, &r.SourceRunID, &r.SelectionApplied, &r.CreatedAt); err != nil { if err == sql.ErrNoRows { return nil, nil } @@ -85,3 +90,36 @@ func (d *DB) DeleteUncertifiedPipelineRange(repoID, branch string) error { } return nil } + +func (d *DB) RestoreUncertifiedPipelineRangeIfCurrent(current UncertifiedPipelineRange, previous *UncertifiedPipelineRange) (bool, error) { + if strings.TrimSpace(current.RepoID) == "" || strings.TrimSpace(current.Branch) == "" { + return false, fmt.Errorf("restore uncertified pipeline range requires current repo and branch") + } + var ( + result sql.Result + err error + ) + if previous == nil { + result, err = d.sql.Exec( + `DELETE FROM uncertified_pipeline_ranges + WHERE repo_id = ? AND branch = ? AND from_sha = ? AND to_sha = ? AND source_run_id = ? AND selection_applied = ?`, + current.RepoID, current.Branch, current.FromSHA, current.ToSHA, current.SourceRunID, current.SelectionApplied, + ) + } else { + result, err = d.sql.Exec( + `UPDATE uncertified_pipeline_ranges + SET from_sha = ?, to_sha = ?, source_run_id = ?, selection_applied = ?, created_at = ? + WHERE repo_id = ? AND branch = ? AND from_sha = ? AND to_sha = ? AND source_run_id = ? AND selection_applied = ?`, + previous.FromSHA, previous.ToSHA, previous.SourceRunID, previous.SelectionApplied, previous.CreatedAt, + current.RepoID, current.Branch, current.FromSHA, current.ToSHA, current.SourceRunID, current.SelectionApplied, + ) + } + if err != nil { + return false, fmt.Errorf("restore uncertified pipeline range: %w", err) + } + changed, err := result.RowsAffected() + if err != nil { + return false, fmt.Errorf("read restored uncertified pipeline range count: %w", err) + } + return changed == 1, nil +} diff --git a/internal/db/uncertified_test.go b/internal/db/uncertified_test.go index 9d1a6ce..aee6f70 100644 --- a/internal/db/uncertified_test.go +++ b/internal/db/uncertified_test.go @@ -28,7 +28,7 @@ func TestUncertifiedPipelineRangeUpsertGetDelete(t *testing.T) { if err != nil { t.Fatal(err) } - if got == nil || got.FromSHA != "from-a" || got.ToSHA != "to-a" || got.SourceRunID != run.ID { + if got == nil || got.FromSHA != "from-a" || got.ToSHA != "to-a" || got.SourceRunID != run.ID || !got.SelectionApplied { t.Fatalf("first upsert = %#v", got) } @@ -43,7 +43,7 @@ func TestUncertifiedPipelineRangeUpsertGetDelete(t *testing.T) { if err != nil { t.Fatal(err) } - if got == nil || got.FromSHA != "from-b" || got.ToSHA != "to-b" || got.SourceRunID != run2.ID { + if got == nil || got.FromSHA != "from-b" || got.ToSHA != "to-b" || got.SourceRunID != run2.ID || !got.SelectionApplied { t.Fatalf("replacement upsert = %#v, want latest range only", got) } diff --git a/internal/e2e/axi_journey_test.go b/internal/e2e/axi_journey_test.go index bf45195..9748ad0 100644 --- a/internal/e2e/axi_journey_test.go +++ b/internal/e2e/axi_journey_test.go @@ -130,6 +130,27 @@ func branchSyncScenario(t *testing.T) string { return path } +func axiGateFindingID(t *testing.T, output string) string { + t.Helper() + if start := strings.LastIndex(output, "\nrun:\n"); start >= 0 { + output = output[start+1:] + } + var doc struct { + Gate struct { + Findings []struct { + ID string `toon:"id"` + } `toon:"findings"` + } `toon:"gate"` + } + if err := toon.UnmarshalString(output, &doc); err != nil { + t.Fatalf("decode axi gate TOON: %v\n%s", err, output) + } + if len(doc.Gate.Findings) != 1 || doc.Gate.Findings[0].ID == "" { + t.Fatalf("axi gate findings = %#v, want one finding with a published ID\n%s", doc.Gate.Findings, output) + } + return doc.Gate.Findings[0].ID +} + // TestAxiBranchSyncJourney reproduces the end-user stale-local journey with the // real binary, fake agent, isolated daemon, and local bare push target. func TestAxiBranchSyncJourney(t *testing.T) { @@ -143,10 +164,11 @@ func TestAxiBranchSyncJourney(t *testing.T) { originalHead := h.CommitChange("feature/sync-journey", "feature.txt", "unsafe\n", "add unsafe feature") operator := h.AddWorktree("feature/sync-journey") gateOut, err := h.RunInDir(operator, "axi", "run", "--intent", "guard the feature and preserve pipeline fixes") - if err != nil || !strings.Contains(gateOut, "sync-1") { + if err != nil { t.Fatalf("initial review gate: %v\n%s", err, gateOut) } - fixOut, err := h.RunInDir(operator, "axi", "respond", "--action", "fix", "--findings", "sync-1") + findingID := axiGateFindingID(t, gateOut) + fixOut, err := h.RunInDir(operator, "axi", "respond", "--action", "fix", "--findings", findingID) if err != nil { t.Fatalf("review fix: %v\n%s", err, fixOut) } @@ -225,15 +247,16 @@ func TestAxiRunReattachesAfterManagedFix(t *testing.T) { submitted := h.CommitChange(branch, "feature.txt", "unsafe\n", "add unsafe feature") operator := h.AddWorktree(branch) gateOut, err := h.RunInDir(operator, "axi", "run", "--intent", "guard the feature and preserve the submitting head") - if err != nil || !strings.Contains(gateOut, "sync-1") { + if err != nil { t.Fatalf("initial review gate: %v\n%s", err, gateOut) } + findingID := axiGateFindingID(t, gateOut) originalRun := h.ActiveRun(branch) if originalRun == nil { t.Fatal("initial axi run did not leave an active run") } - fixOut, err := h.RunInDir(operator, "axi", "respond", "--action", "fix", "--findings", "sync-1") + fixOut, err := h.RunInDir(operator, "axi", "respond", "--action", "fix", "--findings", findingID) if err != nil || !strings.Contains(fixOut, "status: fix_review") { t.Fatalf("review fix: %v\n%s", err, fixOut) } @@ -356,10 +379,11 @@ func TestAxiCustodyRecoveryJourney(t *testing.T) { submitted := h.CommitChange("feature/recover-journey", "feature.txt", "unsafe\n", "add unsafe feature") operator := h.AddWorktree("feature/recover-journey") gateOut, err := h.RunInDir(operator, "axi", "run", "--intent", "guard the feature before cancellation") - if err != nil || !strings.Contains(gateOut, "sync-1") { + if err != nil { t.Fatalf("initial review gate: %v\n%s", err, gateOut) } - fixOut, err := h.RunInDir(operator, "axi", "respond", "--action", "fix", "--findings", "sync-1") + findingID := axiGateFindingID(t, gateOut) + fixOut, err := h.RunInDir(operator, "axi", "respond", "--action", "fix", "--findings", findingID) if err != nil { t.Fatalf("review fix: %v\n%s", err, fixOut) } @@ -564,14 +588,15 @@ func TestAxiCustodyRecoveryAfterRebaseJourney(t *testing.T) { operator := h.AddWorktree("feature/rebase-recover") gateOut, err := h.RunInDir(operator, "axi", "run", "--intent", "guard the feature across a rebased base before cancellation") - if err != nil || !strings.Contains(gateOut, "rebase-1") { + if err != nil { t.Fatalf("initial review gate: %v\n%s", err, gateOut) } + findingID := axiGateFindingID(t, gateOut) // Take the fix round, which adds a file without rewriting the operator's // line, then cancel. The preserved head is now the operator's own commits // replayed onto the advanced base plus one additive pipeline commit, so it // still carries every local change. - fixOut, err := h.RunInDir(operator, "axi", "respond", "--action", "fix", "--findings", "rebase-1") + fixOut, err := h.RunInDir(operator, "axi", "respond", "--action", "fix", "--findings", findingID) if err != nil { t.Fatalf("review fix: %v\n%s", err, fixOut) } @@ -670,9 +695,10 @@ func TestAxiPrePushAbortUnmovedHeadCustodyJourney(t *testing.T) { submitted := h.CommitChange("feature/unmoved-abort", "feature.txt", "unsafe\n", "add unsafe feature") operator := h.AddWorktree("feature/unmoved-abort") gateOut, err := h.RunInDir(operator, "axi", "run", "--intent", "guard the feature before the delivery switch") - if err != nil || !strings.Contains(gateOut, "sync-1") { + if err != nil { t.Fatalf("initial review gate: %v\n%s", err, gateOut) } + axiGateFindingID(t, gateOut) // Delivery switches to a direct PR: abort at the gate, before any pipeline // edit, through the supported public command. @@ -835,8 +861,10 @@ func TestAxiPrePushAbortUnmovedHeadCustodyJourney(t *testing.T) { // between. h.CommitChange("feature/unmoved-rerun", "feature.txt", "unsafe\n", "add second unsafe feature") rerunOperator := h.AddWorktree("feature/unmoved-rerun") - if out, err := h.RunInDir(rerunOperator, "axi", "run", "--intent", "guard the second feature"); err != nil || !strings.Contains(out, "sync-1") { + if out, err := h.RunInDir(rerunOperator, "axi", "run", "--intent", "guard the second feature"); err != nil { t.Fatalf("second lane review gate: %v\n%s", err, out) + } else { + axiGateFindingID(t, out) } if out, err := h.RunInDir(rerunOperator, "axi", "abort"); err != nil { t.Fatalf("second lane abort: %v\n%s", err, out) @@ -848,9 +876,10 @@ func TestAxiPrePushAbortUnmovedHeadCustodyJourney(t *testing.T) { t.Fatalf("commit follow-up: %v\n%s", gitErr, out) } freshOut, err := h.RunInDir(rerunOperator, "axi", "run", "--intent", "revalidate after the unmoved abort without a recovery") - if err != nil || !strings.Contains(freshOut, "sync-1") { + if err != nil { t.Fatalf("fresh validation after unmoved abort was blocked: %v\n%s", err, freshOut) } + axiGateFindingID(t, freshOut) if out, err := h.RunInDir(rerunOperator, "axi", "abort"); err != nil { t.Fatalf("cleanup abort: %v\n%s", err, out) } diff --git a/internal/e2e/journey_test.go b/internal/e2e/journey_test.go index 9a8c927..d63ded5 100644 --- a/internal/e2e/journey_test.go +++ b/internal/e2e/journey_test.go @@ -493,13 +493,19 @@ func cleanReviewScenario(t *testing.T) string { delay_ms: 1500 structured: findings: - - id: "review-info" + - prior_id: null + prior_continuity_token: null severity: info file: "hello.txt" line: 1 description: "looks good" action: no-op review_scope: source + evidence: + tested: + - "fakeagent: simulated review" + testing_summary: "informational observation only" + artifacts: [] summary: "no blocking issues" risk_level: low risk_rationale: "informational finding only" @@ -511,13 +517,19 @@ func cleanReviewScenario(t *testing.T) string { text: "looks good" structured: findings: - - id: "review-info" + - prior_id: null + prior_continuity_token: null severity: info file: "hello.txt" line: 1 description: "looks good" action: no-op review_scope: source + evidence: + tested: + - "fakeagent: simulated review" + testing_summary: "informational observation only" + artifacts: [] summary: "no blocking issues" risk_level: low risk_rationale: "informational finding only" @@ -1796,29 +1808,11 @@ func assertTestAgentNewTestFileRun(t *testing.T, h *Harness) { if testStep.Status != types.StepStatusCompleted { t.Fatalf("test step status = %s, want completed", testStep.Status) } - if testStep.FindingsJSON == nil { - t.Fatal("expected test step to record findings JSON for new test file") - } - findings, err := types.ParseFindingsJSON(*testStep.FindingsJSON) - if err != nil { - t.Fatalf("parse new test file findings: %v", err) - } - if len(findings.Items) != 1 { - t.Fatalf("expected one new test file finding, got %+v", findings.Items) - } - item := findings.Items[0] - if item.Severity != "info" { - t.Fatalf("new test file finding severity = %q, want info", item.Severity) - } - if item.Action != types.ActionNoOp { - t.Fatalf("new test file finding action = %q, want no-op", item.Action) - } - if item.File != "agent_test.py" { - t.Fatalf("new test file finding file = %q, want agent_test.py", item.File) - } - if !strings.Contains(item.Description, "new test file written by agent: agent_test.py") { - t.Fatalf("new test file finding description = %q", item.Description) - } + // The direct TestStep regression pins the informational no-op finding. + // This full journey pins the end-user outcome after Document adopts the + // previously uncommitted file and head invalidation reruns the required + // gates: the run completes and publishes the agent-created test. + assertAgentCreatedTestPushed(t, h, "test-agent-new-test-file", "agent_test.py", "def test_agent():\n pass\n") } func assertTestAgentStagedNewTestFileRun(t *testing.T, h *Harness) { @@ -1838,28 +1832,19 @@ func assertTestAgentStagedNewTestFileRun(t *testing.T, h *Harness) { if testStep.Status != types.StepStatusCompleted { t.Fatalf("test step status = %s, want completed", testStep.Status) } - if testStep.FindingsJSON == nil { - t.Fatal("expected test step to record findings JSON for staged new test file") - } - findings, err := types.ParseFindingsJSON(*testStep.FindingsJSON) + assertAgentCreatedTestPushed(t, h, "test-agent-staged-new-test-file", "agent_staged_test.go", "package main\n") +} + +func assertAgentCreatedTestPushed(t *testing.T, h *Harness, branch, path, want string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + got, err := h.runGit(ctx, h.UpstreamDir, "show", "refs/heads/"+branch+":"+path) if err != nil { - t.Fatalf("parse staged new test file findings: %v", err) - } - if len(findings.Items) != 1 { - t.Fatalf("expected one staged new test file finding, got %+v", findings.Items) - } - item := findings.Items[0] - if item.Severity != "info" { - t.Fatalf("staged new test file finding severity = %q, want info", item.Severity) - } - if item.Action != types.ActionNoOp { - t.Fatalf("staged new test file finding action = %q, want no-op", item.Action) - } - if item.File != "agent_staged_test.go" { - t.Fatalf("staged new test file finding file = %q, want agent_staged_test.go", item.File) + t.Fatalf("read agent-created test %s from pushed branch %s: %v\n%s", path, branch, err, got) } - if !strings.Contains(item.Description, "new test file written by agent: agent_staged_test.go") { - t.Fatalf("staged new test file finding description = %q", item.Description) + if string(got) != want { + t.Fatalf("pushed agent-created test %s = %q, want %q", path, got, want) } } diff --git a/internal/paths/evidence_test.go b/internal/paths/evidence_test.go index 4842c77..8f89017 100644 --- a/internal/paths/evidence_test.go +++ b/internal/paths/evidence_test.go @@ -16,6 +16,17 @@ import ( // actually catches the regression. func TestEvidenceRootResolvesUnderAppRoot(t *testing.T) { root := t.TempDir() + canonicalHome, canonicalHomeSet := os.LookupEnv("NS_HOME") + if err := os.Unsetenv("NS_HOME"); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if canonicalHomeSet { + _ = os.Setenv("NS_HOME", canonicalHome) + } else { + _ = os.Unsetenv("NS_HOME") + } + }) t.Setenv("NM_HOME", root) p, err := New() diff --git a/internal/pipeline/executor.go b/internal/pipeline/executor.go index beb1e05..4eb54d2 100644 --- a/internal/pipeline/executor.go +++ b/internal/pipeline/executor.go @@ -224,14 +224,24 @@ func (e *Executor) Execute(ctx context.Context, run *db.Run, repo *db.Repo, work e.emitStepEventWithFindingsAndError(ipc.EventStepCompleted, run, repo, step.Name(), string(types.StepStatusSkipped), "", "", nil) continue } - state, err := e.durableExecutionState(sr.ID) + state, sr, err := e.executionStateForStep(step, sr) if err != nil { return e.failRun(run, repo, fmt.Errorf("restore step %s execution state: %w", step.Name(), err), ctx) } + stepRecords[step.Name()] = sr + previousHeadSHA := run.HeadSHA skipRemaining, restartFrom, err := e.executeStep(ctx, step, sr, run, repo, workDir, logDir, state) if err != nil { return e.failRun(run, repo, err, ctx) } + restartIndex, restarting, err := e.restartAfterStep(run, repo, previousHeadSHA, restartFrom, i) + if err != nil { + return e.failRun(run, repo, restartStepError(step.Name(), restartFrom, err), ctx) + } + if restarting { + i = restartIndex - 1 + continue + } if skipRemaining { // Mark all subsequent steps as skipped for _, remaining := range e.steps[i+1:] { @@ -243,13 +253,6 @@ func (e *Executor) Execute(ctx context.Context, run *db.Run, repo *db.Repo, work } break } - if restartFrom != "" { - restartIndex, err := e.prepareRestart(run, repo, restartFrom, i) - if err != nil { - return e.failRun(run, repo, restartStepError(step.Name(), restartFrom, err), ctx) - } - i = restartIndex - 1 - } } // Mark run as completed. A failure here must emit a terminal failure rather @@ -284,12 +287,95 @@ func (e *Executor) prepareRestart(run *db.Run, repo *db.Repo, name types.StepNam return index, nil } +func (e *Executor) restartAfterStep(run *db.Run, repo *db.Repo, previousHeadSHA string, requested types.StepName, currentIndex int) (int, bool, error) { + if run.HeadSHA != previousHeadSHA { + index, err := e.restartIndexForStaleRequiredGates(run) + if err != nil { + return 0, false, err + } + if index >= 0 { + e.onEvent(ipc.Event{Type: ipc.EventStepsReset, RunID: run.ID, RepoID: repo.ID}) + return index, true, nil + } + } + if requested == "" { + return 0, false, nil + } + index, err := e.prepareRestart(run, repo, requested, currentIndex) + return index, err == nil, err +} + func (e *Executor) initializeRunScopes(runID string) { sessionsEnabled := e.config != nil && e.config.SessionReuse && e.agent != nil e.sessions = NewRunSessions(e.db, runID, e.agent, sessionsEnabled) e.shared = &RunShared{} } +func (e *Executor) executionStateForStep(step Step, sr *db.StepResult) (stepExecutionState, *db.StepResult, error) { + fresh, err := e.db.GetStepResult(sr.ID) + if err != nil { + return stepExecutionState{}, nil, err + } + if fresh == nil { + return stepExecutionState{}, nil, fmt.Errorf("step result %s not found", sr.ID) + } + rounds, err := e.db.GetRoundsByStep(sr.ID) + if err != nil { + return stepExecutionState{}, nil, err + } + state := stepExecutionState{} + if len(rounds) > 0 { + latest := rounds[len(rounds)-1] + state.roundNum = latest.Round + state.currentRoundID = latest.ID + for _, round := range rounds { + if round.SelectionSource != nil && *round.SelectionSource == db.RoundSelectionSourceAutoFix { + state.autoFixAttempts++ + } + } + } + if findingsMayBeScopeLimited(step) && fresh.FindingsJSON != nil { + state.carriedFindings = *fresh.FindingsJSON + } + return state, fresh, nil +} + +func (e *Executor) restartIndexForStaleRequiredGates(run *db.Run) (int, error) { + steps, err := e.db.GetStepsByRun(run.ID) + if err != nil { + return -1, fmt.Errorf("load required gate certifications: %w", err) + } + required := map[types.StepName]bool{ + types.StepReview: true, + types.StepTest: true, + types.StepDocument: true, + } + var earliest types.StepName + for _, step := range steps { + if !required[step.StepName] || step.Status != types.StepStatusCompleted { + continue + } + if step.CertifiedHeadSHA != nil && *step.CertifiedHeadSHA == run.HeadSHA { + continue + } + if earliest == "" || step.StepOrder < earliest.Order() { + earliest = step.StepName + } + } + if earliest == "" { + return -1, nil + } + index, err := e.stepIndex(earliest) + if err != nil { + return -1, err + } + if err := e.db.ResetStepsFromOrder(run.ID, earliest.Order()); err != nil { + return -1, fmt.Errorf("invalidate stale required gates: %w", err) + } + slog.Info("pipeline head changed; rerunning required gates", "run", run.ID, "head", run.HeadSHA, "from", earliest) + return index, nil +} + type stepExecutionState struct { fixing bool previousFindings string @@ -297,21 +383,7 @@ type stepExecutionState struct { autoFixAttempts int executionMS int64 currentRoundID string -} - -func (e *Executor) durableExecutionState(stepResultID string) (stepExecutionState, error) { - rounds, err := e.db.GetRoundsByStep(stepResultID) - if err != nil { - return stepExecutionState{}, err - } - state := stepExecutionState{} - for _, round := range rounds { - state.roundNum = max(state.roundNum, round.Round) - if round.SelectionSource != nil && *round.SelectionSource == db.RoundSelectionSourceAutoFix { - state.autoFixAttempts++ - } - } - return state, nil + carriedFindings string } func (e *Executor) dispatchableStepResult(stepResultID string, stepName types.StepName) (*db.StepResult, error) { @@ -378,15 +450,18 @@ func (e *Executor) Resume(ctx context.Context, run *db.Run, repo *db.Repo, workD if gate.reviewedHeadSHA == "" { return fmt.Errorf("recovered review has no durable reviewed head candidate") } - if err := e.db.CompleteReviewStep(gate.stepResult.ID, run.ID, gate.reviewedHeadSHA, recoveredExitCode(gate.stepResult), duration, recoveredLogPath(gate.stepResult)); err != nil { + certifiedRange, err := certifiedUncertifiedPipelineRange(ctx, e.db, repo.ID, run.Branch, gate.reviewedHeadSHA, workDir) + if err != nil { + return err + } + if err := e.db.CompleteReviewStep(gate.stepResult.ID, run.ID, gate.reviewedHeadSHA, recoveredExitCode(gate.stepResult), duration, recoveredLogPath(gate.stepResult), certifiedRange); err != nil { return err } reviewedHead := gate.reviewedHeadSHA run.ReviewApprovedHeadSHA = &reviewedHead - ClearUncertifiedPipelineRangeIfCertified(ctx, e.db, repo.ID, run.Branch, reviewedHead, workDir) return nil } - return e.db.CompleteStepWithStatus(gate.stepResult.ID, types.StepStatusCompleted, recoveredExitCode(gate.stepResult), duration, recoveredLogPath(gate.stepResult)) + return e.db.CompleteStepWithStatusAtHead(gate.stepResult.ID, types.StepStatusCompleted, run.HeadSHA, recoveredExitCode(gate.stepResult), duration, recoveredLogPath(gate.stepResult)) } completeReconciledGate := func() error { if err := completeRecoveredGate(); err != nil { @@ -494,24 +569,39 @@ func (e *Executor) Resume(ctx context.Context, run *db.Run, repo *db.Repo, workD return e.failRun(run, repo, fmt.Errorf("step %s: aborted by user", gate.step.Name()), ctx) case types.ActionFix: telemetry.Track("fix", e.fixTelemetryFields("user", gate.step.Name(), selectedFindingCount(gate.findings, response.findingIDs), 0)) - selected := filterFindingsJSON(gate.findings, response.findingIDs) - merged := mergeUserOverridesJSON(selected, response.instructions, response.addedFindings) - if gate.lastRoundID != "" { - allSelectedIDs := combineSelectedFindingIDs(response.findingIDs, merged) - if idsJSON := marshalFindingIDs(allSelectedIDs); idsJSON != "" { - var userFindingsJSON *string - if merged != "" && merged != selected { - userFindingsJSON = &merged - } - if dbErr := e.db.SetStepRoundUserDecision(gate.lastRoundID, &idsJSON, db.RoundSelectionSourceUser, userFindingsJSON); dbErr != nil { - slog.Warn("failed to record recovered user decision", "step", gate.step.Name(), "round", gate.round, "error", dbErr) - } + selectionTruth := gate.findings + if findingsMayBeScopeLimited(gate.step) { + selectionTruth, err = prepareReviewSelectionTruth(selectionTruth) + if err != nil { + return e.failRun(run, repo, fmt.Errorf("prepare recovered %s gate truth: %w", gate.step.Name(), err), ctx) } } + selected := filterFindingsJSON(selectionTruth, response.findingIDs) + registerLineages := findingsMayBeScopeLimited(gate.step) + merged, registered, err := prepareUserFixFindingsJSON(selected, selectionTruth, response.instructions, response.addedFindings, registerLineages) + if err != nil { + return e.failRun(run, repo, fmt.Errorf("normalize recovered %s user findings: %w", gate.step.Name(), err), ctx) + } + if registerLineages { + if err := e.persistReviewFixSelection(ctx, workDir, run, repo, gate.lastRoundID, gate.stepResult.ID, selectionTruth, registered, response.findingIDs, db.RoundSelectionSourceUser, persistedUserFindings(selected, merged)); err != nil { + return e.failRun(run, repo, fmt.Errorf("record recovered %s user decision: %w", gate.step.Name(), err), ctx) + } + } else if err := e.persistUserFixDecision(gate.lastRoundID, gate.stepResult.ID, response.findingIDs, selected, merged, registered); err != nil { + if findingsMayBeScopeLimited(gate.step) { + return e.failRun(run, repo, fmt.Errorf("record recovered %s user decision: %w", gate.step.Name(), err), ctx) + } + slog.Warn("failed to record recovered user decision", "step", gate.step.Name(), "round", gate.round, "error", err) + } if dbErr := e.db.UpdateStepStatus(gate.stepResult.ID, types.StepStatusFixing); dbErr != nil { return e.failRun(run, repo, fmt.Errorf("mark recovered step %s fixing: %w", gate.step.Name(), dbErr), ctx) } e.emitStepEventWithFindingsAndError(ipc.EventStepCompleted, run, repo, gate.step.Name(), string(types.StepStatusFixing), "", "", nil) + carried := "" + if registerLineages { + carried = excludeFindingsJSON(selectionTruth, response.findingIDs) + gate.stepResult.FindingsJSON = ®istered + } + previousHeadSHA := run.HeadSHA skipRemaining, restartFrom, err := e.executeStep(ctx, gate.step, gate.stepResult, run, repo, workDir, logDir, stepExecutionState{ fixing: true, previousFindings: merged, @@ -519,20 +609,21 @@ func (e *Executor) Resume(ctx context.Context, run *db.Run, repo *db.Repo, workD autoFixAttempts: gate.autoFixes, executionMS: duration, currentRoundID: gate.lastRoundID, + carriedFindings: carried, }) if err != nil { return e.failRun(run, repo, err, ctx) } - if skipRemaining { - return e.skipRecoveredRemainder(run, repo, gate.index+1) + restartIndex, restarting, restartErr := e.restartAfterStep(run, repo, previousHeadSHA, restartFrom, gate.index) + if restartErr != nil { + return e.failRun(run, repo, restartStepError(gate.step.Name(), restartFrom, restartErr), ctx) } - if restartFrom != "" { - restartIndex, indexErr := e.prepareRestart(run, repo, restartFrom, gate.index) - if indexErr != nil { - return e.failRun(run, repo, restartStepError(gate.step.Name(), restartFrom, indexErr), ctx) - } + if restarting { return e.executeRecoveredRemainder(ctx, run, repo, workDir, logDir, restartIndex) } + if skipRemaining { + return e.skipRecoveredRemainder(run, repo, gate.index+1) + } return e.executeRecoveredRemainder(ctx, run, repo, workDir, logDir, gate.index+1) default: return e.failRun(run, repo, fmt.Errorf("step %s: unsupported approval action %q", gate.step.Name(), response.action), ctx) @@ -621,23 +712,30 @@ func (e *Executor) executeRecoveredRemainder(ctx context.Context, run *db.Run, r if result.Status == types.StepStatusSkipped { continue } - state, stateErr := e.durableExecutionState(result.ID) + state, result, stateErr := e.executionStateForStep(e.steps[index], result) if stateErr != nil { return e.failRun(run, repo, fmt.Errorf("restore step %s execution state: %w", e.steps[index].Name(), stateErr), ctx) } + results[index] = result + previousHeadSHA := run.HeadSHA skipRemaining, restartFrom, err := e.executeStep(ctx, e.steps[index], result, run, repo, workDir, logDir, state) if err != nil { return e.failRun(run, repo, err, ctx) } - if skipRemaining { - return e.skipRecoveredRemainder(run, repo, index+1) + restartIndex, restarting, restartErr := e.restartAfterStep(run, repo, previousHeadSHA, restartFrom, index) + if restartErr != nil { + return e.failRun(run, repo, restartStepError(e.steps[index].Name(), restartFrom, restartErr), ctx) } - if restartFrom != "" { - restartIndex, indexErr := e.prepareRestart(run, repo, restartFrom, index) - if indexErr != nil { - return e.failRun(run, repo, restartStepError(e.steps[index].Name(), restartFrom, indexErr), ctx) + if restarting { + results, err = e.db.GetStepsByRun(run.ID) + if err != nil { + return e.failRun(run, repo, fmt.Errorf("reload recovered steps after head change: %w", err), ctx) } index = restartIndex - 1 + continue + } + if skipRemaining { + return e.skipRecoveredRemainder(run, repo, index+1) } } if err := e.completeRun(run, repo); err != nil { @@ -799,6 +897,15 @@ func (e *Executor) executeStep(ctx context.Context, step Step, sr *db.StepResult // invocation during execution of round N+1 sees roundNum still at N. autoFixAttempts := state.autoFixAttempts roundNum := state.roundNum + carryFindings := findingsMayBeScopeLimited(step) + carriedFindings := state.carriedFindings + if !carryFindings { + carriedFindings = "" + } + knownLineages := "" + if sr.FindingsJSON != nil { + knownLineages = *sr.FindingsJSON + } stepAgent := e.agent if stepAgent != nil { @@ -849,7 +956,20 @@ func (e *Executor) executeStep(ctx context.Context, step Step, sr *db.StepResult OnPRMerged: e.onPRMerged, } if stepName == types.StepReview { - BindUncertifiedPipelineRange(sctx) + if err := BindUncertifiedPipelineRange(sctx); err != nil { + return false, "", fmt.Errorf("restore uncertified review: %w", err) + } + if sctx.UncertifiedPriorFindings != "" { + carriedFindings = mergeCarriedFindingsJSON(carriedFindings, sctx.UncertifiedPriorFindings, string(stepName)) + if err := e.db.SetStepFindings(sr.ID, carriedFindings); err != nil { + return false, "", fmt.Errorf("restore uncertified review findings: %w", err) + } + } + if sctx.UncertifiedPriorLineages != "" { + knownLineages = mergeFindingsJSON(knownLineages, sctx.UncertifiedPriorLineages) + } else if carriedFindings != "" { + knownLineages = carriedFindings + } } nextTrigger := "initial" @@ -866,6 +986,7 @@ func (e *Executor) executeStep(ctx context.Context, step Step, sr *db.StepResult for { reviewStartingHeadSHA := run.HeadSHA sctx.ReviewStartingHeadSHA = reviewStartingHeadSHA + sctx.KnownReviewLineages = knownLineages outcome, err := step.Execute(sctx) roundNum++ roundDuration := time.Since(phaseStart).Milliseconds() @@ -891,24 +1012,42 @@ func (e *Executor) executeStep(ctx context.Context, step Step, sr *db.StepResult if stepName == types.StepReview { reviewApprovedHeadSHA = outcome.ReviewApprovedHeadSHA } - outcome.Findings = normalizeFindingsJSON(outcome.Findings, string(stepName)) + priorLineages := knownLineages + if !outcome.FindingsNormalized { + outcome.Findings, err = normalizeFindingsJSON(outcome.Findings, string(stepName), knownLineages) + if err != nil { + return false, "", fmt.Errorf("normalize %s findings: %w", stepName, err) + } + } + if carryFindings { + outcome.Findings = mergeReappearedFindingsJSON(outcome.Findings, priorLineages) + } finalExitCode = outcome.ExitCode durationOverrideMS += outcome.DurationOverrideMS + effectiveFindings := outcome.Findings + if carryFindings { + effectiveFindings = mergeCarriedFindingsJSON(outcome.Findings, carriedFindings, string(stepName)) + } + if effectiveFindings != "" { + knownLineages = effectiveFindings + } - if outcome.Findings != "" { - if dbErr := e.db.SetStepFindings(sr.ID, outcome.Findings); dbErr != nil { - slog.Warn("failed to set step findings in db", "step", stepName, "error", dbErr) - } - } else { - if dbErr := e.db.ClearStepFindings(sr.ID); dbErr != nil { - slog.Warn("failed to clear step findings in db", "step", stepName, "error", dbErr) + if !carryFindings { + if effectiveFindings != "" { + if dbErr := e.db.SetStepFindings(sr.ID, effectiveFindings); dbErr != nil { + slog.Warn("failed to set step findings in db", "step", stepName, "error", dbErr) + } + } else { + if dbErr := e.db.ClearStepFindings(sr.ID); dbErr != nil { + slog.Warn("failed to clear step findings in db", "step", stepName, "error", dbErr) + } } } // Persist this execution round. var findingsPtr *string - if outcome.Findings != "" { - findingsPtr = &outcome.Findings + if effectiveFindings != "" { + findingsPtr = &effectiveFindings } var fixSummaryPtr *string if outcome.FixSummary != "" { @@ -921,7 +1060,16 @@ func (e *Executor) executeStep(ctx context.Context, step Step, sr *db.StepResult if stepName == types.StepCI && restartFrom != "" && !sctx.Fixing { roundTrigger = "auto_fix" } - if stepName == types.StepReview { + if carryFindings { + trustedConfigSHA := "" + var globalConfigYAML, repoConfigYAML []byte + if e.config != nil && e.config.CaptureEvalProvenance { + trustedConfigSHA = e.config.TrustedConfigSHA + globalConfigYAML = e.config.ReplayGlobalYAML + repoConfigYAML = e.config.ReplayRepoYAML + } + inserted, dbErr = e.db.InsertEffectiveReviewStepRoundWithProvenance(sr.ID, roundNum, roundTrigger, findingsPtr, fixSummaryPtr, reviewApprovedHeadSHA, reviewStartingHeadSHA, trustedConfigSHA, globalConfigYAML, repoConfigYAML, roundDuration) + } else if stepName == types.StepReview { if e.config != nil && e.config.CaptureEvalProvenance { inserted, dbErr = e.db.InsertReviewStepRoundWithProvenance(sr.ID, roundNum, roundTrigger, findingsPtr, fixSummaryPtr, reviewApprovedHeadSHA, reviewStartingHeadSHA, e.config.TrustedConfigSHA, e.config.ReplayGlobalYAML, e.config.ReplayRepoYAML, roundDuration) } else { @@ -932,6 +1080,9 @@ func (e *Executor) executeStep(ctx context.Context, step Step, sr *db.StepResult } if dbErr != nil { currentRoundID = roundInsertID(currentRoundID, inserted, dbErr) + if carryFindings { + return false, "", fmt.Errorf("persist %s round %d: %w", stepName, roundNum, dbErr) + } slog.Warn("failed to insert step round", "step", stepName, "round", roundNum, "error", dbErr) } else { currentRoundID = roundInsertID(currentRoundID, inserted, nil) @@ -963,7 +1114,18 @@ func (e *Executor) executeStep(ctx context.Context, step Step, sr *db.StepResult // This runs before the NeedsApproval check so that all severity // levels (including "info") get a chance at automatic fixing. if outcome.AutoFixable && autoFixLimit > 0 && autoFixAttempts < autoFixLimit && !convergenceTripped { - fixableFindings := autoFixableFindingsJSON(outcome.Findings) + selectionTruth := effectiveFindings + if carryFindings { + selectionTruth, err = prepareReviewSelectionTruth(effectiveFindings) + if err != nil { + return false, "", fmt.Errorf("prepare %s auto-fix gate truth: %w", stepName, err) + } + } + roundOwnFindings := effectiveFindings + if carryFindings { + roundOwnFindings = retainMatchingFindingsJSON(selectionTruth, outcome.Findings) + } + fixableFindings := autoFixableFindingsJSON(roundOwnFindings) if fixableFindings != "" { autoFixAttempts++ telemetry.Track("fix", e.fixTelemetryFields("auto", stepName, findingsCount(fixableFindings), autoFixAttempts)) @@ -971,27 +1133,34 @@ func (e *Executor) executeStep(ctx context.Context, step Step, sr *db.StepResult executionMS += time.Since(phaseStart).Milliseconds() fixCount := findingsCount(fixableFindings) writeLog(fmt.Sprintf("auto-fix round %d/%d starting after round %d (%d %s)", autoFixAttempts, autoFixLimit, roundNum, fixCount, pluralize(fixCount, "finding", "findings"))) + if carryFindings { + if err := e.persistReviewFixSelection(ctx, workDir, run, repo, currentRoundID, sr.ID, selectionTruth, selectionTruth, findingIDList(fixableFindings), db.RoundSelectionSourceAutoFix, ""); err != nil { + return false, "", fmt.Errorf("record %s auto-fix selection: %w", stepName, err) + } + effectiveFindings = selectionTruth + knownLineages = selectionTruth + sr.FindingsJSON = &selectionTruth + } else if err := e.persistAutoFixSelection(currentRoundID, fixableFindings); err != nil { + slog.Warn("failed to record selected finding ids", "step", stepName, "round", roundNum, "error", err) + } if dbErr := e.db.UpdateStepStatus(sr.ID, types.StepStatusFixing); dbErr != nil { slog.Warn("failed to update step status in db", "step", stepName, "status", "fixing", "error", dbErr) } - if currentRoundID != "" { - if idsJSON := findingIDsJSON(fixableFindings); idsJSON != "" { - if dbErr := e.db.SetStepRoundSelection(currentRoundID, &idsJSON, db.RoundSelectionSourceAutoFix); dbErr != nil { - slog.Warn("failed to record selected finding ids", "step", stepName, "round", roundNum, "error", dbErr) - } - } - } e.emitStepEventWithFindingsAndError(ipc.EventStepCompleted, run, repo, stepName, string(types.StepStatusFixing), "", "", nil) phaseStart = time.Now() sctx.Fixing = true sctx.PreviousFindings = fixableFindings nextTrigger = "auto_fix" + if carryFindings { + carriedFindings = excludeFindingsJSON(selectionTruth, findingIDList(fixableFindings)) + } continue } } - if !outcome.NeedsApproval && !hasAskUserFindingsJSON(outcome.Findings) && - !(convergenceTripped && actionableFindingsCountJSON(outcome.Findings) > 0) { + carryRequiresApproval := carryFindings && actionableFindingsCountJSON(carriedFindings) > 0 + if !outcome.NeedsApproval && !hasAskUserFindingsJSON(effectiveFindings) && !carryRequiresApproval && + !(convergenceTripped && actionableFindingsCountJSON(effectiveFindings) > 0) { // Step completed without needing approval. // Any remaining info-only or non-blocking findings // are acceptable and don't block the pipeline. @@ -1042,7 +1211,7 @@ func (e *Executor) executeStep(ctx context.Context, step Step, sr *db.StepResult e.mu.Unlock() return false, "", fmt.Errorf("persist %s approval gate: %w", stepName, dbErr) } - e.emitStepEventWithFindingsAndError(ipc.EventStepCompleted, run, repo, stepName, string(approvalStatus), outcome.Findings, "", &executionMS) + e.emitStepEventWithFindingsAndError(ipc.EventStepCompleted, run, repo, stepName, string(approvalStatus), effectiveFindings, "", &executionMS) response, reconciled, err := e.waitForApprovalOrReconcile(ctx, step, sctx, true) if dbErr := e.db.CompleteRunAwaitingAgent(run.ID, time.Since(parkStart).Milliseconds()); dbErr != nil { @@ -1068,7 +1237,7 @@ func (e *Executor) executeStep(ctx context.Context, step Step, sr *db.StepResult if agentName := e.telemetryAgentName(); agentName != "" { approvalFields["agent"] = agentName } - if selectedCount := selectedFindingCount(outcome.Findings, response.findingIDs); selectedCount > 0 { + if selectedCount := selectedFindingCount(effectiveFindings, response.findingIDs); selectedCount > 0 { approvalFields["selected_findings_count"] = selectedCount } telemetry.Track("approval", approvalFields) @@ -1081,7 +1250,7 @@ func (e *Executor) executeStep(ctx context.Context, step Step, sr *db.StepResult // adjudication by the approver; record it durably in the step log so // a "passed" run carrying unapplied findings is always attributable // to an explicit decision, never a silent default. - if n := actionableFindingsCountJSON(outcome.Findings); n > 0 { + if n := actionableFindingsCountJSON(effectiveFindings); n > 0 { writeLog(fmt.Sprintf("gate approved with %d unresolved actionable %s; approval recorded as explicit adjudication", n, pluralize(n, "finding", "findings"))) } phaseStart = time.Now() @@ -1103,31 +1272,44 @@ func (e *Executor) executeStep(ctx context.Context, step Step, sr *db.StepResult return false, "", fmt.Errorf("step %s: aborted by user", stepName) case types.ActionFix: - telemetry.Track("fix", e.fixTelemetryFields("user", stepName, selectedFindingCount(outcome.Findings, response.findingIDs), 0)) + telemetry.Track("fix", e.fixTelemetryFields("user", stepName, selectedFindingCount(effectiveFindings, response.findingIDs), 0)) // Fix - mark step as fixing, resume execution timer, re-execute. phaseStart = time.Now() - selectedCount := selectedFindingCount(outcome.Findings, response.findingIDs) + selectedCount := selectedFindingCount(effectiveFindings, response.findingIDs) writeLog(fmt.Sprintf("user-fix round starting after round %d (%d %s selected)", roundNum, selectedCount, pluralize(selectedCount, "finding", "findings"))) + selectionTruth := effectiveFindings + if carryFindings { + selectionTruth, err = prepareReviewSelectionTruth(selectionTruth) + if err != nil { + return false, "", fmt.Errorf("prepare %s user-fix gate truth: %w", stepName, err) + } + } + selectedFindings := filterFindingsJSON(selectionTruth, response.findingIDs) + mergedFindings, registeredLineages, err := prepareUserFixFindingsJSON(selectedFindings, selectionTruth, response.instructions, response.addedFindings, carryFindings) + if err != nil { + return false, "", fmt.Errorf("normalize %s user findings: %w", stepName, err) + } + if carryFindings { + if err := e.persistReviewFixSelection(ctx, workDir, run, repo, currentRoundID, sr.ID, selectionTruth, registeredLineages, response.findingIDs, db.RoundSelectionSourceUser, persistedUserFindings(selectedFindings, mergedFindings)); err != nil { + return false, "", fmt.Errorf("record %s user decision: %w", stepName, err) + } + } else if err := e.persistUserFixDecision(currentRoundID, sr.ID, response.findingIDs, selectedFindings, mergedFindings, registeredLineages); err != nil { + if carryFindings { + return false, "", fmt.Errorf("record %s user decision: %w", stepName, err) + } + slog.Warn("failed to record user decision", "step", stepName, "round", roundNum, "error", err) + } if dbErr := e.db.UpdateStepStatus(sr.ID, types.StepStatusFixing); dbErr != nil { slog.Warn("failed to update step status in db", "step", stepName, "status", "fixing", "error", dbErr) } sctx.Fixing = true - selectedFindings := filterFindingsJSON(outcome.Findings, response.findingIDs) - mergedFindings := mergeUserOverridesJSON(selectedFindings, response.instructions, response.addedFindings) sctx.PreviousFindings = mergedFindings - nextTrigger = "auto_fix" - if currentRoundID != "" { - allSelectedIDs := combineSelectedFindingIDs(response.findingIDs, mergedFindings) - if idsJSON := marshalFindingIDs(allSelectedIDs); idsJSON != "" { - var userFindingsJSON *string - if mergedFindings != "" && mergedFindings != selectedFindings { - userFindingsJSON = &mergedFindings - } - if dbErr := e.db.SetStepRoundUserDecision(currentRoundID, &idsJSON, db.RoundSelectionSourceUser, userFindingsJSON); dbErr != nil { - slog.Warn("failed to record user decision", "step", stepName, "round", roundNum, "error", dbErr) - } - } + if carryFindings { + knownLineages = registeredLineages + sr.FindingsJSON = ®isteredLineages + carriedFindings = excludeFindingsJSON(selectionTruth, response.findingIDs) } + nextTrigger = "auto_fix" e.emitStepEventWithFindingsAndError(ipc.EventStepCompleted, run, repo, stepName, string(types.StepStatusFixing), "", "", nil) slog.Info("step fix requested, re-executing", "step", stepName) continue // loop back to step.Execute @@ -1149,19 +1331,97 @@ done: // return earlier, and skipped reviews deliberately leave the binding empty. // Completion and authority replacement are one DB transaction. if stepName == types.StepReview && status == types.StepStatusCompleted && reviewApprovedHeadSHA != "" { - if err := e.db.CompleteReviewStep(sr.ID, run.ID, reviewApprovedHeadSHA, finalExitCode, durationMS, logPath); err != nil { + certifiedRange, err := certifiedUncertifiedPipelineRange(ctx, e.db, repo.ID, run.Branch, reviewApprovedHeadSHA, workDir) + if err != nil { + return false, "", fmt.Errorf("complete step %s: %w", stepName, err) + } + if err := e.db.CompleteReviewStep(sr.ID, run.ID, reviewApprovedHeadSHA, finalExitCode, durationMS, logPath, certifiedRange); err != nil { return false, "", fmt.Errorf("complete step %s: %w", stepName, err) } reviewedHead := reviewApprovedHeadSHA run.ReviewApprovedHeadSHA = &reviewedHead - ClearUncertifiedPipelineRangeIfCertified(ctx, e.db, repo.ID, run.Branch, reviewedHead, workDir) - } else if err := e.db.CompleteStepWithStatus(sr.ID, status, finalExitCode, durationMS, logPath); err != nil { + } else if err := e.db.CompleteStepWithStatusAtHead(sr.ID, status, run.HeadSHA, finalExitCode, durationMS, logPath); err != nil { return false, "", fmt.Errorf("complete step %s: %w", stepName, err) } e.emitStepEventWithFindingsAndError(ipc.EventStepCompleted, run, repo, stepName, string(status), "", "", &durationMS) return skipRemaining, restartFrom, nil } +func (e *Executor) persistAutoFixSelection(roundID, findings string) error { + idsJSON := findingIDsJSON(findings) + if idsJSON == "" { + return nil + } + if roundID == "" { + return errors.New("step round is not durable") + } + return e.db.SetStepRoundSelection(roundID, &idsJSON, db.RoundSelectionSourceAutoFix) +} + +func (e *Executor) persistUserFixDecision(roundID, stepResultID string, selectedIDs []string, selected, merged, registeredLineages string) error { + idsJSON := marshalFindingIDs(combineSelectedFindingIDs(selectedIDs, merged)) + if idsJSON == "" { + return nil + } + if roundID == "" { + return errors.New("step round is not durable") + } + var userFindingsJSON *string + if merged != "" && merged != selected { + userFindingsJSON = &merged + } + if registeredLineages != "" { + return e.db.SetStepRoundUserDecisionAndFindings(roundID, stepResultID, &idsJSON, db.RoundSelectionSourceUser, userFindingsJSON, registeredLineages) + } + return e.db.SetStepRoundUserDecision(roundID, &idsJSON, db.RoundSelectionSourceUser, userFindingsJSON) +} + +func (e *Executor) persistReviewFixSelection(ctx context.Context, workDir string, run *db.Run, repo *db.Repo, roundID, stepResultID, roundFindings, stepFindings string, selectedIDs []string, source, userFindings string) error { + idsJSON := marshalFindingIDs(combineSelectedFindingIDs(selectedIDs, userFindings)) + if idsJSON == "" { + return nil + } + fromSHA := strings.TrimSpace(run.HeadSHA) + existing, err := e.db.GetUncertifiedPipelineRange(repo.ID, run.Branch) + if err != nil { + return fmt.Errorf("read existing review recovery marker: %w", err) + } + if existing != nil { + inLineage, lineageErr := commitIsSelfOrAncestor(ctx, workDir, existing.ToSHA, run.HeadSHA) + if lineageErr != nil { + return fmt.Errorf("verify existing review recovery marker: %w", lineageErr) + } + if inLineage { + fromSHA = existing.FromSHA + } + } + var userFindingsJSON *string + if userFindings != "" { + userFindingsJSON = &userFindings + } + return e.db.PersistReviewFixSelection(db.ReviewFixSelection{ + RoundID: roundID, + StepResultID: stepResultID, + RepoID: repo.ID, + Branch: run.Branch, + FromSHA: fromSHA, + HeadSHA: run.HeadSHA, + SourceRunID: run.ID, + RoundFindingsJSON: roundFindings, + StepFindingsJSON: stepFindings, + SelectedFindingIDs: &idsJSON, + SelectionSource: source, + UserFindingsJSON: userFindingsJSON, + }) +} + +func persistedUserFindings(selected, merged string) string { + if merged == selected { + return "" + } + return merged +} + func roundInsertID(_ string, inserted *db.StepRound, err error) string { if err != nil || inserted == nil { return "" diff --git a/internal/pipeline/executor_approval_test.go b/internal/pipeline/executor_approval_test.go index e1d52d9..4d296f3 100644 --- a/internal/pipeline/executor_approval_test.go +++ b/internal/pipeline/executor_approval_test.go @@ -3,6 +3,7 @@ package pipeline import ( "context" "fmt" + "strings" "testing" "time" @@ -139,7 +140,7 @@ func TestExecutor_ResumeRestoresParkedGateAndReviewSessions(t *testing.T) { if err := database.SetStepFindings(stepResult.ID, findings); err != nil { t.Fatal(err) } - if _, err := database.InsertReviewStepRound(stepResult.ID, 1, "initial", &findings, nil, "1111111111111111111111111111111111111111", 25); err != nil { + if _, err := database.InsertReviewStepRound(stepResult.ID, 1, "initial", &findings, nil, run.HeadSHA, 25); err != nil { t.Fatal(err) } if err := database.UpdateStepStatusWithDuration(stepResult.ID, types.StepStatusAwaitingApproval, 25); err != nil { @@ -174,7 +175,7 @@ func TestExecutor_ResumeRestoresParkedGateAndReviewSessions(t *testing.T) { if _, err := sctx.Agent.Run(sctx.Ctx, agent.RunOpts{Prompt: "rereview"}); err != nil { return nil, err } - return &StepOutcome{ReviewApprovedHeadSHA: "2222222222222222222222222222222222222222"}, nil + return &StepOutcome{ReviewApprovedHeadSHA: run.HeadSHA}, nil }, } exec := NewExecutor(database, p, &config.Config{SessionReuse: true}, fake, []Step{step}, nil) @@ -221,11 +222,79 @@ func TestExecutor_ResumeRestoresParkedGateAndReviewSessions(t *testing.T) { if resumed.Status != types.RunCompleted || resumed.AwaitingAgentSince != nil { t.Fatalf("recovered run = status %s awaiting %v, want completed and unparked", resumed.Status, resumed.AwaitingAgentSince) } - if resumed.ReviewApprovedHeadSHA == nil || *resumed.ReviewApprovedHeadSHA != "2222222222222222222222222222222222222222" { + if resumed.ReviewApprovedHeadSHA == nil || *resumed.ReviewApprovedHeadSHA != run.HeadSHA { t.Fatalf("recovered rereview approval = %#v", resumed.ReviewApprovedHeadSHA) } } +func TestExecutor_ResumeCarriesUnselectedReviewFinding(t *testing.T) { + database, p, run, repo := setupTest(t) + if err := database.UpdateRunStatus(run.ID, types.RunRunning); err != nil { + t.Fatal(err) + } + stepResult, err := database.InsertStepResult(run.ID, types.StepReview) + if err != nil { + t.Fatal(err) + } + if err := database.StartStep(stepResult.ID); err != nil { + t.Fatal(err) + } + findings := `{"findings":[{"id":"review-1","severity":"error","description":"selected","action":"ask-user"},{"id":"review-2","severity":"warning","description":"must survive","action":"ask-user"}],"summary":"2"}` + if err := database.SetStepFindings(stepResult.ID, findings); err != nil { + t.Fatal(err) + } + if _, err := database.InsertReviewStepRound(stepResult.ID, 1, "initial", &findings, nil, run.HeadSHA, 10); err != nil { + t.Fatal(err) + } + if err := database.UpdateStepStatusWithDuration(stepResult.ID, types.StepStatusAwaitingApproval, 10); err != nil { + t.Fatal(err) + } + if err := database.SetRunAwaitingAgent(run.ID); err != nil { + t.Fatal(err) + } + run, err = database.GetRun(run.ID) + if err != nil { + t.Fatal(err) + } + + step := &scopeLimitedAdaptiveCallStep{adaptiveCallStep: adaptiveCallStep{ + name: types.StepReview, + fn: func(sctx *StepContext) (*StepOutcome, error) { + return &StepOutcome{ + Findings: `{"findings":[],"summary":"clean rereview","risk_level":"low"}`, + ReviewApprovedHeadSHA: run.HeadSHA, + }, nil + }, + }} + exec := NewExecutor(database, p, &config.Config{}, nil, []Step{step}, nil) + done, _ := startResumeExecutor(t, exec, run, repo, t.TempDir()) + + deadline := time.Now().Add(5 * time.Second) + for { + if err := exec.Respond(types.StepReview, types.ActionFix, []string{"review-1"}); err == nil { + break + } + if time.Now().After(deadline) { + t.Fatal("recovered gate never accepted a fix response") + } + time.Sleep(10 * time.Millisecond) + } + waitForStepStatus(t, database, run.ID, types.StepReview, types.StepStatusFixReview) + steps, err := database.GetStepsByRun(run.ID) + if err != nil || steps[0].FindingsJSON == nil || !strings.Contains(*steps[0].FindingsJSON, "review-2") { + t.Fatalf("recovered fix-review did not retain review-2: %v %#v", err, steps[0].FindingsJSON) + } + if strings.Contains(*steps[0].FindingsJSON, "review-1") { + t.Fatalf("selected review-1 remained outstanding: %s", *steps[0].FindingsJSON) + } + if err := exec.Respond(types.StepReview, types.ActionApprove, nil); err != nil { + t.Fatal(err) + } + if err := <-done; err != nil { + t.Fatal(err) + } +} + func TestExecutor_ResumePromotesDurableReviewedCandidateOnApproval(t *testing.T) { database, p, run, repo := setupTest(t) if err := database.UpdateRunStatus(run.ID, types.RunRunning); err != nil { diff --git a/internal/pipeline/executor_autofix_test.go b/internal/pipeline/executor_autofix_test.go index a909e81..2eb603d 100644 --- a/internal/pipeline/executor_autofix_test.go +++ b/internal/pipeline/executor_autofix_test.go @@ -38,7 +38,7 @@ func TestExecutor_AutoFixTriggersWithoutApproval(t *testing.T) { if sctx.PreviousFindings == "" { t.Error("expected PreviousFindings to be set on auto-fix") } - return &StepOutcome{}, nil + return &StepOutcome{ReviewApprovedHeadSHA: run.HeadSHA}, nil }, } @@ -59,6 +59,49 @@ func TestExecutor_AutoFixTriggersWithoutApproval(t *testing.T) { } } +func TestExecutor_ReviewAutoFixPersistsRecoveryTruthBeforeFixer(t *testing.T) { + database, p, run, repo := setupTest(t) + workDir := t.TempDir() + cfg := &config.Config{AutoFix: config.AutoFix{Review: 1}} + calls := 0 + step := &scopeLimitedAdaptiveCallStep{adaptiveCallStep: adaptiveCallStep{ + name: types.StepReview, + fn: func(sctx *StepContext) (*StepOutcome, error) { + calls++ + if calls == 1 { + return &StepOutcome{NeedsApproval: true, AutoFixable: true, Findings: `{"findings":[{"id":"legacy-a","severity":"error","description":"unsafe loader","action":"auto-fix"}],"tested":["reproduce loader failure"]}`}, nil + } + marker, err := database.GetUncertifiedPipelineRange(repo.ID, run.Branch) + if err != nil { + t.Fatal(err) + } + if marker == nil || marker.FromSHA != run.HeadSHA || marker.ToSHA != run.HeadSHA || marker.SourceRunID != run.ID { + t.Fatalf("recovery marker = %#v", marker) + } + steps, err := database.GetStepsByRun(run.ID) + if err != nil || len(steps) != 1 || steps[0].FindingsJSON == nil { + t.Fatalf("durable step findings: steps=%#v err=%v", steps, err) + } + findings, err := types.ParseFindingsJSON(*steps[0].FindingsJSON) + if err != nil { + t.Fatal(err) + } + if len(findings.Items) != 1 || !findings.Items[0].HasLineage() || findings.Items[0].Evidence == nil { + t.Fatalf("durable gate truth = %#v", findings.Items) + } + return &StepOutcome{}, nil + }, + }} + + exec := NewExecutor(database, p, cfg, nil, []Step{step}, nil) + if err := exec.Execute(context.Background(), run, repo, workDir); err != nil { + t.Fatal(err) + } + if calls != 2 { + t.Fatalf("step calls = %d, want 2", calls) + } +} + func TestExecutor_PersistsEffectiveAutoFixLimit(t *testing.T) { database, p, run, repo := setupTest(t) workDir := t.TempDir() @@ -391,10 +434,11 @@ func TestExecutor_AutoFixMixedFindings(t *testing.T) { t.Errorf("expected fixable finding 'bug', got %q", parsed.Items[0].Description) } // Return only the ask-user finding remaining + designID := findingIDByDescription(t, database, run.ID, types.StepReview, "design choice") return &StepOutcome{ NeedsApproval: true, AutoFixable: true, - Findings: `{"findings":[{"id":"review-2","severity":"warning","description":"design choice","action":"ask-user"}],"summary":"1 issue"}`, + Findings: `{"findings":[{"id":"` + designID + `","severity":"warning","description":"design choice","action":"ask-user"}],"summary":"1 issue"}`, }, nil }, } diff --git a/internal/pipeline/executor_fix_test.go b/internal/pipeline/executor_fix_test.go index eeebde5..ac151fd 100644 --- a/internal/pipeline/executor_fix_test.go +++ b/internal/pipeline/executor_fix_test.go @@ -3,6 +3,8 @@ package pipeline import ( "context" "encoding/json" + "fmt" + "slices" "strings" "testing" "time" @@ -83,6 +85,304 @@ func TestExecutor_FixEmitsFixReviewStatusWithoutStreamingTheDiff(t *testing.T) { } } +func TestExecutor_UnselectedReviewFindingSurvivesSilentRereview(t *testing.T) { + database, p, run, repo := setupTest(t) + workDir := t.TempDir() + + initial := `{"findings":[{"id":"review-1","severity":"error","description":"unsafe loader","action":"ask-user"},{"id":"review-2","severity":"warning","description":"hardcoded timeout","action":"ask-user"}],"summary":"2 findings","tested":["initial review evidence"]}` + empty := `{"findings":[],"summary":"no new findings","risk_level":"low","tested":["rereview evidence"]}` + calls := 0 + step := &scopeLimitedAdaptiveCallStep{adaptiveCallStep: adaptiveCallStep{ + name: types.StepReview, + fn: func(sctx *StepContext) (*StepOutcome, error) { + calls++ + if calls == 1 { + return &StepOutcome{NeedsApproval: true, Findings: initial}, nil + } + return &StepOutcome{Findings: empty}, nil + }, + }} + + exec := NewExecutor(database, p, nil, nil, []Step{step}, nil) + done, _ := startExecutor(t, exec, run, repo, workDir) + + waitForStepStatus(t, database, run.ID, types.StepReview, types.StepStatusAwaitingApproval) + unsafeID := findingIDByDescription(t, database, run.ID, types.StepReview, "unsafe loader") + if err := exec.Respond(types.StepReview, types.ActionFix, []string{unsafeID}); err != nil { + t.Fatal(err) + } + + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + select { + case err := <-done: + t.Fatalf("run completed after a silent rereview dropped an unresolved finding: %v", err) + default: + } + steps, err := database.GetStepsByRun(run.ID) + if err == nil && len(steps) == 1 && steps[0].Status == types.StepStatusFixReview { + if steps[0].FindingsJSON == nil { + t.Fatal("fix-review gate has no findings") + } + parsed, err := types.ParseFindingsJSON(*steps[0].FindingsJSON) + if err != nil { + t.Fatal(err) + } + if len(parsed.Items) != 1 || parsed.Items[0].Description != "hardcoded timeout" { + t.Fatalf("outstanding findings = %#v, want only hardcoded timeout", parsed.Items) + } + if len(parsed.Tested) != 2 || !slices.Contains(parsed.Tested, "initial review evidence") || !slices.Contains(parsed.Tested, "rereview evidence") { + t.Fatalf("merged review evidence = %#v, want both rounds", parsed.Tested) + } + stats, err := database.StepFindingStats(steps[0]) + if err != nil { + t.Fatal(err) + } + if stats.ReportedFindings != 2 || stats.FixedFindings != 1 { + t.Fatalf("finding stats = reported %d, fixed %d; want 2 and 1", stats.ReportedFindings, stats.FixedFindings) + } + if err := exec.Respond(types.StepReview, types.ActionApprove, nil); err != nil { + t.Fatal(err) + } + if err := <-done; err != nil { + t.Fatal(err) + } + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("review did not park again on the unresolved carried finding") +} + +func TestExecutor_ReviewUserFixPersistsRecoveryTruthBeforeFixer(t *testing.T) { + database, p, run, repo := setupTest(t) + workDir := t.TempDir() + calls := 0 + step := &scopeLimitedAdaptiveCallStep{adaptiveCallStep: adaptiveCallStep{ + name: types.StepReview, + fn: func(sctx *StepContext) (*StepOutcome, error) { + calls++ + if calls == 1 { + return &StepOutcome{NeedsApproval: true, Findings: `{"findings":[{"id":"legacy-a","severity":"error","description":"unsafe loader","action":"ask-user"}]}`}, nil + } + marker, err := database.GetUncertifiedPipelineRange(repo.ID, run.Branch) + if err != nil { + return nil, err + } + if marker == nil || marker.FromSHA != run.HeadSHA || marker.ToSHA != run.HeadSHA || marker.SourceRunID != run.ID { + return nil, fmt.Errorf("recovery marker = %#v", marker) + } + return &StepOutcome{ReviewApprovedHeadSHA: run.HeadSHA}, nil + }, + }} + exec := NewExecutor(database, p, nil, nil, []Step{step}, nil) + done, _ := startExecutor(t, exec, run, repo, workDir) + waitForStepStatus(t, database, run.ID, types.StepReview, types.StepStatusAwaitingApproval) + id := findingIDByDescription(t, database, run.ID, types.StepReview, "unsafe loader") + if err := exec.Respond(types.StepReview, types.ActionFix, []string{id}); err != nil { + t.Fatal(err) + } + waitExecutorDone(t, done) + if calls != 2 { + t.Fatalf("step calls = %d, want 2", calls) + } +} + +func TestExecutor_LaterSelectedCarriedFindingClearsAfterVerification(t *testing.T) { + database, p, run, repo := setupTest(t) + workDir := t.TempDir() + + initial := `{"findings":[{"id":"review-1","severity":"error","description":"unsafe loader","action":"ask-user"},{"id":"review-2","severity":"warning","description":"hardcoded timeout","action":"ask-user"}],"summary":"2 findings"}` + empty := `{"findings":[],"summary":"no new findings","risk_level":"low"}` + calls := 0 + step := &scopeLimitedAdaptiveCallStep{adaptiveCallStep: adaptiveCallStep{ + name: types.StepReview, + fn: func(sctx *StepContext) (*StepOutcome, error) { + calls++ + if calls == 1 { + return &StepOutcome{NeedsApproval: true, Findings: initial}, nil + } + return &StepOutcome{Findings: empty}, nil + }, + }} + + exec := NewExecutor(database, p, nil, nil, []Step{step}, nil) + done, _ := startExecutor(t, exec, run, repo, workDir) + + waitForStepStatus(t, database, run.ID, types.StepReview, types.StepStatusAwaitingApproval) + firstID := findingIDByDescription(t, database, run.ID, types.StepReview, "unsafe loader") + if err := exec.Respond(types.StepReview, types.ActionFix, []string{firstID}); err != nil { + t.Fatal(err) + } + waitForStepStatus(t, database, run.ID, types.StepReview, types.StepStatusFixReview) + secondID := findingIDByDescription(t, database, run.ID, types.StepReview, "hardcoded timeout") + if err := exec.Respond(types.StepReview, types.ActionFix, []string{secondID}); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("run did not complete after the later-selected finding passed verification") + } + + steps, err := database.GetStepsByRun(run.ID) + if err != nil { + t.Fatal(err) + } + rounds, err := database.GetRoundsByStep(steps[0].ID) + if err != nil { + t.Fatal(err) + } + if len(rounds) != 3 || rounds[1].SelectedFindingIDs == nil || !strings.Contains(*rounds[1].SelectedFindingIDs, secondID) { + t.Fatalf("later selection was not durably attached to the carried gate: %#v", rounds) + } +} + +func TestExecutor_CarriedFindingKeepsIdentityAndStricterAction(t *testing.T) { + database, p, run, repo := setupTest(t) + workDir := t.TempDir() + + initial := `{"findings":[{"id":"review-1","severity":"error","file":"loader.go","line":8,"description":"unsafe loader","action":"ask-user"},{"id":"review-2","severity":"warning","description":"selected first","action":"ask-user"}],"summary":"2 findings"}` + unsafeID := "" + unsafeToken := "" + calls := 0 + step := &scopeLimitedAdaptiveCallStep{adaptiveCallStep: adaptiveCallStep{ + name: types.StepReview, + fn: func(sctx *StepContext) (*StepOutcome, error) { + calls++ + if calls == 1 { + return &StepOutcome{NeedsApproval: true, Findings: initial}, nil + } + rereview := `{"findings":[{"prior_id":"` + unsafeID + `","prior_continuity_token":"` + unsafeToken + `","severity":"error","file":"loader.go","line":9,"description":"unsafe loader","action":"no-op"},{"severity":"warning","description":"new concern","action":"ask-user"}],"summary":"2 findings"}` + return &StepOutcome{NeedsApproval: true, Findings: rereview}, nil + }, + }} + + exec := NewExecutor(database, p, nil, nil, []Step{step}, nil) + done, _ := startExecutor(t, exec, run, repo, workDir) + waitForStepStatus(t, database, run.ID, types.StepReview, types.StepStatusAwaitingApproval) + unsafeID = findingIDByDescription(t, database, run.ID, types.StepReview, "unsafe loader") + parkedSteps, err := database.GetStepsByRun(run.ID) + if err != nil || parkedSteps[0].FindingsJSON == nil { + t.Fatalf("read initial findings: %v", err) + } + parkedFindings, err := types.ParseFindingsJSON(*parkedSteps[0].FindingsJSON) + if err != nil { + t.Fatal(err) + } + for _, finding := range parkedFindings.Items { + if finding.ID == unsafeID { + unsafeToken = finding.ContinuityToken + } + } + if unsafeToken == "" { + t.Fatalf("finding %q has no continuity token", unsafeID) + } + selectedID := findingIDByDescription(t, database, run.ID, types.StepReview, "selected first") + if err := exec.Respond(types.StepReview, types.ActionFix, []string{selectedID}); err != nil { + t.Fatal(err) + } + waitForStepStatus(t, database, run.ID, types.StepReview, types.StepStatusFixReview) + + steps, err := database.GetStepsByRun(run.ID) + if err != nil || steps[0].FindingsJSON == nil { + t.Fatalf("read parked findings: %v", err) + } + findings, err := types.ParseFindingsJSON(*steps[0].FindingsJSON) + if err != nil { + t.Fatal(err) + } + ids := make(map[string]bool) + for _, finding := range findings.Items { + if ids[finding.ID] { + t.Fatalf("duplicate published finding id %q: %#v", finding.ID, findings.Items) + } + ids[finding.ID] = true + if finding.Description == "unsafe loader" && (finding.ID != unsafeID || finding.Action != "ask-user") { + t.Fatalf("restated carried finding lost identity or was relaxed: %#v", finding) + } + } + if len(findings.Items) != 2 || !ids[unsafeID] { + t.Fatalf("effective findings = %#v, want two stable unique ids", findings.Items) + } + if err := exec.Respond(types.StepReview, types.ActionApprove, nil); err != nil { + t.Fatal(err) + } + if err := <-done; err != nil { + t.Fatal(err) + } +} + +func TestExecutor_NonActionableCarryDoesNotGateFreshNonblockingFinding(t *testing.T) { + database, p, run, repo := setupTest(t) + initial := `{"findings":[{"id":"review-1","severity":"error","description":"selected defect","action":"ask-user"},{"id":"review-2","severity":"info","description":"informational carry","action":"no-op"}],"summary":"two"}` + rereview := `{"findings":[{"id":"review-3","severity":"info","description":"optional cleanup","action":"auto-fix"}],"summary":"one suggestion"}` + calls := 0 + step := &scopeLimitedAdaptiveCallStep{adaptiveCallStep: adaptiveCallStep{ + name: types.StepReview, + fn: func(sctx *StepContext) (*StepOutcome, error) { + calls++ + if calls == 1 { + return &StepOutcome{NeedsApproval: true, Findings: initial}, nil + } + return &StepOutcome{AutoFixable: true, Findings: rereview}, nil + }, + }} + + exec := NewExecutor(database, p, nil, nil, []Step{step}, nil) + done, _ := startExecutor(t, exec, run, repo, t.TempDir()) + waitForStepStatus(t, database, run.ID, types.StepReview, types.StepStatusAwaitingApproval) + selectedID := findingIDByDescription(t, database, run.ID, types.StepReview, "selected defect") + if err := exec.Respond(types.StepReview, types.ActionFix, []string{selectedID}); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("non-actionable carry created an approval gate") + } +} + +func TestExecutor_DoesNotDispatchCarriedFixWhenSelectionPersistenceFails(t *testing.T) { + database, p, run, repo := setupTest(t) + calls := 0 + step := &scopeLimitedAdaptiveCallStep{adaptiveCallStep: adaptiveCallStep{ + name: types.StepReview, + fn: func(sctx *StepContext) (*StepOutcome, error) { + calls++ + return &StepOutcome{NeedsApproval: true, Findings: `{"findings":[{"id":"review-1","severity":"error","description":"must persist","action":"ask-user"}]}`}, nil + }, + }} + + exec := NewExecutor(database, p, nil, nil, []Step{step}, nil) + done, _ := startExecutor(t, exec, run, repo, t.TempDir()) + waitForStepStatus(t, database, run.ID, types.StepReview, types.StepStatusAwaitingApproval) + selectedID := findingIDByDescription(t, database, run.ID, types.StepReview, "must persist") + if err := database.Close(); err != nil { + t.Fatal(err) + } + if err := exec.Respond(types.StepReview, types.ActionFix, []string{selectedID}); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if err == nil || !strings.Contains(err.Error(), "record review user decision") { + t.Fatalf("executor error = %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("executor did not fail after selection persistence failed") + } + if calls != 1 { + t.Fatalf("review calls = %d, want no verification dispatch", calls) + } +} + func TestExecutor_FixEmitsFixingStatusImmediately(t *testing.T) { database, p, run, repo := setupTest(t) workDir := t.TempDir() @@ -326,7 +626,7 @@ func TestExecutor_AssignsFindingIDsBeforePersistingAndEmitting(t *testing.T) { if len(items) != 2 { t.Fatalf("expected 2 findings, got %d", len(items)) } - if items[0].ID != "review-1" || items[1].ID != "review-2" { + if items[0].ID == "" || items[1].ID == "" || items[0].ID == items[1].ID || !items[0].IDGenerated || !items[1].IDGenerated { t.Fatalf("unexpected finding IDs: %#v", items) } @@ -341,7 +641,7 @@ func TestExecutor_AssignsFindingIDsBeforePersistingAndEmitting(t *testing.T) { if len(storedItems) != 2 { t.Fatalf("expected 2 stored findings, got %d", len(storedItems)) } - if storedItems[0].ID != "review-1" || storedItems[1].ID != "review-2" { + if storedItems[0].ID != items[0].ID || storedItems[1].ID != items[1].ID { t.Fatalf("unexpected stored finding IDs: %#v", storedItems) } @@ -356,21 +656,30 @@ func TestExecutor_FixAppliesUserInstructionsAndAddedFindings(t *testing.T) { workDir := t.TempDir() var capturedFindings string + var capturedDurableFindings string callCount := 0 - step := &adaptiveCallStep{ + step := &scopeLimitedAdaptiveCallStep{adaptiveCallStep: adaptiveCallStep{ name: types.StepReview, fn: func(sctx *StepContext) (*StepOutcome, error) { callCount++ if callCount == 1 { return &StepOutcome{ NeedsApproval: true, - Findings: `{"findings":[{"id":"review-1","severity":"error","description":"first","action":"auto-fix"},{"id":"review-2","severity":"warning","description":"second","action":"auto-fix"}],"summary":"2 findings"}`, + Findings: `{"findings":[{"id":"review-1","severity":"error","description":"first","action":"auto-fix"}],"summary":"1 finding"}`, }, nil } capturedFindings = sctx.PreviousFindings + stored, err := sctx.DB.GetStepResult(sctx.StepResultID) + if err != nil { + t.Fatal(err) + } + if stored == nil || stored.FindingsJSON == nil { + t.Fatalf("durable review lineages = %#v", stored) + } + capturedDurableFindings = *stored.FindingsJSON return &StepOutcome{}, nil }, - } + }} exec := NewExecutor(database, p, nil, nil, []Step{step}, nil) @@ -380,9 +689,10 @@ func TestExecutor_FixAppliesUserInstructionsAndAddedFindings(t *testing.T) { }() waitForStepStatus(t, database, run.ID, types.StepReview, types.StepStatusAwaitingApproval) - instructions := map[string]string{"review-1": "only touch parser.go, skip helpers"} + selectedID := findingIDByDescription(t, database, run.ID, types.StepReview, "first") + instructions := map[string]string{selectedID: "only touch parser.go, skip helpers"} added := []types.Finding{{Severity: "warning", Description: "also audit logger init", Action: types.ActionAutoFix}} - if err := exec.RespondWithOverrides(types.StepReview, types.ActionFix, []string{"review-1"}, instructions, added); err != nil { + if err := exec.RespondWithOverrides(types.StepReview, types.ActionFix, []string{selectedID}, instructions, added); err != nil { t.Fatal(err) } @@ -395,11 +705,15 @@ func TestExecutor_FixAppliesUserInstructionsAndAddedFindings(t *testing.T) { t.Fatal("executor timed out") } - items := mustParseFindingItems(t, capturedFindings) + parsedFindings, err := types.ParseFindingsJSON(capturedFindings) + if err != nil { + t.Fatal(err) + } + items := parsedFindings.Items if len(items) != 2 { t.Fatalf("expected 2 findings (selected + user-added), got %d: %s", len(items), capturedFindings) } - if items[0].ID != "review-1" { + if items[0].ID != selectedID { t.Errorf("expected selected agent finding first, got %q", items[0].ID) } if items[0].UserInstructions != "only touch parser.go, skip helpers" { @@ -411,6 +725,24 @@ func TestExecutor_FixAppliesUserInstructionsAndAddedFindings(t *testing.T) { if items[1].Source != types.FindingSourceUser { t.Errorf("expected user-added finding to be tagged source=user, got %q", items[1].Source) } + if !items[1].HasLineage() || len(items[1].ContinuityToken) != 32 { + t.Fatalf("user-added finding has no durable lineage: %#v", items[1]) + } + parsedDurable, err := types.ParseFindingsJSON(capturedDurableFindings) + if err != nil { + t.Fatal(err) + } + durableItems := parsedDurable.Items + var durableUser *types.Finding + for i := range durableItems { + if durableItems[i].ID == items[1].ID { + durableUser = &durableItems[i] + break + } + } + if durableUser == nil || durableUser.ContinuityToken != items[1].ContinuityToken || durableUser.Source != types.FindingSourceUser { + t.Fatalf("durable user lineage = %#v, rereview finding = %#v", durableUser, items[1]) + } rounds, err := database.GetRoundsByStep(firstStepID(t, database, run.ID)) if err != nil { @@ -475,7 +807,8 @@ func TestExecutor_FixUsesSelectedFindingIDsOnly(t *testing.T) { }() waitForStepStatus(t, database, run.ID, types.StepReview, types.StepStatusAwaitingApproval) - if err := exec.Respond(types.StepReview, types.ActionFix, []string{"review-2"}); err != nil { + selectedID := findingIDByDescription(t, database, run.ID, types.StepReview, "second") + if err := exec.Respond(types.StepReview, types.ActionFix, []string{selectedID}); err != nil { t.Fatal(err) } @@ -492,7 +825,7 @@ func TestExecutor_FixUsesSelectedFindingIDsOnly(t *testing.T) { if len(items) != 1 { t.Fatalf("expected 1 selected finding, got %d", len(items)) } - if items[0].ID != "review-2" || items[0].Description != "second" { + if items[0].ID != selectedID || items[0].Description != "second" { t.Fatalf("unexpected selected finding: %#v", items[0]) } } @@ -638,7 +971,8 @@ func TestExecutor_FixSelectedFindingsRewritesSummary(t *testing.T) { }() waitForStepStatus(t, database, run.ID, types.StepReview, types.StepStatusAwaitingApproval) - if err := exec.Respond(types.StepReview, types.ActionFix, []string{"review-2"}); err != nil { + selectedID := findingIDByDescription(t, database, run.ID, types.StepReview, "second") + if err := exec.Respond(types.StepReview, types.ActionFix, []string{selectedID}); err != nil { t.Fatal(err) } @@ -658,7 +992,7 @@ func TestExecutor_FixSelectedFindingsRewritesSummary(t *testing.T) { if err := json.Unmarshal([]byte(capturedFindings), &payload); err != nil { t.Fatalf("parse findings JSON: %v", err) } - if len(payload.Findings) != 1 || payload.Findings[0].ID != "review-2" { + if len(payload.Findings) != 1 || payload.Findings[0].ID != selectedID { t.Fatalf("unexpected selected findings payload: %#v", payload.Findings) } if payload.Summary != "1 selected finding" { @@ -693,7 +1027,8 @@ func TestExecutor_UserFixRecordsSelectedFindingIDsAndFixSummary(t *testing.T) { }() waitForStepStatus(t, database, run.ID, types.StepReview, types.StepStatusAwaitingApproval) - if err := exec.Respond(types.StepReview, types.ActionFix, []string{"review-2"}); err != nil { + selectedID := findingIDByDescription(t, database, run.ID, types.StepReview, "second") + if err := exec.Respond(types.StepReview, types.ActionFix, []string{selectedID}); err != nil { t.Fatal(err) } @@ -728,7 +1063,7 @@ func TestExecutor_UserFixRecordsSelectedFindingIDsAndFixSummary(t *testing.T) { if err := json.Unmarshal([]byte(*rounds[0].SelectedFindingIDs), &ids); err != nil { t.Fatalf("parse selected_finding_ids: %v", err) } - if len(ids) != 1 || ids[0] != "review-2" { + if len(ids) != 1 || ids[0] != selectedID { t.Fatalf("unexpected selected ids: %v", ids) } @@ -780,8 +1115,12 @@ func TestExecutor_AutoFixRecordsSelectedFindingIDs(t *testing.T) { if err := json.Unmarshal([]byte(*rounds[0].SelectedFindingIDs), &ids); err != nil { t.Fatalf("parse selected_finding_ids: %v", err) } - if len(ids) != 1 || ids[0] != "review-1" { - t.Fatalf("expected only auto-fixable id to be recorded, got %v", ids) + roundFindings, err := types.ParseFindingsJSON(*rounds[0].FindingsJSON) + if err != nil { + t.Fatal(err) + } + if len(ids) != 1 || len(roundFindings.Items) != 2 || ids[0] != roundFindings.Items[0].ID || roundFindings.Items[0].Description != "a" { + t.Fatalf("expected only auto-fixable lineage to be recorded, got %v from %#v", ids, roundFindings.Items) } if rounds[1].FixSummary == nil || *rounds[1].FixSummary != "apply cheap fix" { t.Fatalf("expected fix_summary persisted on round 2, got %v", rounds[1].FixSummary) diff --git a/internal/pipeline/executor_test.go b/internal/pipeline/executor_test.go index d1d091c..bbe3f87 100644 --- a/internal/pipeline/executor_test.go +++ b/internal/pipeline/executor_test.go @@ -3,7 +3,9 @@ package pipeline import ( "context" "fmt" + "sync" "testing" + "time" "github.com/Blakeolson21/no-slop/internal/ipc" "github.com/Blakeolson21/no-slop/internal/telemetry" @@ -65,6 +67,283 @@ func TestExecutor_SuccessfulStepsDoNotEmitTelemetry(t *testing.T) { } } +func TestExecutor_HeadMutationsInvalidateRequiredGateCertifications(t *testing.T) { + for _, mutationStep := range []types.StepName{types.StepDocument, types.StepLint, types.StepPush, types.StepCI} { + t.Run(string(mutationStep), func(t *testing.T) { + database, p, run, _ := setupTest(t) + const oldHead = "old-head" + const newHead = "new-head" + run.HeadSHA = newHead + if err := database.UpdateRunHeadSHA(run.ID, newHead); err != nil { + t.Fatal(err) + } + if err := database.UpdateRunReviewApprovedHeadSHA(run.ID, oldHead); err != nil { + t.Fatal(err) + } + + names := []types.StepName{types.StepReview, types.StepTest, types.StepDocument, types.StepLint, types.StepPush, types.StepPR, types.StepCI} + pipelineSteps := make([]Step, 0, len(names)) + for _, name := range names { + pipelineSteps = append(pipelineSteps, newPassStep(name)) + record, err := database.InsertStepResult(run.ID, name) + if err != nil { + t.Fatal(err) + } + if name.Order() > mutationStep.Order() { + continue + } + certifiedHead := oldHead + if name == mutationStep { + certifiedHead = newHead + } + if err := database.CompleteStepWithStatusAtHead(record.ID, types.StepStatusCompleted, certifiedHead, 0, 1, ""); err != nil { + t.Fatal(err) + } + } + + exec := NewExecutor(database, p, nil, nil, pipelineSteps, nil) + restart, err := exec.restartIndexForStaleRequiredGates(run) + if err != nil { + t.Fatal(err) + } + if restart != 0 { + t.Fatalf("restart index = %d, want review index 0", restart) + } + records, err := database.GetStepsByRun(run.ID) + if err != nil { + t.Fatal(err) + } + for _, record := range records { + if record.Status != types.StepStatusPending || record.CertifiedHeadSHA != nil { + t.Fatalf("step %s was not invalidated: %#v", record.StepName, record) + } + if record.StepName == types.StepReview || record.StepName == types.StepTest || record.StepName == types.StepDocument { + if err := database.CompleteStepWithStatusAtHead(record.ID, types.StepStatusCompleted, newHead, 0, 1, ""); err != nil { + t.Fatal(err) + } + } + } + freshRun, err := database.GetRun(run.ID) + if err != nil { + t.Fatal(err) + } + if freshRun.ReviewApprovedHeadSHA != nil || freshRun.CIReadyAt != nil || freshRun.CIReadyNoCI { + t.Fatalf("stale run gate evidence survived reset: %#v", freshRun) + } + if restart, err := exec.restartIndexForStaleRequiredGates(run); err != nil || restart != -1 { + t.Fatalf("current certifications restart = %d, err = %v", restart, err) + } + }) + } +} + +func TestExecutor_DocumentHeadMutationRerunsRequiredGatesWithCarriedReviewState(t *testing.T) { + database, p, run, repo := setupTest(t) + const ( + oldHead = "1111111111111111111111111111111111111111" + newHead = "2222222222222222222222222222222222222222" + ) + run.HeadSHA = oldHead + if err := database.UpdateRunHeadSHA(run.ID, oldHead); err != nil { + t.Fatal(err) + } + + var mu sync.Mutex + reviewCalls := 0 + testCalls := 0 + documentCalls := 0 + ciCalls := 0 + review := &scopeLimitedAdaptiveCallStep{adaptiveCallStep: adaptiveCallStep{name: types.StepReview, fn: func(*StepContext) (*StepOutcome, error) { + mu.Lock() + defer mu.Unlock() + reviewCalls++ + if reviewCalls == 1 { + return &StepOutcome{ + NeedsApproval: true, + Findings: `{"findings":[{"id":"carried-review-finding","severity":"error","description":"requires explicit adjudication","action":"ask-user"}]}`, + }, nil + } + return &StepOutcome{}, nil + }}} + testStep := &adaptiveCallStep{name: types.StepTest, fn: func(*StepContext) (*StepOutcome, error) { + mu.Lock() + testCalls++ + mu.Unlock() + return &StepOutcome{}, nil + }} + document := &adaptiveCallStep{name: types.StepDocument, fn: func(sctx *StepContext) (*StepOutcome, error) { + mu.Lock() + documentCalls++ + call := documentCalls + mu.Unlock() + if call == 1 { + if err := sctx.DB.UpdateRunHeadSHA(sctx.Run.ID, newHead); err != nil { + return nil, err + } + sctx.Run.HeadSHA = newHead + } + return &StepOutcome{}, nil + }} + ci := &adaptiveCallStep{name: types.StepCI, fn: func(sctx *StepContext) (*StepOutcome, error) { + mu.Lock() + ciCalls++ + mu.Unlock() + results, err := sctx.DB.GetStepsByRun(sctx.Run.ID) + if err != nil { + return nil, err + } + for _, result := range results { + if result.StepName != types.StepReview && result.StepName != types.StepTest && result.StepName != types.StepDocument { + continue + } + if result.Status != types.StepStatusCompleted || result.CertifiedHeadSHA == nil || *result.CertifiedHeadSHA != newHead { + return nil, fmt.Errorf("%s reached CI without current-head certification", result.StepName) + } + } + return &StepOutcome{}, nil + }} + + exec := NewExecutor(database, p, nil, nil, []Step{review, testStep, document, ci}, nil) + workDir := t.TempDir() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { done <- exec.Execute(ctx, run, repo, workDir) }() + + waitForStepStatus(t, database, run.ID, types.StepReview, types.StepStatusAwaitingApproval) + results, err := database.GetStepsByRun(run.ID) + if err != nil { + t.Fatal(err) + } + carriedID := "" + for _, step := range results { + if step.StepName != types.StepReview || step.FindingsJSON == nil { + continue + } + findings, err := types.ParseFindingsJSON(*step.FindingsJSON) + if err != nil { + t.Fatal(err) + } + if len(findings.Items) != 1 { + t.Fatalf("initial review gate findings = %#v", findings.Items) + } + carriedID = findings.Items[0].ID + } + if carriedID == "" { + t.Fatal("initial review gate has no durable finding identity") + } + if err := exec.Respond(types.StepReview, types.ActionApprove, nil); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(5 * time.Second) + for { + mu.Lock() + calls := reviewCalls + mu.Unlock() + result, err := database.GetStepsByRun(run.ID) + if err != nil { + t.Fatal(err) + } + var reviewResult *string + for _, step := range result { + if step.StepName == types.StepReview && step.Status == types.StepStatusAwaitingApproval && step.FindingsJSON != nil { + reviewResult = step.FindingsJSON + } + } + if calls >= 2 && reviewResult != nil { + findings, err := types.ParseFindingsJSON(*reviewResult) + if err != nil { + t.Fatal(err) + } + if len(findings.Items) != 1 || findings.Items[0].ID != carriedID { + t.Fatalf("revalidated review gate findings = %#v", findings.Items) + } + break + } + if time.Now().After(deadline) { + t.Fatal("revalidated review did not preserve its unresolved finding") + } + time.Sleep(10 * time.Millisecond) + } + if err := exec.Respond(types.StepReview, types.ActionApprove, nil); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("executor did not finish revalidation") + } + + mu.Lock() + defer mu.Unlock() + if reviewCalls != 2 || testCalls != 2 || documentCalls != 2 || ciCalls != 1 { + t.Fatalf("step calls = review:%d test:%d document:%d ci:%d", reviewCalls, testCalls, documentCalls, ciCalls) + } +} + +func TestExecutor_RecoveredRemainderRerunsRequiredGatesAfterHeadMutation(t *testing.T) { + database, p, run, repo := setupTest(t) + const ( + oldHead = "1111111111111111111111111111111111111111" + newHead = "2222222222222222222222222222222222222222" + ) + run.HeadSHA = oldHead + if err := database.UpdateRunStatus(run.ID, types.RunRunning); err != nil { + t.Fatal(err) + } + if err := database.UpdateRunHeadSHA(run.ID, oldHead); err != nil { + t.Fatal(err) + } + + review := newPassStep(types.StepReview) + testStep := newPassStep(types.StepTest) + documentCalls := 0 + document := &adaptiveCallStep{name: types.StepDocument, fn: func(sctx *StepContext) (*StepOutcome, error) { + documentCalls++ + if documentCalls == 1 { + if err := sctx.DB.UpdateRunHeadSHA(sctx.Run.ID, newHead); err != nil { + return nil, err + } + sctx.Run.HeadSHA = newHead + } + return &StepOutcome{}, nil + }} + ci := newPassStep(types.StepCI) + steps := []Step{review, testStep, document, ci} + for _, step := range steps { + result, err := database.InsertStepResult(run.ID, step.Name()) + if err != nil { + t.Fatal(err) + } + if step.Name() == types.StepReview || step.Name() == types.StepTest { + if err := database.CompleteStepWithStatusAtHead(result.ID, types.StepStatusCompleted, oldHead, 0, 1, ""); err != nil { + t.Fatal(err) + } + } + } + + exec := NewExecutor(database, p, nil, nil, steps, nil) + exec.initializeRunScopes(run.ID) + if err := exec.executeRecoveredRemainder(context.Background(), run, repo, t.TempDir(), t.TempDir(), 2); err != nil { + t.Fatal(err) + } + if review.callCount() != 1 || testStep.callCount() != 1 || documentCalls != 2 || ci.callCount() != 1 { + t.Fatalf("recovered step calls = review:%d test:%d document:%d ci:%d", review.callCount(), testStep.callCount(), documentCalls, ci.callCount()) + } + results, err := database.GetStepsByRun(run.ID) + if err != nil { + t.Fatal(err) + } + for _, result := range results { + if result.Status != types.StepStatusCompleted || result.CertifiedHeadSHA == nil || *result.CertifiedHeadSHA != newHead { + t.Fatalf("recovered %s result = %#v", result.StepName, result) + } + } +} + func TestExecutor_SkippedStepsDoNotEmitTelemetry(t *testing.T) { database, p, run, repo := setupTest(t) workDir := t.TempDir() diff --git a/internal/pipeline/findings.go b/internal/pipeline/findings.go index 301bf5e..66f39f2 100644 --- a/internal/pipeline/findings.go +++ b/internal/pipeline/findings.go @@ -2,6 +2,8 @@ package pipeline import ( "encoding/json" + "fmt" + "strings" "github.com/Blakeolson21/no-slop/internal/types" ) @@ -10,21 +12,24 @@ import ( // returns them as a JSON array string. Empty result means there were no // findings or parsing failed. func findingIDsJSON(raw string) string { + return marshalFindingIDs(findingIDList(raw)) +} + +func findingIDList(raw string) []string { if raw == "" { - return "" + return nil } findings, err := types.ParseFindingsJSON(raw) if err != nil { - return "" + return nil } ids := make([]string, 0, len(findings.Items)) for _, item := range findings.Items { - if item.ID == "" { - continue + if item.ID != "" { + ids = append(ids, item.ID) } - ids = append(ids, item.ID) } - return marshalFindingIDs(ids) + return ids } // marshalFindingIDs encodes a list of finding IDs as a JSON array. Empty @@ -40,56 +45,60 @@ func marshalFindingIDs(ids []string) string { return string(encoded) } -func findingKey(item types.Finding) types.Finding { - item.ID = "" - item.Action = "" - item.Source = "" - item.UserInstructions = "" - return item +func findingKey(item types.Finding) types.FindingIdentity { + return item.Identity() } -func findingFingerprint(item types.Finding) types.Finding { - item = findingKey(item) - item.Line = 0 - return item +func findingFingerprint(item types.Finding) types.FindingIdentity { + return item.Fingerprint() } -func countFindingFingerprints(items []types.Finding) map[types.Finding]int { - counts := make(map[types.Finding]int, len(items)) - for _, item := range items { - counts[findingFingerprint(item)]++ - } - return counts +func hasFindingMatch(item types.Finding, stableIDs map[string][]types.Finding, itemOccurrenceCounts, candidateOccurrenceCounts map[string]int, itemIdentityCounts, candidateIdentityCounts, itemFingerprintCounts, candidateFingerprintCounts map[types.FindingIdentity]int) bool { + return types.FindingMatches(item, stableIDs, itemOccurrenceCounts, candidateOccurrenceCounts, itemIdentityCounts, candidateIdentityCounts, itemFingerprintCounts, candidateFingerprintCounts) } -func hasFindingMatch(item types.Finding, exact map[types.Finding]bool, itemCounts, candidateCounts map[types.Finding]int) bool { - if exact[findingKey(item)] { - return true - } - fingerprint := findingFingerprint(item) - return itemCounts[fingerprint] == 1 && candidateCounts[fingerprint] == 1 -} - -func normalizeFindingsJSON(raw string, prefix string) string { +func normalizeFindingsJSON(raw string, prefix string, existingRaw string) (string, error) { if raw == "" { - return "" + return "", nil } findings, err := types.ParseFindingsJSON(raw) if err != nil { - return raw + return raw, nil + } + if prefix == string(types.StepReview) && len(findings.Items) == 1 && findings.Items[0].Evidence == nil && + (len(findings.Tested) > 0 || findings.TestingSummary != "" || len(findings.Artifacts) > 0) { + findings.Items[0].Evidence = &types.FindingEvidence{ + Tested: append([]string(nil), findings.Tested...), + TestingSummary: findings.TestingSummary, + Artifacts: append([]types.TestArtifact(nil), findings.Artifacts...), + } + } + var existing []types.Finding + if existingRaw != "" { + parsed, parseErr := types.ParseFindingsJSON(existingRaw) + if parseErr != nil { + return "", parseErr + } + existing = parsed.Items + } + normalized, err := types.NormalizeFindings(findings, prefix, existing) + if err != nil { + return "", err } - normalized := types.NormalizeFindings(findings, prefix) normalizedRaw, err := types.MarshalFindingsJSON(normalized) if err != nil { - return raw + return raw, nil } - return normalizedRaw + return normalizedRaw, nil } func excludeFindingsJSON(raw string, ids []string) string { - if raw == "" || len(ids) == 0 { + if raw == "" { return "" } + if len(ids) == 0 { + return raw + } findings, err := types.ParseFindingsJSON(raw) if err != nil { return "" @@ -98,6 +107,16 @@ func excludeFindingsJSON(raw string, ids []string) string { if len(excluded.Items) == 0 { return "" } + if len(excluded.Items) != len(findings.Items) { + if !rebuildAttributedEvidence(&excluded) { + excluded.Tested = nil + excluded.TestingSummary = "" + excluded.Artifacts = nil + } + excluded.RiskLevel = "" + excluded.RiskRationale = "" + excluded.RiskScope = "" + } excludedRaw, err := types.MarshalFindingsJSON(excluded) if err != nil { return "" @@ -105,6 +124,639 @@ func excludeFindingsJSON(raw string, ids []string) string { return excludedRaw } +func prepareReviewSelectionTruth(raw string) (string, error) { + if raw == "" { + return "", nil + } + findings, err := types.ParseFindingsJSON(raw) + if err != nil { + return "", err + } + findings, err = types.EnsureFindingOccurrenceTokens(findings) + if err != nil { + return "", err + } + if findings.SharedEvidence == nil { + findings.SharedEvidence = residualSharedEvidence(findings) + } + rebuildAttributedEvidence(&findings) + return types.MarshalFindingsJSON(findings) +} + +// mergeCarriedFindingsJSON forms the effective gate truth for a scope-limited +// round. Already-shown findings keep their stable IDs and cannot have their +// action relaxed by a later restatement. New-ID collisions are reassigned +// before publication. +func mergeCarriedFindingsJSON(freshRaw, carriedRaw, prefix string) string { + if carriedRaw == "" { + return freshRaw + } + if freshRaw == "" { + return carriedRaw + } + fresh, err := types.ParseFindingsJSON(freshRaw) + if err != nil { + return carriedRaw + } + carried, err := types.ParseFindingsJSON(carriedRaw) + if err != nil { + return freshRaw + } + merged := fresh + merged.SharedEvidence = mergeFindingEvidence(sharedEvidenceOwner(fresh), sharedEvidenceOwner(carried)) + freshCounts := types.CountFindingFingerprints(fresh.Items) + carriedCounts := types.CountFindingFingerprints(carried.Items) + freshIdentityCounts := countFindingIdentities(fresh.Items) + carriedIdentityCounts := countFindingIdentities(carried.Items) + freshOccurrenceCounts := types.CountFindingOccurrences(fresh.Items) + carriedOccurrenceCounts := types.CountFindingOccurrences(carried.Items) + carriedIdentity := make(map[int]bool, len(carried.Items)) + carriedCount := 0 + for _, old := range carried.Items { + match := -1 + for i, current := range merged.Items { + identity := findingKey(current) + legacyMatch := (!current.HasLineage() || !old.HasLineage()) && ((identity == findingKey(old) && freshIdentityCounts[identity] == 1 && carriedIdentityCounts[identity] == 1) || + (findingFingerprint(current) == findingFingerprint(old) && freshCounts[findingFingerprint(current)] == 1 && carriedCounts[findingFingerprint(old)] == 1)) + occurrenceMatch := types.FindingOccurrenceCorroborates(current, old) && freshOccurrenceCounts[current.OccurrenceToken] == 1 && carriedOccurrenceCounts[old.OccurrenceToken] == 1 + if occurrenceMatch || types.FindingIDCorroborates(current, old) || legacyMatch { + match = i + break + } + } + if match >= 0 { + merged.Items[match].Evidence = mergeFindingEvidence(merged.Items[match].Evidence, old.Evidence) + merged.Items[match].ID = old.ID + merged.Items[match].IDGenerated = old.IDGenerated + merged.Items[match].ContinuityToken = old.ContinuityToken + merged.Items[match].Action = stricterFindingAction(old.Action, merged.Items[match].Action) + carriedIdentity[match] = true + carriedCount++ + continue + } + merged.Items = append(merged.Items, old) + carriedIdentity[len(merged.Items)-1] = true + carriedCount++ + } + + reserved := make(map[string]bool, len(merged.Items)) + for i, item := range merged.Items { + if carriedIdentity[i] && item.ID != "" { + reserved[item.ID] = true + } + } + nextID := 1 + for i := range merged.Items { + id := merged.Items[i].ID + if carriedIdentity[i] { + continue + } + if id != "" && !reserved[id] { + reserved[id] = true + continue + } + for { + candidate := fmt.Sprintf("%s-%d", prefix, nextID) + nextID++ + if !reserved[candidate] { + merged.Items[i].ID = candidate + merged.Items[i].IDGenerated = true + reserved[candidate] = true + break + } + } + } + + merged.Summary = fmt.Sprintf("%d outstanding %s", len(merged.Items), pluralize(len(merged.Items), "finding", "findings")) + if !rebuildAttributedEvidence(&merged) { + merged.Tested = mergeComparable(fresh.Tested, carried.Tested) + merged.Artifacts = mergeComparable(fresh.Artifacts, carried.Artifacts) + merged.TestingSummary = mergeEvidenceSummary(fresh.TestingSummary, carried.TestingSummary) + } + if carriedCount > 0 { + merged.RiskLevel, merged.RiskRationale, merged.RiskScope = effectiveFindingsRisk(merged.Items, fresh, carried, carriedCount) + } + encoded, err := types.MarshalFindingsJSON(merged) + if err != nil { + return carriedRaw + } + return encoded +} + +func mergeReappearedFindingsJSON(freshRaw, priorRaw string) string { + if freshRaw == "" || priorRaw == "" { + return freshRaw + } + fresh, err := types.ParseFindingsJSON(freshRaw) + if err != nil { + return freshRaw + } + prior, err := types.ParseFindingsJSON(priorRaw) + if err != nil { + return freshRaw + } + freshCounts := types.CountFindingFingerprints(fresh.Items) + priorCounts := types.CountFindingFingerprints(prior.Items) + freshIdentityCounts := countFindingIdentities(fresh.Items) + priorIdentityCounts := countFindingIdentities(prior.Items) + freshOccurrenceCounts := types.CountFindingOccurrences(fresh.Items) + matched := 0 + ambiguousPrior := make([]bool, len(prior.Items)) + matchedPrior := make([]bool, len(prior.Items)) + for i := range fresh.Items { + current := &fresh.Items[i] + lineageMatches := make([]int, 0, 1) + occurrenceMatches := make([]int, 0, 1) + structuralMatches := make([]int, 0, 1) + for j := range prior.Items { + old := prior.Items[j] + if current.PriorID != "" && current.PriorContinuityToken != "" && old.HasLineage() && + current.PriorID == old.ID && current.PriorContinuityToken == old.ContinuityToken { + ambiguousPrior[j] = true + } + if types.FindingIDCorroborates(*current, old) { + lineageMatches = append(lineageMatches, j) + continue + } + if types.FindingOccurrenceCorroborates(*current, old) { + occurrenceMatches = append(occurrenceMatches, j) + continue + } + if findingKey(*current) == findingKey(old) || findingFingerprint(*current) == findingFingerprint(old) { + structuralMatches = append(structuralMatches, j) + } + } + match := -1 + switch { + case len(occurrenceMatches) == 1 && freshOccurrenceCounts[current.OccurrenceToken] == 1: + match = occurrenceMatches[0] + case len(occurrenceMatches) > 1: + for _, j := range occurrenceMatches { + ambiguousPrior[j] = true + } + case len(lineageMatches) == 1: + match = lineageMatches[0] + case len(lineageMatches) == 0: + identity := findingKey(*current) + fingerprint := findingFingerprint(*current) + if len(structuralMatches) == 1 && ((identity == findingKey(prior.Items[structuralMatches[0]]) && freshIdentityCounts[identity] == 1 && priorIdentityCounts[identity] == 1) || + (fingerprint == findingFingerprint(prior.Items[structuralMatches[0]]) && freshCounts[fingerprint] == 1 && priorCounts[fingerprint] == 1)) { + match = structuralMatches[0] + } else { + for _, j := range structuralMatches { + ambiguousPrior[j] = true + } + } + default: + for _, j := range lineageMatches { + ambiguousPrior[j] = true + } + } + if match < 0 { + continue + } + old := prior.Items[match] + matchedPrior[match] = true + current.Evidence = mergeFindingEvidence(current.Evidence, old.Evidence) + current.ID = old.ID + current.IDGenerated = old.IDGenerated + current.ContinuityToken = old.ContinuityToken + current.Action = stricterFindingAction(old.Action, current.Action) + if severityRank(old.Severity) > severityRank(current.Severity) { + current.Severity = old.Severity + } + if current.UserInstructions == "" { + current.UserInstructions = old.UserInstructions + } + if current.ReviewScope == "" || (current.ReviewScope == types.FindingReviewScopePipelineOwnedDelivery && old.ReviewScope != "") { + current.ReviewScope = old.ReviewScope + } + if current.Category == "" { + current.Category = old.Category + } + if old.Source == types.FindingSourceUser { + current.Source = old.Source + } + matched++ + } + cleanedClaims := false + for i := range fresh.Items { + if fresh.Items[i].PriorID != "" || fresh.Items[i].PriorContinuityToken != "" { + cleanedClaims = true + fresh.Items[i].PriorID = "" + fresh.Items[i].PriorContinuityToken = "" + } + } + for j, ambiguous := range ambiguousPrior { + if ambiguous && !matchedPrior[j] { + fresh.Items = append(fresh.Items, prior.Items[j]) + matchedPrior[j] = true + matched++ + } + } + if matched == 0 { + if !cleanedClaims { + return freshRaw + } + encoded, err := types.MarshalFindingsJSON(fresh) + if err != nil { + return freshRaw + } + return encoded + } + fresh.SharedEvidence = mergeFindingEvidence(sharedEvidenceOwner(fresh), sharedEvidenceOwner(prior)) + fresh.Summary = fmt.Sprintf("%d outstanding %s", len(fresh.Items), pluralize(len(fresh.Items), "finding", "findings")) + allPriorSurvived := true + for _, survived := range matchedPrior { + if !survived { + allPriorSurvived = false + break + } + } + attributedEvidence := rebuildAttributedEvidence(&fresh) + if allPriorSurvived { + if !attributedEvidence { + fresh.Tested = mergeComparable(fresh.Tested, prior.Tested) + fresh.Artifacts = mergeComparable(fresh.Artifacts, prior.Artifacts) + fresh.TestingSummary = mergeEvidenceSummary(fresh.TestingSummary, prior.TestingSummary) + } + fresh.RiskLevel, fresh.RiskRationale, fresh.RiskScope = effectiveFindingsRisk(fresh.Items, fresh, prior, matched) + } else { + fresh.RiskLevel, fresh.RiskRationale, fresh.RiskScope = survivingFindingsRisk(fresh) + } + encoded, err := types.MarshalFindingsJSON(fresh) + if err != nil { + return freshRaw + } + return encoded +} + +func survivingFindingsRisk(findings types.Findings) (string, string, string) { + rank := 0 + if findings.RiskScope != types.FindingsRiskScopePipelineOwnedDelivery { + rank = riskRank(findings.RiskLevel) + } + retained := 0 + for _, item := range findings.Items { + if item.ReviewScope == types.FindingReviewScopePipelineOwnedDelivery { + continue + } + retained++ + if itemRank := severityRank(item.Severity); itemRank > rank { + rank = itemRank + } + } + if rank == 0 { + rank = riskRank("low") + } + rationale := fmt.Sprintf("Review risk recomputed from %d surviving %s.", retained, pluralize(retained, "finding", "findings")) + return riskLevel(rank), rationale, types.FindingsRiskScopeSourceOrExternal +} + +func ReconcileReviewFindings(findings types.Findings, priorRaw string) (types.Findings, int, error) { + raw, err := types.MarshalFindingsJSON(findings) + if err != nil { + return types.Findings{}, 0, err + } + normalized, err := normalizeFindingsJSON(raw, string(types.StepReview), priorRaw) + if err != nil { + return types.Findings{}, 0, err + } + merged := mergeReappearedFindingsJSON(normalized, priorRaw) + reconciled, err := types.ParseFindingsJSON(merged) + if err != nil { + return types.Findings{}, 0, err + } + filtered, dropped := FilterDeferredPipelineOwnedDeliveryFindings(reconciled) + return filtered, dropped, nil +} + +func FilterDeferredPipelineOwnedDeliveryFindings(findings types.Findings) (types.Findings, int) { + if len(findings.Items) == 0 { + return findings, 0 + } + kept := make([]types.Finding, 0, len(findings.Items)) + dropped := 0 + for _, item := range findings.Items { + if item.ReviewScope == types.FindingReviewScopePipelineOwnedDelivery { + dropped++ + continue + } + kept = append(kept, item) + } + if dropped == 0 { + return findings, 0 + } + out := findings + out.Items = kept + rebuildAttributedEvidence(&out) + switch len(kept) { + case 0: + out.Summary = "no review findings remain" + case 1: + out.Summary = "1 review finding remains" + default: + out.Summary = fmt.Sprintf("%d review findings remain", len(kept)) + } + if len(kept) == 0 { + out.RiskLevel = "low" + out.RiskRationale = "no delivery-independent review risk was reported" + out.RiskScope = types.FindingsRiskScopeSourceOrExternal + return out, dropped + } + rank := 0 + if findings.RiskScope != types.FindingsRiskScopePipelineOwnedDelivery { + rank = riskRank(findings.RiskLevel) + } + for _, item := range kept { + if item.ReviewScope == types.FindingReviewScopePipelineOwnedDelivery { + continue + } + if itemRank := severityRank(item.Severity); itemRank > rank { + rank = itemRank + } + } + if rank == 0 { + rank = riskRank("low") + } + out.RiskLevel = riskLevel(rank) + if findings.RiskScope != types.FindingsRiskScopePipelineOwnedDelivery && strings.TrimSpace(findings.RiskRationale) != "" { + out.RiskRationale = findings.RiskRationale + } else { + out.RiskRationale = "review risk recomputed after deferred delivery filtering" + } + out.RiskScope = types.FindingsRiskScopeSourceOrExternal + return out, dropped +} + +func normalizeUserFindingsJSON(raw, existingRaw string) (string, error) { + if raw == "" { + return raw, nil + } + findings, err := types.ParseFindingsJSON(raw) + if err != nil { + return "", err + } + var existing []types.Finding + if existingRaw != "" { + parsed, err := types.ParseFindingsJSON(existingRaw) + if err != nil { + return "", err + } + existing = parsed.Items + } + normalized, err := types.NormalizeUserFindings(findings, existing) + if err != nil { + return "", err + } + return types.MarshalFindingsJSON(normalized) +} + +func countFindingIdentities(items []types.Finding) map[types.FindingIdentity]int { + counts := make(map[types.FindingIdentity]int, len(items)) + for _, item := range items { + counts[item.Identity()]++ + } + return counts +} + +func mergeEvidenceSummary(fresh, carried string) string { + fresh = strings.TrimSpace(fresh) + carried = strings.TrimSpace(carried) + switch { + case fresh == "": + return carried + case carried == "", carried == fresh: + return fresh + default: + return fresh + "\n\n" + carried + } +} + +func mergeFindingEvidence(fresh, carried *types.FindingEvidence) *types.FindingEvidence { + if fresh == nil && carried == nil { + return nil + } + merged := &types.FindingEvidence{} + if fresh != nil { + merged.Tested = append(merged.Tested, fresh.Tested...) + merged.TestingSummary = fresh.TestingSummary + merged.Artifacts = append(merged.Artifacts, fresh.Artifacts...) + } + if carried != nil { + merged.Tested = mergeComparable(merged.Tested, carried.Tested) + merged.TestingSummary = mergeEvidenceSummary(merged.TestingSummary, carried.TestingSummary) + merged.Artifacts = mergeComparable(merged.Artifacts, carried.Artifacts) + } + return merged +} + +func rebuildAttributedEvidence(findings *types.Findings) bool { + attributed := false + var tested []string + var testingSummary string + var artifacts []types.TestArtifact + for _, item := range findings.Items { + if item.Evidence == nil { + continue + } + attributed = true + tested = mergeComparable(tested, item.Evidence.Tested) + testingSummary = mergeEvidenceSummary(testingSummary, item.Evidence.TestingSummary) + artifacts = mergeComparable(artifacts, item.Evidence.Artifacts) + } + if findings.SharedEvidence != nil { + attributed = true + tested = mergeComparable(tested, findings.SharedEvidence.Tested) + testingSummary = mergeEvidenceSummary(testingSummary, findings.SharedEvidence.TestingSummary) + artifacts = mergeComparable(artifacts, findings.SharedEvidence.Artifacts) + } + if attributed { + findings.Tested = tested + findings.TestingSummary = testingSummary + findings.Artifacts = artifacts + } + return attributed +} + +func residualSharedEvidence(findings types.Findings) *types.FindingEvidence { + owned := &types.FindingEvidence{} + for _, item := range findings.Items { + owned = mergeFindingEvidence(owned, item.Evidence) + } + shared := &types.FindingEvidence{ + Tested: subtractComparable(findings.Tested, owned.Tested), + TestingSummary: subtractEvidenceSummary(findings.TestingSummary, owned.TestingSummary), + Artifacts: subtractComparable(findings.Artifacts, owned.Artifacts), + } + if len(shared.Tested) == 0 && shared.TestingSummary == "" && len(shared.Artifacts) == 0 { + return &types.FindingEvidence{} + } + return shared +} + +func sharedEvidenceOwner(findings types.Findings) *types.FindingEvidence { + if findings.SharedEvidence != nil { + return findings.SharedEvidence + } + if len(findings.Tested) == 0 && findings.TestingSummary == "" && len(findings.Artifacts) == 0 { + return nil + } + return residualSharedEvidence(findings) +} + +func subtractComparable[T comparable](aggregate, owned []T) []T { + ownedSet := make(map[T]bool, len(owned)) + for _, value := range owned { + ownedSet[value] = true + } + remaining := make([]T, 0, len(aggregate)) + for _, value := range aggregate { + if !ownedSet[value] { + remaining = append(remaining, value) + } + } + return remaining +} + +func subtractEvidenceSummary(aggregate, owned string) string { + aggregate = strings.TrimSpace(aggregate) + owned = strings.TrimSpace(owned) + if aggregate == "" || aggregate == owned { + return "" + } + if owned == "" { + return aggregate + } + ownedBlocks := make(map[string]int) + for _, block := range strings.Split(owned, "\n\n") { + block = strings.TrimSpace(block) + if block != "" { + ownedBlocks[block]++ + } + } + var remaining []string + for _, block := range strings.Split(aggregate, "\n\n") { + block = strings.TrimSpace(block) + if block == "" { + continue + } + if ownedBlocks[block] > 0 { + ownedBlocks[block]-- + continue + } + remaining = append(remaining, block) + } + for _, count := range ownedBlocks { + if count > 0 { + return aggregate + } + } + return strings.Join(remaining, "\n\n") +} + +func effectiveFindingsRisk(items []types.Finding, fresh, carried types.Findings, carriedCount int) (string, string, string) { + rank := 0 + if fresh.RiskScope != types.FindingsRiskScopePipelineOwnedDelivery { + rank = riskRank(fresh.RiskLevel) + } + if carried.RiskScope != types.FindingsRiskScopePipelineOwnedDelivery { + carriedRank := riskRank(carried.RiskLevel) + if carriedRank > rank { + rank = carriedRank + } + } + excluded := 0 + for _, item := range items { + if item.ReviewScope == types.FindingReviewScopePipelineOwnedDelivery { + excluded++ + continue + } + if severityRank(item.Severity) > rank { + rank = severityRank(item.Severity) + } + } + if rank == 0 { + rank = riskRank("low") + } + rationale := fmt.Sprintf("Effective review contains %d unresolved %s, including %d carried from earlier review rounds.", len(items), pluralize(len(items), "finding", "findings"), carriedCount) + if excluded > 0 { + rationale += fmt.Sprintf(" %d pipeline-owned delivery %s excluded from source/external risk.", excluded, pluralize(excluded, "finding was", "findings were")) + } + return riskLevel(rank), rationale, types.FindingsRiskScopeSourceOrExternal +} + +func severityRank(severity string) int { + switch severity { + case "error": + return 3 + case "warning": + return 2 + case "info": + return 1 + default: + return 0 + } +} + +func riskRank(level string) int { + switch level { + case "high": + return 3 + case "medium": + return 2 + case "low": + return 1 + default: + return 0 + } +} + +func riskLevel(rank int) string { + switch rank { + case 3: + return "high" + case 2: + return "medium" + case 1: + return "low" + default: + return "" + } +} + +func mergeComparable[T comparable](fresh, carried []T) []T { + seen := make(map[T]bool, len(fresh)+len(carried)) + merged := make([]T, 0, len(fresh)+len(carried)) + for _, values := range [][]T{fresh, carried} { + for _, value := range values { + if !seen[value] { + seen[value] = true + merged = append(merged, value) + } + } + } + return merged +} + +func stricterFindingAction(carried, fresh string) string { + if findingActionRank(fresh) > findingActionRank(carried) { + return fresh + } + return carried +} + +func findingActionRank(action string) int { + switch action { + case types.ActionNoOp: + return 1 + case types.ActionAutoFix: + return 2 + default: + return 3 + } +} + func mergeFindingsJSON(existingRaw, additionalRaw string) string { if existingRaw == "" { return additionalRaw @@ -120,28 +772,33 @@ func mergeFindingsJSON(existingRaw, additionalRaw string) string { if err != nil { return existingRaw } - seen := make(map[types.Finding]bool, len(existing.Items)+len(additional.Items)) - existingCounts := countFindingFingerprints(existing.Items) - additionalCounts := countFindingFingerprints(additional.Items) - merged := types.Findings{Summary: existing.Summary, Tested: existing.Tested, TestingSummary: existing.TestingSummary, RiskLevel: existing.RiskLevel, RiskRationale: existing.RiskRationale, RiskScope: existing.RiskScope} + existingIDs := types.StableFindingIDs(existing.Items) + existingOccurrences := types.CountFindingOccurrences(existing.Items) + additionalOccurrences := types.CountFindingOccurrences(additional.Items) + existingCounts := types.CountFindingFingerprints(existing.Items) + additionalCounts := types.CountFindingFingerprints(additional.Items) + existingIdentityCounts := countFindingIdentities(existing.Items) + additionalIdentityCounts := countFindingIdentities(additional.Items) + merged := types.Findings{Summary: existing.Summary, Tested: existing.Tested, TestingSummary: existing.TestingSummary, Artifacts: existing.Artifacts, SharedEvidence: mergeFindingEvidence(sharedEvidenceOwner(existing), sharedEvidenceOwner(additional)), RiskLevel: existing.RiskLevel, RiskRationale: existing.RiskRationale, RiskScope: existing.RiskScope} for _, item := range existing.Items { merged.Items = append(merged.Items, item) - seen[findingKey(item)] = true } for _, item := range additional.Items { - if hasFindingMatch(item, seen, additionalCounts, existingCounts) { - continue - } - key := findingKey(item) - if seen[key] { + if hasFindingMatch(item, existingIDs, additionalOccurrences, existingOccurrences, additionalIdentityCounts, existingIdentityCounts, additionalCounts, existingCounts) { + for i := range merged.Items { + if types.FindingIDCorroborates(merged.Items[i], item) || types.FindingOccurrenceCorroborates(merged.Items[i], item) { + merged.Items[i].Evidence = mergeFindingEvidence(merged.Items[i].Evidence, item.Evidence) + break + } + } continue } merged.Items = append(merged.Items, item) - seen[key] = true } if len(merged.Items) == 0 { return "" } + rebuildAttributedEvidence(&merged) mergedRaw, err := types.MarshalFindingsJSON(merged) if err != nil { return existingRaw @@ -161,15 +818,16 @@ func removeMatchingFindingsJSON(existingRaw, removeRaw string) string { if err != nil { return existingRaw } - toRemove := make(map[types.Finding]bool, len(remove.Items)) - existingCounts := countFindingFingerprints(existing.Items) - removeCounts := countFindingFingerprints(remove.Items) - for _, item := range remove.Items { - toRemove[findingKey(item)] = true - } - filtered := types.Findings{Summary: existing.Summary, Tested: existing.Tested, TestingSummary: existing.TestingSummary, RiskLevel: existing.RiskLevel, RiskRationale: existing.RiskRationale, RiskScope: existing.RiskScope} + removeIDs := types.StableFindingIDs(remove.Items) + removeOccurrences := types.CountFindingOccurrences(remove.Items) + existingOccurrences := types.CountFindingOccurrences(existing.Items) + existingCounts := types.CountFindingFingerprints(existing.Items) + removeCounts := types.CountFindingFingerprints(remove.Items) + existingIdentityCounts := countFindingIdentities(existing.Items) + removeIdentityCounts := countFindingIdentities(remove.Items) + filtered := types.Findings{Summary: existing.Summary, Tested: existing.Tested, TestingSummary: existing.TestingSummary, Artifacts: existing.Artifacts, SharedEvidence: existing.SharedEvidence, RiskLevel: existing.RiskLevel, RiskRationale: existing.RiskRationale, RiskScope: existing.RiskScope} for _, item := range existing.Items { - if hasFindingMatch(item, toRemove, existingCounts, removeCounts) { + if hasFindingMatch(item, removeIDs, existingOccurrences, removeOccurrences, existingIdentityCounts, removeIdentityCounts, existingCounts, removeCounts) { continue } filtered.Items = append(filtered.Items, item) @@ -177,6 +835,11 @@ func removeMatchingFindingsJSON(existingRaw, removeRaw string) string { if len(filtered.Items) == 0 { return "" } + if len(filtered.Items) != len(existing.Items) && !rebuildAttributedEvidence(&filtered) { + filtered.Tested = nil + filtered.TestingSummary = "" + filtered.Artifacts = nil + } filteredRaw, err := types.MarshalFindingsJSON(filtered) if err != nil { return existingRaw @@ -196,15 +859,16 @@ func retainMatchingFindingsJSON(existingRaw, keepRaw string) string { if err != nil { return "" } - allowed := make(map[types.Finding]bool, len(keep.Items)) - existingCounts := countFindingFingerprints(existing.Items) - keepCounts := countFindingFingerprints(keep.Items) - for _, item := range keep.Items { - allowed[findingKey(item)] = true - } - filtered := types.Findings{Summary: existing.Summary, Tested: existing.Tested, TestingSummary: existing.TestingSummary, RiskLevel: existing.RiskLevel, RiskRationale: existing.RiskRationale, RiskScope: existing.RiskScope} + keepIDs := types.StableFindingIDs(keep.Items) + keepOccurrences := types.CountFindingOccurrences(keep.Items) + existingOccurrences := types.CountFindingOccurrences(existing.Items) + existingCounts := types.CountFindingFingerprints(existing.Items) + keepCounts := types.CountFindingFingerprints(keep.Items) + existingIdentityCounts := countFindingIdentities(existing.Items) + keepIdentityCounts := countFindingIdentities(keep.Items) + filtered := types.Findings{Summary: existing.Summary, Tested: existing.Tested, TestingSummary: existing.TestingSummary, Artifacts: existing.Artifacts, SharedEvidence: existing.SharedEvidence, RiskLevel: existing.RiskLevel, RiskRationale: existing.RiskRationale, RiskScope: existing.RiskScope} for _, item := range existing.Items { - if !hasFindingMatch(item, allowed, existingCounts, keepCounts) { + if !hasFindingMatch(item, keepIDs, existingOccurrences, keepOccurrences, existingIdentityCounts, keepIdentityCounts, existingCounts, keepCounts) { continue } filtered.Items = append(filtered.Items, item) @@ -212,6 +876,11 @@ func retainMatchingFindingsJSON(existingRaw, keepRaw string) string { if len(filtered.Items) == 0 { return "" } + if len(filtered.Items) != len(existing.Items) && !rebuildAttributedEvidence(&filtered) { + filtered.Tested = nil + filtered.TestingSummary = "" + filtered.Artifacts = nil + } filteredRaw, err := types.MarshalFindingsJSON(filtered) if err != nil { return "" @@ -231,6 +900,9 @@ func autoFixableFindingsJSON(raw string) string { if len(fixable.Items) == 0 { return "" } + if len(fixable.Items) != len(findings.Items) { + rebuildAttributedEvidence(&fixable) + } fixableRaw, err := types.MarshalFindingsJSON(fixable) if err != nil { return raw @@ -311,6 +983,18 @@ func mergeUserOverridesJSON(raw string, instructions map[string]string, added [] return encoded } +func prepareUserFixFindingsJSON(selected, known string, instructions map[string]string, added []types.Finding, registerLineages bool) (string, string, error) { + merged := mergeUserOverridesJSON(selected, instructions, added) + if !registerLineages { + return merged, "", nil + } + normalized, err := normalizeUserFindingsJSON(merged, known) + if err != nil { + return "", "", err + } + return normalized, mergeFindingsJSON(normalized, known), nil +} + func filterFindingsJSON(raw string, ids []string) string { if raw == "" { return raw @@ -320,11 +1004,16 @@ func filterFindingsJSON(raw string, ids []string) string { return raw } filtered := types.FilterFindings(findings, ids) + if len(filtered.Items) != len(findings.Items) { + rebuildAttributedEvidence(&filtered) + } if len(ids) == 0 { filtered = types.Findings{ Summary: "0 selected findings", Tested: findings.Tested, TestingSummary: findings.TestingSummary, + Artifacts: findings.Artifacts, + SharedEvidence: findings.SharedEvidence, RiskLevel: findings.RiskLevel, RiskRationale: findings.RiskRationale, RiskScope: findings.RiskScope, diff --git a/internal/pipeline/findings_test.go b/internal/pipeline/findings_test.go index 6184ecc..d2cfe67 100644 --- a/internal/pipeline/findings_test.go +++ b/internal/pipeline/findings_test.go @@ -1,28 +1,403 @@ package pipeline import ( + "strings" "testing" "github.com/Blakeolson21/no-slop/internal/types" ) -func TestMergeFindingsJSON_KeepsDistinctFindingsWithSameAutoID(t *testing.T) { - existingRaw := `{"findings":[{"id":"review-1","severity":"warning","description":"first"}],"summary":"1 finding"}` - additionalRaw := `{"findings":[{"id":"review-1","severity":"error","description":"second"}],"summary":"1 finding"}` +func TestMergeFindingsJSON_UsesPipelineLineageAcrossRewording(t *testing.T) { + existingRaw := `{"findings":[{"id":"review-1","id_generated":true,"continuity_token":"token-1","severity":"warning","description":"first"}],"summary":"1 finding"}` + additionalRaw := `{"findings":[{"id":"review-1","id_generated":true,"continuity_token":"token-1","severity":"error","description":"second"}],"summary":"1 finding"}` mergedRaw := mergeFindingsJSON(existingRaw, additionalRaw) merged, err := types.ParseFindingsJSON(mergedRaw) if err != nil { t.Fatalf("parse merged findings: %v", err) } - if len(merged.Items) != 2 { - t.Fatalf("expected 2 findings, got %d", len(merged.Items)) + if len(merged.Items) != 1 { + t.Fatalf("expected one lineage, got %d", len(merged.Items)) } - if merged.Items[0].Description != "first" || merged.Items[1].Description != "second" { + if merged.Items[0].Description != "first" { t.Fatalf("unexpected merged findings: %#v", merged.Items) } } +func TestMergeFindingsJSON_DistinctPipelineLineagesDoNotCollapse(t *testing.T) { + existingRaw := `{"findings":[{"id":"review-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","id_generated":true,"continuity_token":"token-a","severity":"warning","file":"auth.go","line":12,"description":"authentication token fails"}]}` + additionalRaw := `{"findings":[{"id":"review-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","id_generated":true,"continuity_token":"token-b","severity":"warning","file":"auth.go","line":12,"description":"authentication token fails"}]}` + + merged, err := types.ParseFindingsJSON(mergeFindingsJSON(existingRaw, additionalRaw)) + if err != nil { + t.Fatal(err) + } + if len(merged.Items) != 2 { + t.Fatalf("distinct pipeline lineages collapsed: %#v", merged.Items) + } + carried, err := types.ParseFindingsJSON(mergeCarriedFindingsJSON(additionalRaw, existingRaw, "review")) + if err != nil { + t.Fatal(err) + } + if len(carried.Items) != 2 { + t.Fatalf("distinct carried pipeline lineages collapsed: %#v", carried.Items) + } +} + +func TestMergeCarriedFindingsJSON_PreservesExplicitIDAcrossRephrasing(t *testing.T) { + carriedRaw := `{"findings":[{"id":"review-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","id_generated":true,"continuity_token":"token-a","severity":"warning","file":"loader.go","line":12,"description":"unsafe loader","action":"ask-user"}],"risk_level":"medium","risk_rationale":"Needs review."}` + freshRaw := `{"findings":[{"id":"review-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","id_generated":true,"continuity_token":"token-a","severity":"error","file":"manager.go","line":88,"description":"credentials are invalidated prematurely","action":"auto-fix"}],"risk_level":"high","risk_rationale":"Reproduced."}` + + mergedRaw := mergeCarriedFindingsJSON(freshRaw, carriedRaw, "review") + merged, err := types.ParseFindingsJSON(mergedRaw) + if err != nil { + t.Fatal(err) + } + if len(merged.Items) != 1 { + t.Fatalf("findings = %#v, want one stable defect", merged.Items) + } + if merged.Items[0].ID != "review-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" || merged.Items[0].Description != "credentials are invalidated prematurely" || merged.Items[0].Action != "ask-user" { + t.Fatalf("merged finding = %#v", merged.Items[0]) + } +} + +func TestMergeCarriedFindingsJSON_MatchedLineagePreservesEffectiveRisk(t *testing.T) { + carriedRaw := `{"findings":[{"id":"review-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","id_generated":true,"continuity_token":"token-a","severity":"error","file":"loader.go","line":12,"description":"unsafe loader","action":"ask-user","review_scope":"source"}],"testing_summary":"Reproduced data loss.","risk_level":"high","risk_rationale":"Data can be lost.","risk_scope":"source-or-external"}` + freshRaw := `{"findings":[{"id":"review-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","id_generated":true,"continuity_token":"token-a","severity":"warning","file":"manager.go","line":88,"description":"loader still races","action":"auto-fix","review_scope":"source"}],"testing_summary":"Narrow retest passed.","risk_level":"low","risk_rationale":"Narrow path is safe.","risk_scope":"source-or-external"}` + + merged, err := types.ParseFindingsJSON(mergeCarriedFindingsJSON(freshRaw, carriedRaw, "review")) + if err != nil { + t.Fatal(err) + } + if len(merged.Items) != 1 { + t.Fatalf("findings = %#v, want one continued lineage", merged.Items) + } + if merged.Items[0].Action != "ask-user" || merged.RiskLevel != "high" { + t.Fatalf("effective finding = %#v, risk = %q", merged.Items[0], merged.RiskLevel) + } + if !strings.Contains(merged.TestingSummary, "Reproduced data loss") || !strings.Contains(merged.TestingSummary, "Narrow retest passed") { + t.Fatalf("testing summary = %q", merged.TestingSummary) + } +} + +func TestMergeReappearedFindingsJSONPreservesSelectedLineageSemanticsOnly(t *testing.T) { + priorRaw := `{"findings":[{"id":"review-a","id_generated":true,"continuity_token":"token-a","severity":"error","file":"loader.go","line":12,"description":"unsafe loader","action":"ask-user","review_scope":"source"},{"id":"review-b","id_generated":true,"continuity_token":"token-b","severity":"warning","description":"narrow omitted issue","action":"ask-user","review_scope":"source"}],"tested":["reproduced data loss"],"testing_summary":"Full reproduction failed.","risk_level":"high","risk_rationale":"Data can be lost.","risk_scope":"source-or-external"}` + freshRaw := `{"findings":[{"id":"review-a","id_generated":true,"continuity_token":"token-a","severity":"info","file":"loader.go","line":12,"description":"unsafe loader","action":"no-op","review_scope":"source"}],"tested":["narrow retest"],"testing_summary":"Narrow path passed.","risk_level":"low","risk_rationale":"Narrow path is safe.","risk_scope":"source-or-external"}` + + merged, err := types.ParseFindingsJSON(mergeReappearedFindingsJSON(freshRaw, priorRaw)) + if err != nil { + t.Fatal(err) + } + if len(merged.Items) != 1 || merged.Items[0].ID != "review-a" { + t.Fatalf("reappeared findings = %#v, want only selected lineage A", merged.Items) + } + if merged.Items[0].Action != types.ActionAskUser || merged.Items[0].Severity != "error" || merged.RiskLevel != "high" { + t.Fatalf("reappeared semantics = %#v, risk %q", merged.Items[0], merged.RiskLevel) + } + if !strings.Contains(merged.TestingSummary, "Full reproduction failed") || !strings.Contains(merged.TestingSummary, "Narrow path passed") || len(merged.Tested) != 2 { + t.Fatalf("reappeared evidence = tested %#v, summary %q", merged.Tested, merged.TestingSummary) + } +} + +func TestMergeReappearedFindingsJSONDropsClearedLineageAggregateEvidence(t *testing.T) { + priorRaw := `{"findings":[{"id":"review-a","id_generated":true,"continuity_token":"token-a","severity":"warning","description":"surviving defect","action":"ask-user","review_scope":"source"},{"id":"review-b","id_generated":true,"continuity_token":"token-b","severity":"error","description":"cleared defect","action":"ask-user","review_scope":"source","evidence":{"tested":["reproduced cleared defect"],"testing_summary":"Cleared defect corrupts data.","artifacts":[{"kind":"log","label":"cleared-defect.log"}]}}],"tested":["reproduced cleared defect"],"testing_summary":"Cleared defect corrupts data.","artifacts":[{"kind":"log","label":"cleared-defect.log"}],"risk_level":"high","risk_rationale":"Cleared defect can corrupt data.","risk_scope":"source-or-external"}` + freshRaw := `{"findings":[{"id":"review-a","id_generated":true,"continuity_token":"token-a","severity":"info","description":"surviving defect","action":"no-op","review_scope":"source"}],"tested":["retested surviving defect"],"testing_summary":"Surviving defect remains bounded.","risk_level":"low","risk_rationale":"Current review is bounded.","risk_scope":"source-or-external"}` + + merged, err := types.ParseFindingsJSON(mergeReappearedFindingsJSON(freshRaw, priorRaw)) + if err != nil { + t.Fatal(err) + } + if len(merged.Items) != 1 || merged.Items[0].ID != "review-a" || merged.Items[0].Action != types.ActionAskUser || merged.Items[0].Severity != "warning" { + t.Fatalf("surviving lineage semantics = %#v", merged.Items) + } + if len(merged.Tested) != 1 || merged.Tested[0] != "retested surviving defect" || merged.TestingSummary != "Surviving defect remains bounded." || len(merged.Artifacts) != 0 { + t.Fatalf("cleared lineage evidence survived: tested=%#v summary=%q artifacts=%#v", merged.Tested, merged.TestingSummary, merged.Artifacts) + } + if merged.RiskLevel != "medium" || merged.RiskScope != types.FindingsRiskScopeSourceOrExternal || strings.Contains(merged.RiskRationale, "Cleared defect") { + t.Fatalf("recomputed risk = %q/%q %q", merged.RiskLevel, merged.RiskScope, merged.RiskRationale) + } +} + +func TestMergeReappearedFindingsJSONCorroboratesUniqueGeneratedStructure(t *testing.T) { + priorRaw := `{"findings":[{"id":"review-a","id_generated":true,"continuity_token":"token-a","severity":"error","file":"loader.go","line":12,"description":"unsafe loader","action":"ask-user","review_scope":"source"}],"risk_level":"high","risk_rationale":"Data can be lost.","risk_scope":"source-or-external"}` + freshRaw := `{"findings":[{"id":"review-c","id_generated":true,"continuity_token":"token-c","severity":"info","file":"loader.go","line":12,"description":"unsafe loader","action":"no-op","review_scope":"source"}],"risk_level":"low","risk_rationale":"Narrow path is safe.","risk_scope":"source-or-external"}` + + merged, err := types.ParseFindingsJSON(mergeReappearedFindingsJSON(freshRaw, priorRaw)) + if err != nil { + t.Fatal(err) + } + if len(merged.Items) != 1 || merged.Items[0].ID != "review-a" || merged.Items[0].ContinuityToken != "token-a" { + t.Fatalf("unique structural continuation = %#v", merged.Items) + } + if merged.Items[0].Action != types.ActionAskUser || merged.Items[0].Severity != "error" || merged.RiskLevel != "high" { + t.Fatalf("continued semantics = %#v, risk %q", merged.Items[0], merged.RiskLevel) + } +} + +func TestMergeReappearedFindingsJSONPreservesAmbiguousGeneratedLineages(t *testing.T) { + priorRaw := `{"findings":[{"id":"review-a","id_generated":true,"continuity_token":"token-a","severity":"error","file":"loader.go","line":12,"description":"unsafe loader","action":"ask-user"},{"id":"review-b","id_generated":true,"continuity_token":"token-b","severity":"warning","file":"loader.go","line":12,"description":"unsafe loader","action":"ask-user"}]}` + freshRaw := `{"findings":[{"id":"review-c","id_generated":true,"continuity_token":"token-c","severity":"info","file":"loader.go","line":12,"description":"unsafe loader","action":"no-op"}]}` + + merged, err := types.ParseFindingsJSON(mergeReappearedFindingsJSON(freshRaw, priorRaw)) + if err != nil { + t.Fatal(err) + } + if len(merged.Items) != 3 { + t.Fatalf("ambiguous structural continuation dropped lineages: %#v", merged.Items) + } + ids := map[string]bool{} + for _, item := range merged.Items { + ids[item.ID] = true + } + if !ids["review-a"] || !ids["review-b"] || !ids["review-c"] { + t.Fatalf("ambiguous structural identities = %#v", merged.Items) + } +} + +func TestRetainMatchingFindingsJSONRejectsAmbiguousExactLegacyMatch(t *testing.T) { + existing := `{"findings":[{"id":"legacy-a","severity":"warning","file":"loader.go","line":12,"description":"unsafe loader","action":"auto-fix"},{"id":"legacy-b","severity":"warning","file":"loader.go","line":12,"description":"unsafe loader","action":"auto-fix"}]}` + keep := `{"findings":[{"id":"legacy-c","severity":"warning","file":"loader.go","line":12,"description":"unsafe loader","action":"auto-fix"}]}` + + if retained := retainMatchingFindingsJSON(existing, keep); retained != "" { + t.Fatalf("ambiguous exact match retained findings: %s", retained) + } +} + +func TestOccurrenceTokensPreventComposedAmbiguousCarryMultiplication(t *testing.T) { + prior := `{"findings":[{"id":"legacy-a","severity":"warning","file":"loader.go","line":12,"description":"unsafe loader","action":"ask-user"},{"id":"legacy-b","severity":"warning","file":"loader.go","line":12,"description":"unsafe loader","action":"ask-user"}]}` + prior, err := prepareReviewSelectionTruth(prior) + if err != nil { + t.Fatal(err) + } + fresh := `{"findings":[{"id":"legacy-c","severity":"warning","file":"loader.go","line":12,"description":"unsafe loader","action":"ask-user"}]}` + reappeared := mergeReappearedFindingsJSON(fresh, prior) + merged, err := types.ParseFindingsJSON(mergeCarriedFindingsJSON(reappeared, prior, "review")) + if err != nil { + t.Fatal(err) + } + if len(merged.Items) != 3 { + t.Fatalf("composed ambiguous findings = %#v, want three occurrences", merged.Items) + } + ids := map[string]int{} + for _, item := range merged.Items { + ids[item.ID]++ + } + if ids["legacy-a"] != 1 || ids["legacy-b"] != 1 || ids["legacy-c"] != 1 { + t.Fatalf("occurrence multiplicity = %#v", ids) + } +} + +func TestReconcileReviewFindingsPreservesRejectedSelectedClaim(t *testing.T) { + prior, err := types.NormalizeFindings(types.Findings{Items: []types.Finding{{ + Severity: "error", + File: "loader.go", + Line: 42, + Description: "nil dereference remains reachable", + Action: types.ActionAskUser, + ReviewScope: types.FindingReviewScopeSource, + }}}, "review", nil) + if err != nil { + t.Fatal(err) + } + priorRaw, err := types.MarshalFindingsJSON(prior) + if err != nil { + t.Fatal(err) + } + fresh := types.Findings{Items: []types.Finding{{ + PriorID: prior.Items[0].ID, + PriorContinuityToken: prior.Items[0].ContinuityToken, + Severity: "warning", + File: "loader.go", + Line: 42, + Description: "SQL injection remains reachable", + Action: types.ActionAutoFix, + ReviewScope: types.FindingReviewScopeSource, + }}} + + reconciled, _, err := ReconcileReviewFindings(fresh, priorRaw) + if err != nil { + t.Fatal(err) + } + if len(reconciled.Items) != 2 { + t.Fatalf("ambiguous selected claim dropped a finding: %#v", reconciled.Items) + } + byDescription := make(map[string]types.Finding, len(reconciled.Items)) + for _, item := range reconciled.Items { + byDescription[item.Description] = item + } + if byDescription["nil dereference remains reachable"].ID != prior.Items[0].ID || byDescription["nil dereference remains reachable"].Action != types.ActionAskUser { + t.Fatalf("prior selected lineage changed: %#v", reconciled.Items) + } + if byDescription["SQL injection remains reachable"].ID == prior.Items[0].ID { + t.Fatalf("unrelated finding inherited selected lineage: %#v", reconciled.Items) + } +} + +func TestFilterDeferredPipelineOwnedDeliveryRecomputesRetainedSourceRisk(t *testing.T) { + filtered, dropped := FilterDeferredPipelineOwnedDeliveryFindings(types.Findings{ + Items: []types.Finding{ + {Severity: "error", Description: "source corruption remains", ReviewScope: types.FindingReviewScopeSource}, + {Severity: "error", Description: "PR publication is pending", ReviewScope: types.FindingReviewScopePipelineOwnedDelivery}, + }, + RiskLevel: "low", + RiskScope: types.FindingsRiskScopePipelineOwnedDelivery, + }) + if dropped != 1 || len(filtered.Items) != 1 { + t.Fatalf("filtered findings = %#v, dropped = %d", filtered.Items, dropped) + } + if filtered.RiskLevel != "high" || filtered.RiskScope != types.FindingsRiskScopeSourceOrExternal { + t.Fatalf("retained source risk = %q/%q", filtered.RiskLevel, filtered.RiskScope) + } +} + +func TestReconcileReviewFindingsRestoresUserLineageSemanticsBeforeFiltering(t *testing.T) { + prior := types.Findings{Items: []types.Finding{{ + ID: "user-1", + IDGenerated: true, + ContinuityToken: "00112233445566778899aabbccddeeff", + Severity: "error", + File: "loader.go", + Description: "operator-added defect", + Action: types.ActionAskUser, + Source: types.FindingSourceUser, + UserInstructions: "preserve the compatibility path", + ReviewScope: types.FindingReviewScopeSource, + }}} + priorRaw, err := types.MarshalFindingsJSON(prior) + if err != nil { + t.Fatal(err) + } + fresh := types.Findings{Items: []types.Finding{{ + PriorID: "user-1", + PriorContinuityToken: "00112233445566778899aabbccddeeff", + Severity: "warning", + File: "loader.go", + Description: "operator-added defect", + Action: types.ActionNoOp, + ReviewScope: types.FindingReviewScopePipelineOwnedDelivery, + }}} + + reconciled, dropped, err := ReconcileReviewFindings(fresh, priorRaw) + if err != nil { + t.Fatal(err) + } + if dropped != 0 || len(reconciled.Items) != 1 { + t.Fatalf("reconciled findings = %#v, dropped = %d", reconciled.Items, dropped) + } + item := reconciled.Items[0] + if item.ID != "user-1" || item.ContinuityToken != prior.Items[0].ContinuityToken || item.Source != types.FindingSourceUser { + t.Fatalf("user lineage changed: %#v", item) + } + if item.Action != types.ActionAskUser || item.Severity != "error" || item.UserInstructions != prior.Items[0].UserInstructions || item.ReviewScope != types.FindingReviewScopeSource { + t.Fatalf("user lineage semantics changed: %#v", item) + } +} + +func TestMergeCarriedFindingsJSON_ExcludesPipelineDeliveryFromEffectiveRisk(t *testing.T) { + carriedRaw := `{"findings":[{"id":"review-delivery","severity":"error","description":"PR not pushed","action":"ask-user","review_scope":"pipeline-owned-delivery"}],"risk_level":"high","risk_rationale":"PR is absent.","risk_scope":"pipeline-owned-delivery"}` + freshRaw := `{"findings":[{"id":"review-source","severity":"info","description":"bounded source concern","action":"ask-user","review_scope":"source"}],"risk_level":"low","risk_rationale":"Source change is bounded.","risk_scope":"source-or-external"}` + + merged, err := types.ParseFindingsJSON(mergeCarriedFindingsJSON(freshRaw, carriedRaw, "review")) + if err != nil { + t.Fatal(err) + } + if merged.RiskLevel != "low" || merged.RiskScope != types.FindingsRiskScopeSourceOrExternal { + t.Fatalf("effective source risk = %q/%q, want low/source-or-external", merged.RiskLevel, merged.RiskScope) + } + if !strings.Contains(merged.RiskRationale, "excluded from source/external risk") { + t.Fatalf("risk rationale = %q", merged.RiskRationale) + } +} + +func TestMergeCarriedFindingsJSON_DoesNotTrustUncorroboratedExplicitID(t *testing.T) { + carriedRaw := `{"findings":[{"id":"review-1","severity":"warning","file":"loader.go","line":12,"description":"unsafe loader","action":"ask-user"}]}` + freshRaw := `{"findings":[{"id":"review-1","severity":"error","file":"loader.go","line":12,"description":"cache write can deadlock","action":"auto-fix"}]}` + + mergedRaw := mergeCarriedFindingsJSON(freshRaw, carriedRaw, "review") + merged, err := types.ParseFindingsJSON(mergedRaw) + if err != nil { + t.Fatal(err) + } + if len(merged.Items) != 2 { + t.Fatalf("findings = %#v, want both unrelated defects", merged.Items) + } + byDescription := make(map[string]types.Finding, len(merged.Items)) + for _, item := range merged.Items { + byDescription[item.Description] = item + } + if byDescription["unsafe loader"].Action != "ask-user" || byDescription["cache write can deadlock"].Action != "auto-fix" { + t.Fatalf("explicit ID collision changed findings: %#v", merged.Items) + } + if merged.Items[0].ID == merged.Items[1].ID { + t.Fatalf("explicit ID collision survived merge: %#v", merged.Items) + } +} + +func TestMergeCarriedFindingsJSON_PreservesPriorWhenClaimIsUnrelated(t *testing.T) { + prior, err := types.NormalizeFindings(types.Findings{Items: []types.Finding{{ + File: "loader.go", + Line: 42, + Description: "unsafe loader", + Action: types.ActionAskUser, + }}}, "review", nil) + if err != nil { + t.Fatal(err) + } + fresh, err := types.NormalizeFindings(types.Findings{Items: []types.Finding{{ + PriorID: prior.Items[0].ID, + PriorContinuityToken: prior.Items[0].ContinuityToken, + File: "loader.go", + Line: 42, + Description: "authentication token leaks in logs", + Action: types.ActionAutoFix, + }}}, "review", prior.Items) + if err != nil { + t.Fatal(err) + } + priorRaw, err := types.MarshalFindingsJSON(prior) + if err != nil { + t.Fatal(err) + } + freshRaw, err := types.MarshalFindingsJSON(fresh) + if err != nil { + t.Fatal(err) + } + merged, err := types.ParseFindingsJSON(mergeCarriedFindingsJSON(freshRaw, priorRaw, "review")) + if err != nil { + t.Fatal(err) + } + if len(merged.Items) != 2 { + t.Fatalf("unrelated claim replaced carried finding: %#v", merged.Items) + } + byDescription := make(map[string]types.Finding, len(merged.Items)) + for _, item := range merged.Items { + byDescription[item.Description] = item + } + if byDescription["unsafe loader"].ID != prior.Items[0].ID || byDescription["unsafe loader"].Action != types.ActionAskUser { + t.Fatalf("prior finding changed: %#v", merged.Items) + } + if byDescription["authentication token leaks in logs"].ID == prior.Items[0].ID { + t.Fatalf("new finding inherited prior lineage: %#v", merged.Items) + } +} + +func TestMergeCarriedFindingsJSON_DoesNotTrustReviewerIDCollision(t *testing.T) { + carriedRaw := `{"findings":[{"id":"review-1","severity":"warning","description":"first defect","action":"ask-user"}]}` + freshRaw := `{"findings":[{"id":"review-1","severity":"error","description":"second defect","action":"auto-fix"}]}` + + mergedRaw := mergeCarriedFindingsJSON(freshRaw, carriedRaw, "review") + merged, err := types.ParseFindingsJSON(mergedRaw) + if err != nil { + t.Fatal(err) + } + if len(merged.Items) != 2 { + t.Fatalf("findings = %#v, want two distinct defects", merged.Items) + } + if merged.Items[0].ID == merged.Items[1].ID { + t.Fatalf("generated ID collision survived merge: %#v", merged.Items) + } +} + func TestRetainMatchingFindingsJSON_DropsFindingsMissingFromLatestReview(t *testing.T) { existingRaw := `{"findings":[{"id":"review-1","severity":"warning","description":"first"},{"id":"review-2","severity":"error","description":"second"}],"summary":"2 findings"}` keepRaw := `{"findings":[{"id":"review-7","severity":"error","description":"second"},{"id":"review-8","severity":"warning","description":"third"}],"summary":"2 findings"}` @@ -131,6 +506,264 @@ func TestMergeFindingsJSON_DeduplicatesShiftedUniqueDismissedFinding(t *testing. } } +func TestMergeCarriedFindingsJSON_PreservesIdentityAcrossReclassification(t *testing.T) { + carriedRaw := `{"findings":[{"id":"review-1","severity":"warning","file":"loader.go","line":12,"description":"unsafe loader","action":"ask-user","review_scope":"source","category":"documentation"}],"risk_level":"medium","risk_rationale":"Needs review."}` + freshRaw := `{"findings":[{"id":"review-9","severity":"error","file":"loader.go","line":12,"description":"unsafe loader","action":"no-op","review_scope":"external-delivery","category":"lint"}],"risk_level":"high","risk_rationale":"Reclassified."}` + + mergedRaw := mergeCarriedFindingsJSON(freshRaw, carriedRaw, "review") + merged, err := types.ParseFindingsJSON(mergedRaw) + if err != nil { + t.Fatal(err) + } + if len(merged.Items) != 1 { + t.Fatalf("findings = %#v, want one stable defect", merged.Items) + } + if merged.Items[0].ID != "review-1" || merged.Items[0].Severity != "error" || merged.Items[0].Action != "ask-user" { + t.Fatalf("merged finding = %#v", merged.Items[0]) + } +} + +func TestMergeCarriedFindingsJSON_PreservesAmbiguousLegacyLineages(t *testing.T) { + carriedRaw := `{"findings":[{"id":"review-old-a","severity":"error","file":"loader.go","line":42,"description":"unsafe loader","action":"ask-user"},{"id":"review-old-b","severity":"error","file":"loader.go","line":42,"description":"unsafe loader","action":"ask-user"}]}` + freshRaw := `{"findings":[{"id":"review-fresh","severity":"error","file":"loader.go","line":42,"description":"unsafe loader","action":"auto-fix"}]}` + + merged, err := types.ParseFindingsJSON(mergeCarriedFindingsJSON(freshRaw, carriedRaw, "review")) + if err != nil { + t.Fatal(err) + } + if len(merged.Items) != 3 { + t.Fatalf("ambiguous legacy lineages collapsed: %#v", merged.Items) + } + ids := make(map[string]bool, len(merged.Items)) + for _, item := range merged.Items { + if ids[item.ID] { + t.Fatalf("ambiguous lineages share ID %q: %#v", item.ID, merged.Items) + } + ids[item.ID] = true + } + if !ids["review-old-a"] || !ids["review-old-b"] { + t.Fatalf("carried identities changed: %#v", merged.Items) + } +} + +func TestMergeCarriedFindingsJSON_RecomputesEffectiveRiskAndPreservesEvidence(t *testing.T) { + carriedRaw := `{"findings":[{"id":"review-2","severity":"error","description":"remaining concern","action":"ask-user","review_scope":"source"}],"testing_summary":"Reproduced the remaining race under load.","risk_level":"high","risk_rationale":"Selected finding can corrupt data.","risk_scope":"source-or-external"}` + freshRaw := `{"findings":[],"testing_summary":"Verified the selected defect is fixed.","risk_level":"low","risk_rationale":"The selected defect is fixed.","risk_scope":"source-or-external"}` + + mergedRaw := mergeCarriedFindingsJSON(freshRaw, carriedRaw, "review") + merged, err := types.ParseFindingsJSON(mergedRaw) + if err != nil { + t.Fatal(err) + } + if merged.RiskLevel != "high" || strings.Contains(merged.RiskRationale, "Selected finding") || strings.Contains(merged.RiskRationale, "selected defect") { + t.Fatalf("aggregate risk = %q %q", merged.RiskLevel, merged.RiskRationale) + } + if !strings.Contains(merged.TestingSummary, "Reproduced the remaining race") || !strings.Contains(merged.TestingSummary, "Verified the selected defect") { + t.Fatalf("testing summary = %q", merged.TestingSummary) + } +} + +func TestExcludeFindingsJSON_DropsAggregateRiskForSubset(t *testing.T) { + raw := `{"findings":[{"id":"review-1","severity":"error","description":"selected defect"},{"id":"review-2","severity":"warning","description":"remaining concern"}],"risk_level":"high","risk_rationale":"Selected defect can corrupt data.","risk_scope":"source-or-external"}` + + excludedRaw := excludeFindingsJSON(raw, []string{"review-1"}) + excluded, err := types.ParseFindingsJSON(excludedRaw) + if err != nil { + t.Fatal(err) + } + if len(excluded.Items) != 1 || excluded.Items[0].ID != "review-2" { + t.Fatalf("remaining findings = %#v", excluded.Items) + } + if excluded.RiskLevel != "" || excluded.RiskRationale != "" || excluded.RiskScope != "" { + t.Fatalf("subset retained aggregate risk: %#v", excluded) + } +} + +func TestReviewSelectionAttributesSharedLegacyEvidenceToSurvivors(t *testing.T) { + raw := `{"findings":[{"id":"legacy-a","severity":"error","description":"selected defect","action":"auto-fix"},{"id":"legacy-b","severity":"warning","description":"remaining defect","action":"ask-user"}],"tested":["reproduce shared failure"],"testing_summary":"Shared reproduction remains relevant.","artifacts":[{"kind":"log","label":"shared trace"}]}` + attributed, err := prepareReviewSelectionTruth(raw) + if err != nil { + t.Fatal(err) + } + remaining, err := types.ParseFindingsJSON(excludeFindingsJSON(attributed, []string{"legacy-a"})) + if err != nil { + t.Fatal(err) + } + if len(remaining.Items) != 1 || remaining.Items[0].ID != "legacy-b" { + t.Fatalf("remaining finding = %#v", remaining.Items) + } + if remaining.Items[0].Evidence != nil || remaining.SharedEvidence == nil { + t.Fatalf("shared evidence ownership = item %#v shared %#v", remaining.Items[0].Evidence, remaining.SharedEvidence) + } + if len(remaining.Tested) != 1 || remaining.Tested[0] != "reproduce shared failure" || remaining.TestingSummary != "Shared reproduction remains relevant." { + t.Fatalf("remaining evidence = tested %#v summary %q", remaining.Tested, remaining.TestingSummary) + } + if len(remaining.Artifacts) != 1 || remaining.Artifacts[0].Label != "shared trace" { + t.Fatalf("remaining artifacts = %#v", remaining.Artifacts) + } +} + +func TestReviewSelectionDoesNotTransferItemEvidenceToLegacySurvivor(t *testing.T) { + raw := `{"findings":[{"id":"legacy-a","severity":"error","description":"selected defect","action":"auto-fix","evidence":{"tested":["reproduce A"],"testing_summary":"A-only reproduction.","artifacts":[{"kind":"log","label":"A trace"}]}},{"id":"legacy-b","severity":"warning","description":"remaining defect","action":"ask-user"}],"tested":["reproduce A"],"testing_summary":"A-only reproduction.","artifacts":[{"kind":"log","label":"A trace"}]}` + attributed, err := prepareReviewSelectionTruth(raw) + if err != nil { + t.Fatal(err) + } + remainingRaw := excludeFindingsJSON(attributed, []string{"legacy-a"}) + remaining, err := types.ParseFindingsJSON(remainingRaw) + if err != nil { + t.Fatal(err) + } + if len(remaining.Items) != 1 || remaining.Items[0].ID != "legacy-b" { + t.Fatalf("remaining findings = %#v", remaining.Items) + } + if remaining.Items[0].Evidence != nil || remaining.SharedEvidence == nil { + t.Fatalf("evidence ownership = item %#v shared %#v", remaining.Items[0].Evidence, remaining.SharedEvidence) + } + if len(remaining.Tested) != 0 || remaining.TestingSummary != "" || len(remaining.Artifacts) != 0 { + t.Fatalf("A-only aggregate survived: %#v", remaining) + } +} + +func TestReviewSelectionPersistsOnlyUnownedSharedEvidence(t *testing.T) { + raw := `{"findings":[{"id":"legacy-a","severity":"error","description":"selected defect","action":"auto-fix","evidence":{"tested":["reproduce A"],"testing_summary":"A-only reproduction.","artifacts":[{"kind":"log","label":"A trace"}]}},{"id":"legacy-b","severity":"warning","description":"remaining defect","action":"ask-user"}],"tested":["reproduce A","exercise shared path"],"testing_summary":"A-only reproduction.\n\nShared environment details.","artifacts":[{"kind":"log","label":"A trace"},{"kind":"log","label":"shared trace"}]}` + attributed, err := prepareReviewSelectionTruth(raw) + if err != nil { + t.Fatal(err) + } + roundTripped, err := types.ParseFindingsJSON(attributed) + if err != nil { + t.Fatal(err) + } + if roundTripped.SharedEvidence == nil || len(roundTripped.SharedEvidence.Tested) != 1 || roundTripped.SharedEvidence.Tested[0] != "exercise shared path" || roundTripped.SharedEvidence.TestingSummary != "Shared environment details." { + t.Fatalf("persisted shared evidence = %#v", roundTripped.SharedEvidence) + } + if len(roundTripped.SharedEvidence.Artifacts) != 1 || roundTripped.SharedEvidence.Artifacts[0].Label != "shared trace" { + t.Fatalf("persisted shared artifacts = %#v", roundTripped.SharedEvidence.Artifacts) + } + + remaining, err := types.ParseFindingsJSON(excludeFindingsJSON(attributed, []string{"legacy-a"})) + if err != nil { + t.Fatal(err) + } + if len(remaining.Tested) != 1 || remaining.Tested[0] != "exercise shared path" || remaining.TestingSummary != "Shared environment details." { + t.Fatalf("surviving aggregate evidence = tested %#v summary %q", remaining.Tested, remaining.TestingSummary) + } + if len(remaining.Artifacts) != 1 || remaining.Artifacts[0].Label != "shared trace" { + t.Fatalf("surviving aggregate artifacts = %#v", remaining.Artifacts) + } +} + +func TestReviewSelectionRetainsAmbiguousAggregateSummary(t *testing.T) { + raw := `{"findings":[{"id":"legacy-a","severity":"error","description":"selected defect","action":"auto-fix","evidence":{"testing_summary":"A reproduced"}},{"id":"legacy-b","severity":"warning","description":"remaining defect","action":"ask-user"}],"testing_summary":"A and B reproduced"}` + attributed, err := prepareReviewSelectionTruth(raw) + if err != nil { + t.Fatal(err) + } + remaining, err := types.ParseFindingsJSON(excludeFindingsJSON(attributed, []string{"legacy-a"})) + if err != nil { + t.Fatal(err) + } + if len(remaining.Items) != 1 || remaining.Items[0].ID != "legacy-b" { + t.Fatalf("remaining findings = %#v", remaining.Items) + } + if remaining.SharedEvidence == nil || remaining.SharedEvidence.TestingSummary != "A and B reproduced" || remaining.TestingSummary != "A and B reproduced" { + t.Fatalf("ambiguous shared summary was lost: %#v", remaining) + } +} + +func TestReviewEvidenceFollowsSurvivingLineagesAcrossSelection(t *testing.T) { + prior := types.Findings{ + Items: []types.Finding{ + { + ID: "review-a", + IDGenerated: true, + ContinuityToken: "token-a", + Severity: "error", + File: "a.go", + Line: 10, + Description: "defect A", + Action: types.ActionAutoFix, + ReviewScope: types.FindingReviewScopeSource, + Evidence: &types.FindingEvidence{ + Tested: []string{"reproduce A"}, + TestingSummary: "A remains reproducible.", + Artifacts: []types.TestArtifact{{Kind: "log", Label: "A trace", Content: "A"}}, + }, + }, + { + ID: "review-b", + IDGenerated: true, + ContinuityToken: "token-b", + Severity: "error", + File: "b.go", + Line: 20, + Description: "defect B", + Action: types.ActionAutoFix, + ReviewScope: types.FindingReviewScopeSource, + Evidence: &types.FindingEvidence{ + Tested: []string{"reproduce B"}, + TestingSummary: "B remains reproducible.", + Artifacts: []types.TestArtifact{{Kind: "log", Label: "B trace", Content: "B"}}, + }, + }, + }, + Tested: []string{"reproduce A", "reproduce B"}, + TestingSummary: "A remains reproducible.\n\nB remains reproducible.", + Artifacts: []types.TestArtifact{{Kind: "log", Label: "A trace", Content: "A"}, {Kind: "log", Label: "B trace", Content: "B"}}, + } + priorRaw, err := types.MarshalFindingsJSON(prior) + if err != nil { + t.Fatal(err) + } + fresh := types.Findings{Items: []types.Finding{{ + PriorID: "review-b", + PriorContinuityToken: "token-b", + Severity: "error", + File: "b.go", + Line: 20, + Description: "defect B", + Action: types.ActionAutoFix, + ReviewScope: types.FindingReviewScopeSource, + Evidence: &types.FindingEvidence{}, + }}} + freshRaw, err := types.MarshalFindingsJSON(fresh) + if err != nil { + t.Fatal(err) + } + freshRaw, err = normalizeFindingsJSON(freshRaw, "review", priorRaw) + if err != nil { + t.Fatal(err) + } + freshRaw = mergeReappearedFindingsJSON(freshRaw, priorRaw) + + for _, tc := range []struct { + name string + selected []string + }{ + {name: "selected subset", selected: []string{"review-a"}}, + {name: "selected all", selected: []string{"review-a", "review-b"}}, + } { + t.Run(tc.name, func(t *testing.T) { + carriedRaw := excludeFindingsJSON(priorRaw, tc.selected) + effectiveRaw := mergeCarriedFindingsJSON(freshRaw, carriedRaw, "review") + effective, err := types.ParseFindingsJSON(effectiveRaw) + if err != nil { + t.Fatal(err) + } + if len(effective.Items) != 1 || effective.Items[0].ID != "review-b" { + t.Fatalf("surviving findings = %#v", effective.Items) + } + if len(effective.Tested) != 1 || effective.Tested[0] != "reproduce B" || effective.TestingSummary != "B remains reproducible." { + t.Fatalf("surviving tested evidence = %#v, %q", effective.Tested, effective.TestingSummary) + } + if len(effective.Artifacts) != 1 || effective.Artifacts[0].Label != "B trace" { + t.Fatalf("surviving artifacts = %#v", effective.Artifacts) + } + }) + } +} + func TestFilterFindingsJSON_EmptySelectionReturnsEmptyFindings(t *testing.T) { raw := `{"findings":[{"id":"review-1","severity":"error","description":"first"}],"summary":"1 finding"}` diff --git a/internal/pipeline/helpers_test.go b/internal/pipeline/helpers_test.go index 86f8456..065dceb 100644 --- a/internal/pipeline/helpers_test.go +++ b/internal/pipeline/helpers_test.go @@ -9,7 +9,6 @@ import ( "path/filepath" "strings" "sync" - "sync/atomic" "testing" "time" @@ -162,6 +161,15 @@ func (a *adaptiveCallStep) Execute(sctx *StepContext) (*StepOutcome, error) { return a.fn(sctx) } +// scopeLimitedAdaptiveCallStep models a step whose later rounds may report +// only what they reassessed. The executor must therefore retain unresolved +// findings that a later round does not mention. +type scopeLimitedAdaptiveCallStep struct { + adaptiveCallStep +} + +func (s *scopeLimitedAdaptiveCallStep) FindingsMayBeScopeLimited() bool { return true } + // waitForStepEvent polls the event collector until an event with the given type and step name appears. func waitForStepEvent(t *testing.T, ec *eventCollector, eventType ipc.EventType, stepName types.StepName) *ipc.Event { t.Helper() @@ -217,30 +225,64 @@ func waitForStepStatus(t *testing.T, database *db.DB, runID string, stepName typ t.Fatalf("step %s did not reach status %q within timeout; last seen %v", stepName, expected, last) } +func findingIDByDescription(t *testing.T, database *db.DB, runID string, stepName types.StepName, description string) string { + t.Helper() + steps, err := database.GetStepsByRun(runID) + if err != nil { + t.Fatal(err) + } + for _, step := range steps { + if step.StepName != stepName || step.FindingsJSON == nil { + continue + } + findings, err := types.ParseFindingsJSON(*step.FindingsJSON) + if err != nil { + t.Fatal(err) + } + for _, finding := range findings.Items { + if finding.Description == description { + return finding.ID + } + } + } + t.Fatalf("finding %q not found for %s", description, stepName) + return "" +} + // startExecutor runs Execute in a goroutine and cancels it during cleanup so a // parked step closes its log file before t.TempDir removes the tree. Windows // refuses unlinkat on a still-open handle; leaving Execute running after a // failed wait was the lint.log leak in TestExecutor_AutoFixRespectsMaxAttempts. func startExecutor(t *testing.T, exec *Executor, run *db.Run, repo *db.Repo, workDir string) (<-chan error, context.CancelFunc) { + t.Helper() + return startExecutorOperation(t, func(ctx context.Context) error { + return exec.Execute(ctx, run, repo, workDir) + }) +} + +func startResumeExecutor(t *testing.T, exec *Executor, run *db.Run, repo *db.Repo, workDir string) (<-chan error, context.CancelFunc) { + t.Helper() + return startExecutorOperation(t, func(ctx context.Context) error { + return exec.Resume(ctx, run, repo, workDir) + }) +} + +func startExecutorOperation(t *testing.T, run func(context.Context) error) (<-chan error, context.CancelFunc) { t.Helper() ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) - var finished atomic.Bool + exited := make(chan struct{}) t.Cleanup(func() { cancel() - if finished.Load() { - return - } select { - case <-done: + case <-exited: case <-time.After(10 * time.Second): t.Error("executor did not return after cancel") } }) go func() { - err := exec.Execute(ctx, run, repo, workDir) - finished.Store(true) - done <- err + defer close(exited) + done <- run(ctx) }() return done, cancel } @@ -267,6 +309,7 @@ func dirExists(path string) bool { type findingJSON struct { ID string `json:"id"` + IDGenerated bool `json:"id_generated"` Severity string `json:"severity"` Description string `json:"description"` Source string `json:"source"` diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index b67b8d3..eb7c2a4 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -28,6 +28,7 @@ type StepContext struct { SkipFixExecution bool // replay an already-completed fix round's review turn only ReviewStartingHeadSHA string PreviousFindings string // JSON findings from the previous execution (set during fix loop) + KnownReviewLineages string // StepResultID is the DB row ID of the current step's step_results record. // Steps use it to query their own round history for multi-round prompts. StepResultID string @@ -60,7 +61,9 @@ type StepContext struct { UncertifiedSourceRunID string // UncertifiedPriorRounds are review rounds from the source run that left // the uncertified range. Nil when none apply. - UncertifiedPriorRounds []*db.StepRound + UncertifiedPriorRounds []*db.StepRound + UncertifiedPriorFindings string + UncertifiedPriorLineages string // Sessions manages the run's durable review-fixer session. The session // machinery remains role-generic for legacy recovery; nil runs every // invocation cold. @@ -88,13 +91,14 @@ func (sctx *StepContext) RunAgentSession(role SessionRole, opts agent.RunOpts) ( // StepOutcome is the result of executing a pipeline step. type StepOutcome struct { - NeedsApproval bool // whether the step pauses for user action - AutoFixable bool - Findings string // JSON findings for TUI display (optional) - ExitCode int // process exit code (0 = success) - PRURL string // PR/MR URL if this step created or found one - Skipped bool // mark the step as skipped without failing the run - SkipRemaining bool // skip all subsequent steps (e.g. empty diff after rebase) + NeedsApproval bool // whether the step pauses for user action + AutoFixable bool + Findings string // JSON findings for TUI display (optional) + FindingsNormalized bool + ExitCode int // process exit code (0 = success) + PRURL string // PR/MR URL if this step created or found one + Skipped bool // mark the step as skipped without failing the run + SkipRemaining bool // skip all subsequent steps (e.g. empty diff after rebase) // RestartFrom asks the executor to re-run validation from this earlier step. // CI repairs use it to send the new local head back through review before push. RestartFrom types.StepName @@ -125,6 +129,18 @@ type Step interface { Execute(sctx *StepContext) (*StepOutcome, error) } +// ScopeLimitedFindingsStep marks a step whose later rounds may reassess only +// the work selected for that round. For such a step, silence in a later round +// cannot retract an unresolved finding that the operator was already shown. +type ScopeLimitedFindingsStep interface { + FindingsMayBeScopeLimited() bool +} + +func findingsMayBeScopeLimited(step Step) bool { + scopeLimited, ok := step.(ScopeLimitedFindingsStep) + return ok && scopeLimited.FindingsMayBeScopeLimited() +} + // ApprovalGateReconciler is implemented by a step whose parked approval gate // can become obsolete when an external source of truth changes. The executor // invokes it with a bounded context while also waiting for an approval. A true diff --git a/internal/pipeline/steps/ci.go b/internal/pipeline/steps/ci.go index 051fd6d..2fed745 100644 --- a/internal/pipeline/steps/ci.go +++ b/internal/pipeline/steps/ci.go @@ -41,7 +41,8 @@ type CIStep struct { lastFixedCompletedAt map[string]time.Time // terminally failed check completion times seen before the last fix attempt ciFixAttempts int // number of CI auto-fix attempts made transientReruns checkRerunBudget // per-check rerun budget spent on provider-reported transient failures - pollIntervalOverride time.Duration // if set, overrides computed poll interval (for testing) + expectedAttestation expectedAttestationState + pollIntervalOverride time.Duration // if set, overrides computed poll interval (for testing) waitForNextPoll func(context.Context, time.Duration) error now func() time.Time // baseBranchTip resolves the current tip SHA of the upstream default @@ -137,6 +138,9 @@ func (s *CIStep) Execute(sctx *pipeline.StepContext) (*pipeline.StepOutcome, err // spent. Without this the fresh in-memory budget would grant reruns the // documented limit already accounted for. s.loadRerunBudget(sctx) + if err := s.loadExpectedAttestationState(sctx); err != nil { + return nil, err + } ctx := sctx.Ctx if err := ctx.Err(); err != nil { return nil, err @@ -315,6 +319,10 @@ func (s *CIStep) Execute(sctx *pipeline.StepContext) (*pipeline.StepOutcome, err lastMonitorLog = "" sctx.Log(fmt.Sprintf("warning: could not check CI: %v", err)) } else { + checks, err = s.filterExpectedStaleAttestationChecks(sctx, host, checks) + if err != nil { + return nil, err + } // checksPending is the narrow execution state: only checks that are // actively running or queued block a rerun or issue escalation. A // provider-cancelled check is terminal enough to enter the transient @@ -458,12 +466,12 @@ func (s *CIStep) Execute(sctx *pipeline.StepContext) (*pipeline.StepOutcome, err manualFixAttempted = true sctx.Log(fmt.Sprintf("issues detected: %s - manual fix requested...", issueDesc)) previousHeadSHA := sctx.Run.HeadSHA - changed, err := s.autoFixCI(sctx, host, pr, fixTargets, mergeConflict) + result, err := s.autoFixCI(sctx, host, pr, fixTargets, mergeConflict) if err != nil { if fatalErr := s.handleCIRepairError(sctx, previousHeadSHA, "manual fix", err); fatalErr != nil { return nil, fatalErr } - } else if changed || sctx.Run.HeadSHA != previousHeadSHA { + } else if result.HeadChanged() { s.lastFixedChecks = fixKey s.lastFixedCompletedAt = fixCompletedAt return s.restartValidationOutcome(), nil @@ -491,12 +499,12 @@ func (s *CIStep) Execute(sctx *pipeline.StepContext) (*pipeline.StepOutcome, err s.ciFixAttempts = nextAttempt sctx.Log(fmt.Sprintf("issues detected: %s - auto-fixing (attempt %d/%d)...", issueDesc, s.ciFixAttempts, ciFixLimit)) previousHeadSHA := sctx.Run.HeadSHA - changed, err := s.autoFixCI(sctx, host, pr, fixTargets, mergeConflict) + result, err := s.autoFixCI(sctx, host, pr, fixTargets, mergeConflict) if err != nil { if fatalErr := s.handleCIRepairError(sctx, previousHeadSHA, "auto-fix", err); fatalErr != nil { return nil, fatalErr } - } else if changed || sctx.Run.HeadSHA != previousHeadSHA { + } else if result.HeadChanged() { s.lastFixedChecks = fixKey s.lastFixedCompletedAt = fixCompletedAt return s.restartValidationOutcome(), nil diff --git a/internal/pipeline/steps/ci_autofix_test.go b/internal/pipeline/steps/ci_autofix_test.go index fcd9e3f..c19a379 100644 --- a/internal/pipeline/steps/ci_autofix_test.go +++ b/internal/pipeline/steps/ci_autofix_test.go @@ -12,6 +12,7 @@ import ( "github.com/Blakeolson21/no-slop/internal/agent" "github.com/Blakeolson21/no-slop/internal/config" + "github.com/Blakeolson21/no-slop/internal/types" ) func TestCIStep_CIFailureAutoFix(t *testing.T) { @@ -54,7 +55,7 @@ func TestCIStep_CIFailureAutoFix(t *testing.T) { } prURL := "https://github.com/test/repo/pull/42" - sctx := newTestContext(t, ag, dir, baseSHA, headSHA, config.Commands{}) + sctx := newTestContextWithDBRecords(t, ag, dir, baseSHA, headSHA, config.Commands{}) sctx.Env = env sctx.Run.PRURL = &prURL sctx.Repo.UpstreamURL = upstream @@ -62,22 +63,23 @@ func TestCIStep_CIFailureAutoFix(t *testing.T) { sctx.UserIntent = "user wanted CI autofix to preserve the extracted intent" sctx.Config.CITimeout = 30 * time.Second sctx.Config.AutoFix = config.AutoFix{CI: 3} - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - sctx.Ctx = ctx + for _, name := range []types.StepName{types.StepReview, types.StepTest, types.StepDocument} { + result, err := sctx.DB.InsertStepResult(sctx.Run.ID, name) + if err != nil { + t.Fatal(err) + } + if err := sctx.DB.CompleteStepWithStatusAtHead(result.ID, types.StepStatusCompleted, headSHA, 0, 1, ""); err != nil { + t.Fatal(err) + } + } var logs []string sctx.Log = func(s string) { logs = append(logs, s) } - pollCount := 0 step := &CIStep{ waitForNextPoll: func(ctx context.Context, interval time.Duration) error { - pollCount++ - if pollCount == 2 { - cancel() - } - return ctx.Err() + t.Fatal("CI monitor polled again after making required gate evidence stale") + return nil }, } outcome, err := step.Execute(sctx) @@ -86,8 +88,8 @@ func TestCIStep_CIFailureAutoFix(t *testing.T) { t.Error("expected agent to be called for CI auto-fix") } - if len(ag.calls) == 0 { - t.Fatal("expected agent call") + if len(ag.calls) != 1 { + t.Fatalf("agent calls = %d, want exactly one CI repair", len(ag.calls)) } foundAutoFix := false @@ -102,6 +104,40 @@ func TestCIStep_CIFailureAutoFix(t *testing.T) { } } +func TestCIStep_ManualFixWithFailingCheckRestartsValidation(t *testing.T) { + dir, baseSHA, headSHA := setupGitRepo(t) + ag := &mockAgent{name: "test", runFn: func(_ context.Context, opts agent.RunOpts) (*agent.Result, error) { + if err := os.WriteFile(filepath.Join(opts.CWD, "manual-ci-fix.txt"), []byte("fixed"), 0o644); err != nil { + return nil, err + } + return &agent.Result{Output: []byte(`{"summary":"repair manual CI failure"}`)}, nil + }} + sctx := newTestContextWithDBRecords(t, ag, dir, baseSHA, headSHA, config.Commands{}) + sctx.Fixing = true + prURL := "https://github.com/test/repo/pull/42" + sctx.Run.PRURL = &prURL + sctx.Env = fakeCIGH(t, "OPEN", `[{"name":"test","state":"FAILURE","bucket":"fail"}]`) + sctx.Config.CITimeout = 30 * time.Second + for _, name := range []types.StepName{types.StepReview, types.StepTest, types.StepDocument} { + result, err := sctx.DB.InsertStepResult(sctx.Run.ID, name) + if err != nil { + t.Fatal(err) + } + if err := sctx.DB.CompleteStepWithStatusAtHead(result.ID, types.StepStatusCompleted, headSHA, 0, 1, ""); err != nil { + t.Fatal(err) + } + } + + outcome, err := (&CIStep{waitForNextPoll: func(context.Context, time.Duration) error { + t.Fatal("CI monitor polled after a successful manual repair") + return nil + }}).Execute(sctx) + assertCIRestartsValidation(t, outcome, err) + if len(ag.calls) != 1 { + t.Fatalf("manual CI repair calls = %d, want 1", len(ag.calls)) + } +} + func TestCIStep_CIAutoFixDisabledWithZero(t *testing.T) { t.Parallel() dir, baseSHA, headSHA := setupGitRepo(t) @@ -762,7 +798,7 @@ func TestCIStep_FixMode_ManualInterventionRunsCIFix(t *testing.T) { } prURL := "https://github.com/test/repo/pull/42" - sctx := newTestContext(t, ag, dir, baseSHA, headSHA, config.Commands{}) + sctx := newTestContextWithDBRecords(t, ag, dir, baseSHA, headSHA, config.Commands{}) sctx.Env = env sctx.Run.PRURL = &prURL sctx.Repo.UpstreamURL = upstream @@ -772,18 +808,10 @@ func TestCIStep_FixMode_ManualInterventionRunsCIFix(t *testing.T) { sctx.Fixing = true sctx.PreviousFindings = string(findingsJSON) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - sctx.Ctx = ctx - - pollCount := 0 step := &CIStep{ waitForNextPoll: func(ctx context.Context, interval time.Duration) error { - pollCount++ - if pollCount == 2 { - cancel() - } - return ctx.Err() + t.Fatal("CI monitor polled after publishing a manual-fix head") + return nil }, } outcome, err := step.Execute(sctx) diff --git a/internal/pipeline/steps/ci_checks.go b/internal/pipeline/steps/ci_checks.go index 9b953dd..1ff835f 100644 --- a/internal/pipeline/steps/ci_checks.go +++ b/internal/pipeline/steps/ci_checks.go @@ -1,6 +1,7 @@ package steps import ( + "context" "encoding/json" "fmt" "time" @@ -10,6 +11,98 @@ import ( "github.com/Blakeolson21/no-slop/internal/types" ) +const requiredAttestationCheckName = "PR must be raised via no-slop" + +func (s *CIStep) filterExpectedStaleAttestationChecks(sctx *pipeline.StepContext, host scm.Host, checks []scm.Check) ([]scm.Check, error) { + state := &s.expectedAttestation + if state.HeadSHA == "" || state.HeadSHA != sctx.Run.HeadSHA { + return checks, nil + } + if !validPublicationNonce(state.PublicationNonce) { + return nil, fmt.Errorf("expected attestation boundary is missing") + } + reader, ok := host.(scm.CheckAttemptIdentityReader) + if !ok { + return nil, fmt.Errorf("provider cannot identify expected stale attestation check attempts") + } + publicationReader, ok := host.(scm.AttestationPublicationIdentityReader) + if !ok { + return nil, fmt.Errorf("provider cannot identify the attestation publication workflow event") + } + identities := make(map[string]scm.CheckAttemptIdentity) + publicationRunID := state.PublicationRunID + publicationRunNumber := state.PublicationRunNumber + if publicationRunNumber == 0 { + identity, found, err := publicationReader.FindAttestationPublicationIdentity(sctx.Ctx, sctx.Run.HeadSHA, state.PublicationNonce) + if err != nil { + return nil, fmt.Errorf("identify attestation publication workflow event: %w", err) + } + if found { + if identity.RunID <= 0 || identity.RunNumber <= 0 || identity.HeadSHA != sctx.Run.HeadSHA || identity.PublicationNonce != state.PublicationNonce { + return nil, fmt.Errorf("attestation publication workflow identity is incomplete") + } + publicationRunID = identity.RunID + publicationRunNumber = identity.RunNumber + } + } + if publicationRunNumber != 0 && (state.PublicationRunID != publicationRunID || state.PublicationRunNumber != publicationRunNumber) { + state.PublicationRunID = publicationRunID + state.PublicationRunNumber = publicationRunNumber + if err := persistExpectedAttestationState(sctx, *state); err != nil { + return nil, fmt.Errorf("persist attestation publication run identity: %w", err) + } + } + + filtered := make([]scm.Check, 0, len(checks)+1) + if publicationRunNumber == 0 { + for _, check := range checks { + if check.Name != requiredAttestationCheckName { + filtered = append(filtered, check) + } + } + filtered = append(filtered, scm.Check{Name: requiredAttestationCheckName, Bucket: scm.CheckBucketPending, State: "EXPECTED_ATTESTATION"}) + return filtered, nil + } + currentAttemptPresent := false + for _, check := range checks { + if check.Name != requiredAttestationCheckName { + filtered = append(filtered, check) + continue + } + identity, err := readCheckAttemptIdentity(sctx.Ctx, reader, check, identities) + if err != nil { + return nil, err + } + if identity.HeadSHA != sctx.Run.HeadSHA { + continue + } + if identity.RunID <= 0 || identity.RunNumber <= 0 { + return nil, fmt.Errorf("attestation check attempt has incomplete run identity") + } + if identity.RunNumber < publicationRunNumber { + continue + } + filtered = append(filtered, check) + currentAttemptPresent = true + } + if !currentAttemptPresent { + filtered = append(filtered, scm.Check{Name: requiredAttestationCheckName, Bucket: scm.CheckBucketPending, State: "EXPECTED_ATTESTATION"}) + } + return filtered, nil +} + +func readCheckAttemptIdentity(ctx context.Context, reader scm.CheckAttemptIdentityReader, check scm.Check, cache map[string]scm.CheckAttemptIdentity) (scm.CheckAttemptIdentity, error) { + if identity, ok := cache[check.Link]; ok { + return identity, nil + } + identity, err := reader.GetCheckAttemptIdentity(ctx, check) + if err != nil { + return scm.CheckAttemptIdentity{}, fmt.Errorf("identify attestation check attempt: %w", err) + } + cache[check.Link] = identity + return identity, nil +} + type lastFixedIssues struct { Checks []string `json:"checks,omitempty"` MergeConflict bool `json:"mergeConflict,omitempty"` diff --git a/internal/pipeline/steps/ci_checks_test.go b/internal/pipeline/steps/ci_checks_test.go index 401dee4..828f98b 100644 --- a/internal/pipeline/steps/ci_checks_test.go +++ b/internal/pipeline/steps/ci_checks_test.go @@ -1,12 +1,194 @@ package steps import ( + "context" + "encoding/json" + "strings" "testing" "time" + "github.com/Blakeolson21/no-slop/internal/config" "github.com/Blakeolson21/no-slop/internal/scm" ) +type attestationIdentityHost struct { + recordingPRUpdateHost + identities map[string]scm.CheckAttemptIdentity + publication scm.CheckAttemptIdentity +} + +func TestCIStepFailsClosedWhenAttestationStateCannotBeRestored(t *testing.T) { + for _, encoded := range []string{ + `{`, + `{"head_sha":"head-without-boundary"}`, + } { + t.Run(encoded, func(t *testing.T) { + dir, baseSHA, headSHA := setupGitRepo(t) + sctx := newTestContextWithDBRecords(t, &mockAgent{name: "test"}, dir, baseSHA, headSHA, config.Commands{}) + if err := sctx.DB.SetRunCIAttestationState(sctx.Run.ID, encoded); err != nil { + t.Fatal(err) + } + outcome, err := (&CIStep{}).Execute(sctx) + if err == nil || !strings.Contains(err.Error(), "persisted CI attestation state") { + t.Fatalf("Execute() = (%#v, %v), want restoration error", outcome, err) + } + }) + } +} + +func TestCIStepFailsClosedWhenAttestationStateCannotBeRead(t *testing.T) { + dir, baseSHA, headSHA := setupGitRepo(t) + sctx := newTestContextWithDBRecords(t, &mockAgent{name: "test"}, dir, baseSHA, headSHA, config.Commands{}) + if err := sctx.DB.Close(); err != nil { + t.Fatal(err) + } + outcome, err := (&CIStep{}).Execute(sctx) + if err == nil || !strings.Contains(err.Error(), "read persisted CI attestation state") { + t.Fatalf("Execute() = (%#v, %v), want read error", outcome, err) + } +} + +func TestCIStepKeepsLegacyRerunRestorationBestEffort(t *testing.T) { + dir, baseSHA, headSHA := setupGitRepo(t) + sctx := newTestContextWithDBRecords(t, &mockAgent{name: "test"}, dir, baseSHA, headSHA, config.Commands{}) + if err := sctx.DB.SetRunCIRerunState(sctx.Run.ID, `{"spent":[]}`); err != nil { + t.Fatal(err) + } + var logs []string + sctx.Log = func(line string) { logs = append(logs, line) } + sctx.Run.PRURL = nil + outcome, err := (&CIStep{}).Execute(sctx) + if err != nil { + t.Fatal(err) + } + if outcome == nil || !outcome.Skipped { + t.Fatalf("Execute() = %#v, want no-PR skip", outcome) + } + if !strings.Contains(strings.Join(logs, "\n"), "could not restore the persisted rerun budget") { + t.Fatalf("logs = %q", logs) + } +} + +func TestCIStepFailsClosedOnMalformedLegacyAttestationState(t *testing.T) { + dir, baseSHA, headSHA := setupGitRepo(t) + sctx := newTestContextWithDBRecords(t, &mockAgent{name: "test"}, dir, baseSHA, headSHA, config.Commands{}) + if err := sctx.DB.SetRunCIRerunState(sctx.Run.ID, `{`); err != nil { + t.Fatal(err) + } + sctx.Run.PRURL = nil + outcome, err := (&CIStep{}).Execute(sctx) + if err == nil || !strings.Contains(err.Error(), "restore legacy persisted CI attestation state") { + t.Fatalf("Execute() = (%#v, %v), want legacy restoration error", outcome, err) + } +} + +func TestCIStepRejectsLegacyTimestampAttestationState(t *testing.T) { + dir, baseSHA, headSHA := setupGitRepo(t) + sctx := newTestContextWithDBRecords(t, &mockAgent{name: "test"}, dir, baseSHA, headSHA, config.Commands{}) + boundary := time.Date(2026, 8, 23, 18, 42, 31, 0, time.UTC) + legacy := `{"spent":{"build":1},"expected_attestation_head_sha":"` + headSHA + `","expected_attestation_updated_at":"` + boundary.Format(time.RFC3339Nano) + `"}` + if err := sctx.DB.SetRunCIRerunState(sctx.Run.ID, legacy); err != nil { + t.Fatal(err) + } + step := &CIStep{} + step.loadRerunBudget(sctx) + err := step.loadExpectedAttestationState(sctx) + if err == nil || !strings.Contains(err.Error(), "requires PR attestation republication") { + t.Fatalf("loadExpectedAttestationState() error = %v, want republication", err) + } + if step.transientReruns.used("build") != 1 { + t.Fatalf("restored state = budget %#v, attestation %#v", step.transientReruns, step.expectedAttestation) + } +} + +func (h *attestationIdentityHost) GetCheckAttemptIdentity(_ context.Context, check scm.Check) (scm.CheckAttemptIdentity, error) { + return h.identities[check.Link], nil +} + +func (h *attestationIdentityHost) FindAttestationPublicationIdentity(_ context.Context, headSHA, nonce string) (scm.CheckAttemptIdentity, bool, error) { + if h.publication.HeadSHA != headSHA || h.publication.PublicationNonce != nonce { + return scm.CheckAttemptIdentity{}, false, nil + } + return h.publication, true, nil +} + +func TestFilterExpectedStaleAttestationChecksUsesPublicationNonce(t *testing.T) { + dir, baseSHA, headSHA := setupGitRepo(t) + sctx := newTestContextWithDBRecords(t, &mockAgent{name: "test"}, dir, baseSHA, headSHA, config.Commands{}) + currentNonce := "00112233445566778899aabbccddeeff" + staleNonce := "ffeeddccbbaa99887766554433221100" + olderPass := scm.Check{Name: requiredAttestationCheckName, Bucket: scm.CheckBucketPass, State: "SUCCESS", Link: "older-pass"} + stale := scm.Check{Name: requiredAttestationCheckName, Bucket: scm.CheckBucketFail, State: "FAILURE", Link: "stale"} + publicationCancelled := scm.Check{Name: requiredAttestationCheckName, Bucket: scm.CheckBucketCancel, State: "CANCELLED", Link: "publication-cancelled"} + laterFailure := scm.Check{Name: requiredAttestationCheckName, Bucket: scm.CheckBucketFail, State: "FAILURE", Link: "later-failure"} + laterSameBody := scm.Check{Name: requiredAttestationCheckName, Bucket: scm.CheckBucketPass, State: "SUCCESS", Link: "later-same-body"} + host := &attestationIdentityHost{identities: map[string]scm.CheckAttemptIdentity{ + "older-pass": {RunID: 999, RunNumber: 99, RunAttempt: 1, HeadSHA: headSHA, PublicationNonce: staleNonce}, + "stale": {RunID: 1000, RunNumber: 101, RunAttempt: 1, HeadSHA: headSHA, PublicationNonce: staleNonce}, + "publication-cancelled": {RunID: 1002, RunNumber: 102, RunAttempt: 1, HeadSHA: headSHA, PublicationNonce: currentNonce}, + "later-failure": {RunID: 1001, RunNumber: 103, RunAttempt: 1, HeadSHA: headSHA, PublicationNonce: staleNonce}, + "later-same-body": {RunID: 1004, RunNumber: 104, RunAttempt: 1, HeadSHA: headSHA, PublicationNonce: currentNonce}, + }, publication: scm.CheckAttemptIdentity{RunID: 1002, RunNumber: 102, RunAttempt: 1, HeadSHA: headSHA, PublicationNonce: currentNonce}} + state := expectedAttestationState{HeadSHA: headSHA, PublicationNonce: currentNonce} + encoded, err := json.Marshal(state) + if err != nil { + t.Fatal(err) + } + if err := sctx.DB.SetRunCIAttestationState(sctx.Run.ID, string(encoded)); err != nil { + t.Fatal(err) + } + step := &CIStep{} + step.loadRerunBudget(sctx) + if err := step.loadExpectedAttestationState(sctx); err != nil { + t.Fatal(err) + } + + filtered, err := step.filterExpectedStaleAttestationChecks(sctx, host, []scm.Check{olderPass, stale}) + if err != nil { + t.Fatal(err) + } + if len(filtered) != 1 || filtered[0].Bucket != scm.CheckBucketPending { + t.Fatalf("pre-update terminal checks = %#v, want synthetic pending", filtered) + } + + filtered, err = step.filterExpectedStaleAttestationChecks(sctx, host, []scm.Check{olderPass, stale, publicationCancelled}) + if err != nil { + t.Fatal(err) + } + if len(filtered) != 1 || filtered[0].Link != "publication-cancelled" || filtered[0].Bucket != scm.CheckBucketCancel { + t.Fatalf("publication attempt ordering = %#v", filtered) + } + if step.expectedAttestation.PublicationRunID != 1002 { + t.Fatalf("publication run ID = %d, want 1002", step.expectedAttestation.PublicationRunID) + } + if step.expectedAttestation.PublicationRunNumber != 102 { + t.Fatalf("publication run number = %d, want 102", step.expectedAttestation.PublicationRunNumber) + } + + filtered, err = step.filterExpectedStaleAttestationChecks(sctx, host, []scm.Check{olderPass, stale, publicationCancelled, laterFailure, laterSameBody}) + if err != nil { + t.Fatal(err) + } + if len(filtered) != 3 || filtered[0].Link != "publication-cancelled" || filtered[1].Link != "later-failure" || filtered[2].Link != "later-same-body" { + t.Fatalf("later authoritative attempts were suppressed: %#v", filtered) + } + + recovered := &CIStep{} + if err := recovered.loadExpectedAttestationState(sctx); err != nil { + t.Fatal(err) + } + filtered, err = recovered.filterExpectedStaleAttestationChecks(sctx, host, []scm.Check{stale, publicationCancelled, laterFailure}) + if err != nil { + t.Fatal(err) + } + if len(filtered) != 2 || filtered[0].Link != "publication-cancelled" || filtered[1].Link != "later-failure" { + t.Fatalf("recovered attempt ordering = %#v", filtered) + } + if recovered.expectedAttestation.PublicationRunID != 1002 || recovered.expectedAttestation.PublicationRunNumber != 102 { + t.Fatalf("recovered attestation state = %#v", recovered.expectedAttestation) + } +} + func TestAllChecksPassedFailsClosed(t *testing.T) { tests := []struct { name string diff --git a/internal/pipeline/steps/ci_commit_test.go b/internal/pipeline/steps/ci_commit_test.go index 55e8c80..9d6ef06 100644 --- a/internal/pipeline/steps/ci_commit_test.go +++ b/internal/pipeline/steps/ci_commit_test.go @@ -1,17 +1,344 @@ package steps import ( + "context" "os" "os/exec" "path/filepath" "strings" "testing" + "github.com/Blakeolson21/no-slop/internal/agent" "github.com/Blakeolson21/no-slop/internal/branchsync" "github.com/Blakeolson21/no-slop/internal/config" "github.com/Blakeolson21/no-slop/internal/db" + "github.com/Blakeolson21/no-slop/internal/scm" + "github.com/Blakeolson21/no-slop/internal/types" ) +type recordingPRUpdateHost struct { + updates []scm.PRContent +} + +func (h *recordingPRUpdateHost) UpdatePR(_ context.Context, _ *scm.PR, content scm.PRContent) (*scm.PR, error) { + h.updates = append(h.updates, content) + return &scm.PR{Number: "42"}, nil +} + +func (h *recordingPRUpdateHost) Provider() scm.Provider { return scm.ProviderGitHub } +func (h *recordingPRUpdateHost) Capabilities() scm.Capabilities { + return scm.Capabilities{} +} +func (h *recordingPRUpdateHost) Available(context.Context) error { return nil } +func (h *recordingPRUpdateHost) FindPR(context.Context, string, string) (*scm.PR, error) { + return nil, nil +} +func (h *recordingPRUpdateHost) CreatePR(context.Context, string, string, scm.PRContent) (*scm.PR, error) { + return nil, nil +} +func (h *recordingPRUpdateHost) GetPRState(context.Context, *scm.PR) (scm.PRState, error) { + return scm.PRStateOpen, nil +} +func (h *recordingPRUpdateHost) GetChecks(context.Context, *scm.PR) ([]scm.Check, error) { + return nil, nil +} +func (h *recordingPRUpdateHost) GetMergeableState(context.Context, *scm.PR) (scm.MergeableState, error) { + return scm.MergeableUnknown, scm.ErrUnsupported +} +func (h *recordingPRUpdateHost) FetchFailedCheckLogs(context.Context, *scm.PR, string, string, []string) (string, error) { + return "", scm.ErrUnsupported +} + +func TestCIStep_AutoFixWithoutPushDoesNotUpdatePR(t *testing.T) { + dir, baseSHA, headSHA := setupGitRepo(t) + sctx := newTestContextWithDBRecords(t, &mockAgent{name: "test"}, dir, baseSHA, headSHA, config.Commands{}) + host := &recordingPRUpdateHost{} + + result, err := (&CIStep{}).autoFixCI(sctx, host, &scm.PR{Number: "42"}, []string{"build"}, false) + if err != nil { + t.Fatal(err) + } + if result.HeadChanged() { + t.Fatal("no-change CI fix reported a push") + } + if len(host.updates) != 0 { + t.Fatalf("no-change CI fix updated PR content: %d calls", len(host.updates)) + } +} + +func TestCIStep_AutoFixDefersPRUpdateAfterAdoptingLocalHead(t *testing.T) { + upstream := t.TempDir() + gitCmd(t, upstream, "init", "--bare") + dir, baseSHA, headSHA := setupGitRepo(t) + gitCmd(t, dir, "remote", "add", "origin", upstream) + gitCmd(t, dir, "push", "origin", "feature") + if err := os.WriteFile(filepath.Join(dir, "already-published.txt"), []byte("published"), 0o644); err != nil { + t.Fatal(err) + } + gitCmd(t, dir, "add", "-A") + gitCmd(t, dir, "commit", "-m", "already published") + newHeadSHA := gitCmd(t, dir, "rev-parse", "HEAD") + gitCmd(t, dir, "push", "origin", "feature") + + sctx := newTestContextWithDBRecords(t, &mockAgent{name: "test"}, dir, baseSHA, headSHA, config.Commands{}) + sctx.Repo.UpstreamURL = upstream + sctx.Run.Branch = "refs/heads/feature" + for _, name := range []types.StepName{types.StepReview, types.StepTest, types.StepDocument} { + step, err := sctx.DB.InsertStepResult(sctx.Run.ID, name) + if err != nil { + t.Fatal(err) + } + if err := sctx.DB.CompleteStepWithStatusAtHead(step.ID, types.StepStatusCompleted, headSHA, 0, 1, ""); err != nil { + t.Fatal(err) + } + } + host := &recordingPRUpdateHost{} + + result, err := (&CIStep{}).autoFixCI(sctx, host, &scm.PR{Number: "42"}, []string{"build"}, false) + if err != nil { + t.Fatal(err) + } + if !result.HeadChanged() { + t.Fatalf("adopted-head result = %#v", result) + } + if result.HeadSHA != newHeadSHA || sctx.Run.HeadSHA != newHeadSHA { + t.Fatalf("adopted head = %q / %q, want %q", result.HeadSHA, sctx.Run.HeadSHA, newHeadSHA) + } + if len(host.updates) != 0 { + t.Fatalf("local repair updated PR content before revalidation: %d calls", len(host.updates)) + } +} + +func TestCIStep_AutoFixLocalRepairDoesNotUpdatePR(t *testing.T) { + upstream := t.TempDir() + gitCmd(t, upstream, "init", "--bare") + dir, baseSHA, headSHA := setupGitRepo(t) + gitCmd(t, dir, "remote", "add", "origin", upstream) + gitCmd(t, dir, "push", "origin", "feature") + agent := &mockAgent{name: "test", runFn: func(_ context.Context, opts agent.RunOpts) (*agent.Result, error) { + if err := os.WriteFile(filepath.Join(opts.CWD, "ci-fix.txt"), []byte("fixed"), 0o644); err != nil { + t.Fatal(err) + } + return &agent.Result{}, nil + }} + sctx := newTestContextWithDBRecords(t, agent, dir, baseSHA, headSHA, config.Commands{}) + sctx.Repo.UpstreamURL = upstream + sctx.Run.Branch = "refs/heads/feature" + host := &recordingPRUpdateHost{} + + result, err := (&CIStep{}).autoFixCI(sctx, host, &scm.PR{Number: "42"}, []string{"build"}, false) + if err != nil { + t.Fatal(err) + } + if !result.HeadChanged() || !result.HeadPersisted { + t.Fatalf("local repair result = %#v", result) + } + if len(host.updates) != 0 { + t.Fatalf("local repair updated PR content: %d calls", len(host.updates)) + } + rng, err := sctx.DB.GetUncertifiedPipelineRange(sctx.Repo.ID, sctx.Run.Branch) + if err != nil { + t.Fatal(err) + } + if rng == nil || rng.FromSHA != headSHA || rng.ToSHA != result.HeadSHA || rng.SourceRunID != sctx.Run.ID { + t.Fatalf("CI repair uncertified range = %#v", rng) + } + if got := gitCmd(t, upstream, "rev-parse", "refs/heads/feature"); got != headSHA { + t.Fatalf("CI repair published before revalidation: remote head = %s, want %s", got, headSHA) + } +} + +func TestCIStep_RefusesLocalHeadWhenUncertifiedRangePersistenceFails(t *testing.T) { + dir, baseSHA, headSHA := setupGitRepo(t) + gitCmd(t, dir, "checkout", "--detach", headSHA) + if err := os.WriteFile(filepath.Join(dir, "ci-fix.txt"), []byte("fixed"), 0o644); err != nil { + t.Fatal(err) + } + sctx := newTestContextWithDBRecords(t, &mockAgent{name: "test"}, dir, baseSHA, headSHA, config.Commands{}) + originalRunID := sctx.Run.ID + sctx.Repo.ID = "missing-repo" + + changed, err := (&CIStep{}).commitRepair(sctx, "repair checks") + if err == nil || !strings.Contains(err.Error(), "persist uncertified review range before CI head adoption") { + t.Fatalf("commitRepair() = (%v, %v), want persistence refusal", changed, err) + } + if changed { + t.Fatal("failed CI persistence reported an adopted head") + } + if got := gitCmd(t, dir, "rev-parse", "refs/heads/feature"); got != headSHA { + t.Fatalf("branch head = %s, want unchanged %s", got, headSHA) + } + if sctx.Run.HeadSHA != headSHA { + t.Fatalf("in-memory head = %s, want %s", sctx.Run.HeadSHA, headSHA) + } + stored, getErr := sctx.DB.GetRun(originalRunID) + if getErr != nil { + t.Fatal(getErr) + } + if stored.HeadSHA != headSHA { + t.Fatalf("stored head = %s, want %s", stored.HeadSHA, headSHA) + } +} + +func TestCIStep_AutoFixDoesNotPersistLocalHeadWhenRefAdoptionFails(t *testing.T) { + upstream := t.TempDir() + gitCmd(t, upstream, "init", "--bare") + dir, baseSHA, headSHA := setupGitRepo(t) + gitCmd(t, dir, "remote", "add", "origin", upstream) + gitCmd(t, dir, "push", "origin", "feature") + gitCmd(t, dir, "checkout", "--detach", headSHA) + tree := gitCmd(t, dir, "rev-parse", headSHA+"^{tree}") + unrelated := gitCmd(t, dir, "commit-tree", tree, "-m", "unrelated branch head") + gitCmd(t, dir, "update-ref", "refs/heads/feature", unrelated) + agent := &mockAgent{name: "test", runFn: func(_ context.Context, opts agent.RunOpts) (*agent.Result, error) { + if err := os.WriteFile(filepath.Join(opts.CWD, "ci-fix.txt"), []byte("fixed"), 0o644); err != nil { + t.Fatal(err) + } + return &agent.Result{}, nil + }} + sctx := newTestContextWithDBRecords(t, agent, dir, baseSHA, headSHA, config.Commands{}) + sctx.Repo.UpstreamURL = upstream + sctx.Run.Branch = "refs/heads/feature" + if err := sctx.DB.UpsertUncertifiedPipelineRange(sctx.Repo.ID, sctx.Run.Branch, baseSHA, headSHA, sctx.Run.ID); err != nil { + t.Fatal(err) + } + host := &recordingPRUpdateHost{} + + result, err := (&CIStep{}).autoFixCI(sctx, host, &scm.PR{Number: "42"}, []string{"build"}, false) + if err == nil || !strings.Contains(err.Error(), "refusing to move branch ref") { + t.Fatalf("autoFixCI error = %v", err) + } + if result.HeadChanged() || result.HeadPersisted { + t.Fatalf("failed local adoption changed durable head: %#v", result) + } + remoteHead := gitCmd(t, upstream, "rev-parse", "refs/heads/feature") + if remoteHead != headSHA { + t.Fatalf("failed local adoption published remote head %q, want %q", remoteHead, headSHA) + } + persisted, getErr := sctx.DB.GetRun(sctx.Run.ID) + if getErr != nil { + t.Fatal(getErr) + } + if persisted.HeadSHA != headSHA { + t.Fatalf("persisted head = %q, want original %q", persisted.HeadSHA, headSHA) + } + rng, rangeErr := sctx.DB.GetUncertifiedPipelineRange(sctx.Repo.ID, sctx.Run.Branch) + if rangeErr != nil { + t.Fatal(rangeErr) + } + if rng == nil || rng.FromSHA != baseSHA || rng.ToSHA != headSHA || rng.SourceRunID != sctx.Run.ID { + t.Fatalf("failed CI adoption left rewritten uncertified range: %#v", rng) + } +} + +func TestCIStep_AutoFixLocalRepairDoesNotVerifyOrPublishRemote(t *testing.T) { + upstream := t.TempDir() + gitCmd(t, upstream, "init", "--bare") + dir, baseSHA, headSHA := setupGitRepo(t) + gitCmd(t, dir, "remote", "add", "origin", upstream) + gitCmd(t, dir, "push", "origin", "feature") + + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatal(err) + } + binDir := fakeCLIBinDir(t) + linkTestBinary(t, binDir, "git") + marker := filepath.Join(t.TempDir(), "pushed") + env := fakeCLIEnv(binDir, map[string]string{ + "FAKE_CLI_MODE": "git-fail-verify-after-push", + "FAKE_CLI_REAL_GIT": realGit, + "FAKE_CLI_PUSH_MARKER": marker, + }) + agent := &mockAgent{name: "test", runFn: func(_ context.Context, opts agent.RunOpts) (*agent.Result, error) { + if err := os.WriteFile(filepath.Join(opts.CWD, "ci-fix.txt"), []byte("fixed"), 0o644); err != nil { + t.Fatal(err) + } + return &agent.Result{}, nil + }} + sctx := newTestContextWithDBRecords(t, agent, dir, baseSHA, headSHA, config.Commands{}) + sctx.Env = env + sctx.Repo.UpstreamURL = upstream + sctx.Run.Branch = "refs/heads/feature" + host := &recordingPRUpdateHost{} + + result, err := (&CIStep{}).autoFixCI(sctx, host, &scm.PR{Number: "42"}, []string{"build"}, false) + if err != nil { + t.Fatal(err) + } + if !result.HeadChanged() || !result.HeadPersisted { + t.Fatalf("local repair result = %#v", result) + } + remoteHead := gitCmd(t, upstream, "rev-parse", "refs/heads/feature") + if remoteHead != headSHA { + t.Fatalf("local repair published remote head %q, want %q", remoteHead, headSHA) + } + persisted, getErr := sctx.DB.GetRun(sctx.Run.ID) + if getErr != nil { + t.Fatal(getErr) + } + if persisted.HeadSHA != result.HeadSHA { + t.Fatalf("persisted head = %q, want local repair %q", persisted.HeadSHA, result.HeadSHA) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("local repair unexpectedly invoked push; marker error = %v", err) + } +} + +func TestCIStep_AutoFixLocalRepairDoesNotInvokeAmbiguousPush(t *testing.T) { + upstream := t.TempDir() + gitCmd(t, upstream, "init", "--bare") + dir, baseSHA, headSHA := setupGitRepo(t) + gitCmd(t, dir, "remote", "add", "origin", upstream) + gitCmd(t, dir, "push", "origin", "feature") + + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatal(err) + } + binDir := fakeCLIBinDir(t) + linkTestBinary(t, binDir, "git") + marker := filepath.Join(t.TempDir(), "pushed") + env := fakeCLIEnv(binDir, map[string]string{ + "FAKE_CLI_MODE": "git-fail-after-push", + "FAKE_CLI_REAL_GIT": realGit, + "FAKE_CLI_PUSH_MARKER": marker, + }) + agent := &mockAgent{name: "test", runFn: func(_ context.Context, opts agent.RunOpts) (*agent.Result, error) { + if err := os.WriteFile(filepath.Join(opts.CWD, "ci-fix.txt"), []byte("fixed"), 0o644); err != nil { + t.Fatal(err) + } + return &agent.Result{}, nil + }} + sctx := newTestContextWithDBRecords(t, agent, dir, baseSHA, headSHA, config.Commands{}) + sctx.Env = env + sctx.Repo.UpstreamURL = upstream + sctx.Run.Branch = "refs/heads/feature" + host := &recordingPRUpdateHost{} + + result, err := (&CIStep{}).autoFixCI(sctx, host, &scm.PR{Number: "42"}, []string{"build"}, false) + if err != nil { + t.Fatal(err) + } + if !result.HeadChanged() || !result.HeadPersisted { + t.Fatalf("local repair result = %#v", result) + } + remoteHead := gitCmd(t, upstream, "rev-parse", "refs/heads/feature") + if remoteHead != headSHA { + t.Fatalf("local repair published remote head %q, want %q", remoteHead, headSHA) + } + persisted, getErr := sctx.DB.GetRun(sctx.Run.ID) + if getErr != nil { + t.Fatal(getErr) + } + if persisted.HeadSHA != result.HeadSHA { + t.Fatalf("persisted head = %q, want local repair %q", persisted.HeadSHA, result.HeadSHA) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("local repair unexpectedly invoked push; marker error = %v", err) + } +} + func TestCIStep_CommitAndPush(t *testing.T) { t.Parallel() // Set up upstream bare repo diff --git a/internal/pipeline/steps/ci_fix.go b/internal/pipeline/steps/ci_fix.go index 6887fc1..36ca526 100644 --- a/internal/pipeline/steps/ci_fix.go +++ b/internal/pipeline/steps/ci_fix.go @@ -12,14 +12,22 @@ import ( "github.com/Blakeolson21/no-slop/internal/types" ) +type ciFixResult struct { + PreviousHeadSHA string + HeadSHA string + HeadPersisted bool +} + +func (r ciFixResult) HeadChanged() bool { + return r.HeadSHA != "" && r.HeadSHA != r.PreviousHeadSHA +} + // autoFixCI runs the agent to fix CI failures and/or merge conflicts, then // commits the repair locally for a new validation cycle. -// Returns (true, nil) when the local head changed, (false, nil) -// when the agent produced no changes, or (false, err) on failure. -func (s *CIStep) autoFixCI(sctx *pipeline.StepContext, host scm.Host, pr *scm.PR, failingNames []string, mergeConflict bool) (bool, error) { +func (s *CIStep) autoFixCI(sctx *pipeline.StepContext, host scm.Host, pr *scm.PR, failingNames []string, mergeConflict bool) (ciFixResult, error) { ctx := sctx.Ctx if err := sctx.DB.SetRunPushActive(sctx.Run.ID, true); err != nil { - return false, err + return ciFixResult{}, err } defer func() { _ = sctx.DB.SetRunPushActive(sctx.Run.ID, false) }() baseSHA := resolveBranchBaseSHA(ctx, sctx.WorkDir, sctx.Run.BaseSHA, sctx.Repo.DefaultBranch) @@ -110,14 +118,24 @@ CI logs: OnChunk: sctx.LogChunk, }) if err != nil { - return false, fmt.Errorf("agent CI fix: %w", err) + return ciFixResult{}, fmt.Errorf("agent CI fix: %w", err) } + previousHeadSHA := sctx.Run.HeadSHA summary, summaryErr := extractCommitSummary(result) if summaryErr != nil { sctx.Log(fmt.Sprintf("warning: could not parse CI repair summary: %v", summaryErr)) } - return s.commitRepair(sctx, summary) + _, err = s.commitRepair(sctx, summary) + fixResult := ciFixResult{PreviousHeadSHA: previousHeadSHA, HeadSHA: sctx.Run.HeadSHA} + if fixResult.HeadChanged() { + persisted, getErr := sctx.DB.GetRun(sctx.Run.ID) + fixResult.HeadPersisted = getErr == nil && persisted != nil && persisted.HeadSHA == fixResult.HeadSHA + } + if err != nil { + return fixResult, err + } + return fixResult, nil } // commitAndPush retains its historical name as the narrow test seam. CI repair @@ -163,7 +181,14 @@ func (s *CIStep) commitRepair(sctx *pipeline.StepContext, summary string) (bool, } func (s *CIStep) recordLocalRepair(sctx *pipeline.StepContext, newHeadSHA string) (bool, error) { + rollbackRange, err := pipeline.PersistUncertifiedPipelineRangeWithRollback(sctx, sctx.Run.HeadSHA, newHeadSHA) + if err != nil { + return false, fmt.Errorf("persist uncertified review range before CI head adoption: %w", err) + } if err := adoptBranchRef(sctx, newHeadSHA); err != nil { + if rollbackErr := rollbackRange(); rollbackErr != nil { + return false, fmt.Errorf("%w; restore uncertified review range: %v", err, rollbackErr) + } return false, err } sctx.Run.HeadSHA = newHeadSHA diff --git a/internal/pipeline/steps/ci_revalidation_test.go b/internal/pipeline/steps/ci_revalidation_test.go index 015acc9..b4f7e3b 100644 --- a/internal/pipeline/steps/ci_revalidation_test.go +++ b/internal/pipeline/steps/ci_revalidation_test.go @@ -218,6 +218,7 @@ func TestCIStep_PersistenceFailureAfterRepairDoesNotResumePolling(t *testing.T) t.Parallel() dir, baseSHA, approvedHead := setupGitRepo(t) + gitCmd(t, dir, "checkout", "--detach", approvedHead) var sctx *pipeline.StepContext ag := &mockAgent{ name: "test", @@ -249,15 +250,18 @@ func TestCIStep_PersistenceFailureAfterRepairDoesNotResumePolling(t *testing.T) }} outcome, err := step.Execute(sctx) - if err == nil || !strings.Contains(err.Error(), "update run head sha for revalidation") { + if err == nil || !strings.Contains(err.Error(), "persist uncertified review range before CI head adoption") { t.Fatalf("CI outcome = %#v, error = %v, want actionable persistence failure", outcome, err) } if waitCalls != 0 { t.Fatalf("CI resumed polling %d times after the repaired head advanced", waitCalls) } repairedHead := gitCmd(t, dir, "rev-parse", "HEAD") - if repairedHead == approvedHead || sctx.Run.HeadSHA != repairedHead { - t.Fatalf("repaired head = %s, in-memory head = %s, approved head = %s", repairedHead, sctx.Run.HeadSHA, approvedHead) + if repairedHead == approvedHead || sctx.Run.HeadSHA != approvedHead { + t.Fatalf("unadopted repair head = %s, in-memory head = %s, approved head = %s", repairedHead, sctx.Run.HeadSHA, approvedHead) + } + if branchHead := gitCmd(t, dir, "rev-parse", "refs/heads/feature"); branchHead != approvedHead { + t.Fatalf("branch adopted repair despite failed recovery persistence: got %s, want %s", branchHead, approvedHead) } if sctx.Run.ReviewApprovedHeadSHA == nil || *sctx.Run.ReviewApprovedHeadSHA != approvedHead { t.Fatalf("review authority = %#v, want stale authority retained only on the aborted path", sctx.Run.ReviewApprovedHeadSHA) diff --git a/internal/pipeline/steps/ci_test.go b/internal/pipeline/steps/ci_test.go index 756019c..216de05 100644 --- a/internal/pipeline/steps/ci_test.go +++ b/internal/pipeline/steps/ci_test.go @@ -230,8 +230,8 @@ func TestCIStep_Execute_FixMode_RemoteAlreadyUpdatedDoesNotReturnManualIntervent step := &CIStep{ waitForNextPoll: func(ctx context.Context, interval time.Duration) error { - cancel() - return ctx.Err() + t.Fatal("CI monitor polled after adopting a new published head") + return nil }, } outcome, err := step.Execute(sctx) @@ -416,7 +416,9 @@ func TestCIStep_CIWarningAllowsChecksPassedToBeReannounced(t *testing.T) { sctx := newTestContext(t, ag, dir, baseSHA, headSHA, config.Commands{}) sctx.Env = env sctx.Run.PRURL = &prURL - sctx.Config.CITimeout = 10 * time.Second + // This test owns termination through waitForNextPoll below. A wall-clock + // timeout would make the warning sequence depend on subprocess speed. + sctx.Config.CITimeout = -1 var logs []string sctx.Log = func(s string) { logs = append(logs, s) } diff --git a/internal/pipeline/steps/ci_transient.go b/internal/pipeline/steps/ci_transient.go index 4cc2b8a..3eaa34e 100644 --- a/internal/pipeline/steps/ci_transient.go +++ b/internal/pipeline/steps/ci_transient.go @@ -114,6 +114,18 @@ type persistedRerunBudget struct { Rollup map[string]persistedRollupState `json:"rollup,omitempty"` } +type expectedAttestationState struct { + HeadSHA string `json:"head_sha"` + PublicationNonce string `json:"publication_nonce"` + PublicationRunID int64 `json:"publication_run_id,omitempty"` + PublicationRunNumber int64 `json:"publication_run_number,omitempty"` +} + +type legacyExpectedAttestationState struct { + HeadSHA string `json:"expected_attestation_head_sha"` + UpdatedAt string `json:"expected_attestation_updated_at"` +} + type persistedRollupState struct { CompletedAt time.Time `json:"completed_at"` GraceRemaining int `json:"grace_remaining"` @@ -479,10 +491,7 @@ func mergeCheckNames(base, extra []string) []string { // // Every failure path here falls back to the behavior this policy replaces: no // rerun, and the failure escalates exactly as it would without it. -// loadRerunBudget restores the durable rerun budget for this run. A run that -// never spent one, or a database that cannot be read, leaves the in-memory -// budget as it is: the failure direction is a fresh budget, which the -// reservation write below then re-establishes. +// loadRerunBudget restores the durable rerun budget for this run. func (s *CIStep) loadRerunBudget(sctx *pipeline.StepContext) { if sctx.DB == nil || sctx.Run == nil { return @@ -497,6 +506,47 @@ func (s *CIStep) loadRerunBudget(sctx *pipeline.StepContext) { } } +func (s *CIStep) loadExpectedAttestationState(sctx *pipeline.StepContext) error { + if sctx.DB == nil || sctx.Run == nil { + return nil + } + encoded, err := sctx.DB.GetRunCIAttestationState(sctx.Run.ID) + if err != nil { + return fmt.Errorf("read persisted CI attestation state: %w", err) + } + if strings.TrimSpace(encoded) == "" { + legacyEncoded, legacyErr := sctx.DB.GetRunCIRerunState(sctx.Run.ID) + if legacyErr != nil { + return fmt.Errorf("read legacy persisted CI attestation state: %w", legacyErr) + } + if strings.TrimSpace(legacyEncoded) == "" { + s.expectedAttestation = expectedAttestationState{} + return nil + } + var legacy legacyExpectedAttestationState + if err := json.Unmarshal([]byte(legacyEncoded), &legacy); err != nil { + return fmt.Errorf("restore legacy persisted CI attestation state: %w", err) + } + if legacy.HeadSHA == "" && legacy.UpdatedAt == "" { + s.expectedAttestation = expectedAttestationState{} + return nil + } + if legacy.HeadSHA == "" || legacy.UpdatedAt == "" { + return fmt.Errorf("restore persisted CI attestation state: expected attestation boundary is incomplete") + } + return fmt.Errorf("restore persisted CI attestation state: timestamp boundary requires PR attestation republication") + } + var state expectedAttestationState + if err := json.Unmarshal([]byte(encoded), &state); err != nil { + return fmt.Errorf("restore persisted CI attestation state: %w", err) + } + if state.HeadSHA == "" || !validPublicationNonce(state.PublicationNonce) || state.PublicationRunID < 0 || state.PublicationRunNumber < 0 || (state.PublicationRunNumber > 0 && state.PublicationRunID == 0) { + return fmt.Errorf("restore persisted CI attestation state: expected attestation boundary is incomplete") + } + s.expectedAttestation = state + return nil +} + // persistRerunBudget writes the rerun budget so a recovered run resumes with // what it already spent rather than a fresh allowance. func (s *CIStep) persistRerunBudget(sctx *pipeline.StepContext) error { @@ -514,6 +564,34 @@ func (s *CIStep) persistRerunBudgetCandidate(sctx *pipeline.StepContext, candida return sctx.DB.SetRunCIRerunState(sctx.Run.ID, encoded) } +func persistExpectedAttestationPublication(sctx *pipeline.StepContext, publicationNonce string) error { + if !validPublicationNonce(publicationNonce) { + return fmt.Errorf("PR attestation publication nonce is invalid") + } + state := expectedAttestationState{HeadSHA: sctx.Run.HeadSHA, PublicationNonce: publicationNonce} + return persistExpectedAttestationState(sctx, state) +} + +func persistExpectedAttestationState(sctx *pipeline.StepContext, state expectedAttestationState) error { + encoded, err := json.Marshal(state) + if err != nil { + return err + } + return sctx.DB.SetRunCIAttestationState(sctx.Run.ID, string(encoded)) +} + +func validPublicationNonce(nonce string) bool { + if len(nonce) != 32 { + return false + } + for _, char := range nonce { + if (char < '0' || char > '9') && (char < 'a' || char > 'f') { + return false + } + } + return true +} + func (s *CIStep) retireResolvedReruns(sctx *pipeline.StepContext, checks []scm.Check) (bool, error) { return s.transientReruns.retireResolvedReruns(checks, sctx.Run.HeadSHA, func(candidate *checkRerunBudget) error { return s.persistRerunBudgetCandidate(sctx, candidate) diff --git a/internal/pipeline/steps/common.go b/internal/pipeline/steps/common.go index 67d3bbd..6c5b80b 100644 --- a/internal/pipeline/steps/common.go +++ b/internal/pipeline/steps/common.go @@ -98,15 +98,38 @@ var reviewFindingsSchema = json.RawMessage(`{ "items": { "type": "object", "properties": { - "id": {"type": "string"}, + "prior_id": {"type": "string"}, + "prior_continuity_token": {"type": "string"}, "severity": {"type": "string", "enum": ["error", "warning", "info"]}, "file": {"type": "string"}, "line": {"type": "integer"}, "description": {"type": "string"}, "action": {"type": "string", "enum": ["no-op", "auto-fix", "ask-user"]}, - "review_scope": {"type": "string", "enum": ["source", "pipeline-owned-delivery", "external-delivery"]} + "review_scope": {"type": "string", "enum": ["source", "pipeline-owned-delivery", "external-delivery"]}, + "evidence": { + "type": "object", + "properties": { + "tested": {"type": "array", "items": {"type": "string"}}, + "testing_summary": {"type": "string"}, + "artifacts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": {"type": "string"}, + "label": {"type": "string"}, + "path": {"type": "string"}, + "url": {"type": "string"}, + "content": {"type": "string"} + }, + "required": ["label"] + } + } + }, + "required": ["tested", "testing_summary", "artifacts"] + } }, - "required": ["severity", "description", "action", "review_scope"] + "required": ["severity", "description", "action", "review_scope", "evidence"] } }, "tested": { diff --git a/internal/pipeline/steps/common_fix.go b/internal/pipeline/steps/common_fix.go index 6d2e691..22d34d1 100644 --- a/internal/pipeline/steps/common_fix.go +++ b/internal/pipeline/steps/common_fix.go @@ -167,20 +167,29 @@ func commitAgentFixes(sctx *pipeline.StepContext, stepName types.StepName, summa if err := assertPipelineHeadContinuity(sctx, stepName); err != nil { return err } - if err := adoptBranchRef(sctx, headSHA); err != nil { - return err - } startingHead := strings.TrimSpace(sctx.ReviewStartingHeadSHA) if startingHead == "" { startingHead = sctx.Run.HeadSHA } + var rollbackRange func() error + if stepPersistsUncertifiedReview(stepName) { + rollbackRange, err = pipeline.PersistUncertifiedPipelineRangeWithRollback(sctx, startingHead, headSHA) + if err != nil { + return fmt.Errorf("persist uncertified review range before %s head adoption: %w", stepName, err) + } + } + if err := adoptBranchRef(sctx, headSHA); err != nil { + if rollbackRange != nil { + if rollbackErr := rollbackRange(); rollbackErr != nil { + return fmt.Errorf("%w; restore uncertified review range: %v", err, rollbackErr) + } + } + return err + } sctx.Run.HeadSHA = headSHA if err := sctx.DB.UpdateRunHeadSHA(sctx.Run.ID, headSHA); err != nil { return err } - if stepName == types.StepReview { - pipeline.PersistUncertifiedPipelineRange(sctx, startingHead, headSHA) - } if commitMessage != "" { sctx.Log(fmt.Sprintf("committed agent fixes: %s", commitMessage)) } else { @@ -189,6 +198,15 @@ func commitAgentFixes(sctx *pipeline.StepContext, stepName types.StepName, summa return nil } +func stepPersistsUncertifiedReview(stepName types.StepName) bool { + switch stepName { + case types.StepReview, types.StepTest, types.StepDocument, types.StepLint, types.StepCI: + return true + default: + return false + } +} + func extractCommitSummary(result *agent.Result) (string, error) { var summary commitSummary if result.Output == nil { diff --git a/internal/pipeline/steps/common_git.go b/internal/pipeline/steps/common_git.go index 0850a4b..96d802e 100644 --- a/internal/pipeline/steps/common_git.go +++ b/internal/pipeline/steps/common_git.go @@ -152,9 +152,13 @@ func normalizedBranchRef(ref string) string { // adoptBranchRef applies the shared branch-ref adoption policy (git.AdoptBranchRef) // with the step's own command environment. func adoptBranchRef(sctx *pipeline.StepContext, newHeadSHA string) error { + return adoptBranchRefFrom(sctx, newHeadSHA, sctx.Run.HeadSHA) +} + +func adoptBranchRefFrom(sctx *pipeline.StepContext, newHeadSHA, currentHeadSHA string) error { return git.AdoptBranchRef(func(args ...string) (string, error) { return stepGitRun(sctx, args...) - }, sctx.Run.Branch, newHeadSHA, sctx.Run.HeadSHA) + }, sctx.Run.Branch, newHeadSHA, currentHeadSHA) } // resolveUpstreamURL returns the upstream URL to push or query. Ordinarily it diff --git a/internal/pipeline/steps/common_test.go b/internal/pipeline/steps/common_test.go index 9c59070..80c587f 100644 --- a/internal/pipeline/steps/common_test.go +++ b/internal/pipeline/steps/common_test.go @@ -605,49 +605,84 @@ func TestCommitAgentFixes_PersistsUncertifiedRangeForReview(t *testing.T) { } } -func TestCommitAgentFixes_LintDoesNotPersistUncertifiedRange(t *testing.T) { - t.Parallel() +func TestCommitAgentFixes_RefusesReviewHeadWhenRangePersistenceFails(t *testing.T) { dir, baseSHA, headSHA := setupGitRepo(t) gitCmd(t, dir, "checkout", "--detach", headSHA) - - ag := &mockAgent{name: "test"} - sctx := newTestContextWithDBRecords(t, ag, dir, baseSHA, headSHA, config.Commands{}) - sctx.ReviewStartingHeadSHA = headSHA - if err := os.WriteFile(filepath.Join(dir, "lint-fix.txt"), []byte("fixed"), 0o644); err != nil { + sctx := newTestContextWithDBRecords(t, &mockAgent{name: "test"}, dir, baseSHA, headSHA, config.Commands{}) + originalRunID := sctx.Run.ID + sctx.Repo.ID = "missing-repo" + if err := os.WriteFile(filepath.Join(dir, "review-fix.txt"), []byte("fixed"), 0o644); err != nil { t.Fatal(err) } - if err := commitAgentFixes(sctx, types.StepLint, "apply fix", "fallback"); err != nil { - t.Fatal(err) + err := commitAgentFixes(sctx, types.StepReview, "apply fix", "fallback") + if err == nil || !strings.Contains(err.Error(), "persist uncertified review range") { + t.Fatalf("commitAgentFixes() error = %v, want persistence refusal", err) } - got, err := sctx.DB.GetUncertifiedPipelineRange(sctx.Repo.ID, sctx.Run.Branch) + if got := gitCmd(t, dir, "rev-parse", "refs/heads/feature"); got != headSHA { + t.Fatalf("branch head = %s, want unchanged %s", got, headSHA) + } + if sctx.Run.HeadSHA != headSHA { + t.Fatalf("in-memory run head = %s, want unchanged %s", sctx.Run.HeadSHA, headSHA) + } + stored, err := sctx.DB.GetRun(originalRunID) if err != nil { t.Fatal(err) } - if got != nil { - t.Fatalf("lint persist = %#v, want no uncertified range", got) + if stored.HeadSHA != headSHA { + t.Fatalf("persisted run head = %s, want unchanged %s", stored.HeadSHA, headSHA) } } -func TestCommitAgentFixes_DocumentDoesNotPersistUncertifiedRange(t *testing.T) { - t.Parallel() +func TestCommitAgentFixes_RestoresUncertifiedRangeWhenRefAdoptionFails(t *testing.T) { dir, baseSHA, headSHA := setupGitRepo(t) gitCmd(t, dir, "checkout", "--detach", headSHA) - - ag := &mockAgent{name: "test"} - sctx := newTestContextWithDBRecords(t, ag, dir, baseSHA, headSHA, config.Commands{}) - sctx.ReviewStartingHeadSHA = headSHA - if err := os.WriteFile(filepath.Join(dir, "docs-fix.txt"), []byte("fixed"), 0o644); err != nil { + sctx := newTestContextWithDBRecords(t, &mockAgent{name: "test"}, dir, baseSHA, headSHA, config.Commands{}) + if err := sctx.DB.UpsertUncertifiedPipelineRange(sctx.Repo.ID, sctx.Run.Branch, baseSHA, headSHA, sctx.Run.ID); err != nil { t.Fatal(err) } - if err := commitAgentFixes(sctx, types.StepDocument, "apply fix", "fallback"); err != nil { + tree := gitCmd(t, dir, "rev-parse", headSHA+"^{tree}") + unrelated := gitCmd(t, dir, "commit-tree", tree, "-m", "unrelated branch head") + gitCmd(t, dir, "update-ref", "refs/heads/feature", unrelated) + if err := os.WriteFile(filepath.Join(dir, "review-fix.txt"), []byte("fixed"), 0o644); err != nil { t.Fatal(err) } - got, err := sctx.DB.GetUncertifiedPipelineRange(sctx.Repo.ID, sctx.Run.Branch) - if err != nil { - t.Fatal(err) + + err := commitAgentFixes(sctx, types.StepReview, "apply fix", "fallback") + if err == nil || !strings.Contains(err.Error(), "refusing to move branch ref") { + t.Fatalf("commitAgentFixes() error = %v, want ref-adoption refusal", err) + } + got, getErr := sctx.DB.GetUncertifiedPipelineRange(sctx.Repo.ID, sctx.Run.Branch) + if getErr != nil { + t.Fatal(getErr) } - if got != nil { - t.Fatalf("document persist = %#v, want no uncertified range", got) + if got == nil || got.FromSHA != baseSHA || got.ToSHA != headSHA || got.SourceRunID != sctx.Run.ID { + t.Fatalf("failed adoption left rewritten uncertified range: %#v", got) + } +} + +func TestCommitAgentFixes_PersistsUncertifiedRangeForPostReviewSteps(t *testing.T) { + for _, stepName := range []types.StepName{types.StepTest, types.StepDocument, types.StepLint} { + t.Run(string(stepName), func(t *testing.T) { + dir, baseSHA, headSHA := setupGitRepo(t) + gitCmd(t, dir, "checkout", "--detach", headSHA) + + ag := &mockAgent{name: "test"} + sctx := newTestContextWithDBRecords(t, ag, dir, baseSHA, headSHA, config.Commands{}) + sctx.ReviewStartingHeadSHA = headSHA + if err := os.WriteFile(filepath.Join(dir, string(stepName)+"-fix.txt"), []byte("fixed"), 0o644); err != nil { + t.Fatal(err) + } + if err := commitAgentFixes(sctx, stepName, "apply fix", "fallback"); err != nil { + t.Fatal(err) + } + got, err := sctx.DB.GetUncertifiedPipelineRange(sctx.Repo.ID, sctx.Run.Branch) + if err != nil { + t.Fatal(err) + } + if got == nil || got.FromSHA != headSHA || got.ToSHA != sctx.Run.HeadSHA || got.SourceRunID != sctx.Run.ID { + t.Fatalf("%s uncertified range = %#v", stepName, got) + } + }) } } @@ -1391,6 +1426,24 @@ func TestReviewFindingsSchema_ActionAtItemLevel(t *testing.T) { } } +func TestReviewFindingsSchema_UsesExplicitPriorLineageClaims(t *testing.T) { + var parsed map[string]interface{} + if err := json.Unmarshal(reviewFindingsSchema, &parsed); err != nil { + t.Fatal(err) + } + props := parsed["properties"].(map[string]interface{}) + items := props["findings"].(map[string]interface{})["items"].(map[string]interface{}) + itemProps := items["properties"].(map[string]interface{}) + for _, name := range []string{"prior_id", "prior_continuity_token"} { + if _, ok := itemProps[name]; !ok { + t.Fatalf("review finding schema missing %s", name) + } + } + if _, ok := itemProps["id"]; ok { + t.Fatal("review finding schema accepts current pipeline IDs as fresh claims") + } +} + func TestReviewFindingsSchema_AllowsTestingMetadata(t *testing.T) { t.Parallel() var parsed map[string]interface{} @@ -1419,6 +1472,31 @@ func TestReviewFindingsSchema_AllowsTestingMetadata(t *testing.T) { } } +func TestReviewFindingsSchemaRequiresLineageEvidence(t *testing.T) { + t.Parallel() + var parsed map[string]interface{} + if err := json.Unmarshal(reviewFindingsSchema, &parsed); err != nil { + t.Fatal(err) + } + props := parsed["properties"].(map[string]interface{}) + items := props["findings"].(map[string]interface{})["items"].(map[string]interface{}) + itemProps := items["properties"].(map[string]interface{}) + evidence := itemProps["evidence"].(map[string]interface{}) + evidenceProps := evidence["properties"].(map[string]interface{}) + for _, name := range []string{"tested", "testing_summary", "artifacts"} { + if _, ok := evidenceProps[name]; !ok { + t.Fatalf("review finding evidence missing %s", name) + } + } + required := items["required"].([]interface{}) + for _, value := range required { + if value == "evidence" { + return + } + } + t.Fatal("review finding schema does not require evidence") +} + func TestSanitizedPreviousFindingsForPrompt_PreservesMultilineDescriptions(t *testing.T) { t.Parallel() raw, err := types.MarshalFindingsJSON(types.Findings{ diff --git a/internal/pipeline/steps/evidence_test.go b/internal/pipeline/steps/evidence_test.go index e8e908f..6dec417 100644 --- a/internal/pipeline/steps/evidence_test.go +++ b/internal/pipeline/steps/evidence_test.go @@ -28,6 +28,17 @@ func TestTestEvidenceDir_ReadsTheExecutorResolvedDirectory(t *testing.T) { // NM_HOME and not in the shared system temp directory the old code used. func TestTestEvidenceDir_DefaultResolutionStaysUnderTheAppRoot(t *testing.T) { root := t.TempDir() + canonicalHome, canonicalHomeSet := os.LookupEnv("NS_HOME") + if err := os.Unsetenv("NS_HOME"); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if canonicalHomeSet { + _ = os.Setenv("NS_HOME", canonicalHome) + } else { + _ = os.Unsetenv("NS_HOME") + } + }) t.Setenv("NM_HOME", root) p, err := paths.New() if err != nil { diff --git a/internal/pipeline/steps/helpers_test.go b/internal/pipeline/steps/helpers_test.go index fa7a7bb..b1713c6 100644 --- a/internal/pipeline/steps/helpers_test.go +++ b/internal/pipeline/steps/helpers_test.go @@ -145,11 +145,19 @@ func newTestContext(t *testing.T, ag agent.Agent, workDir, baseSHA, headSHA stri t.Fatal(err) } t.Cleanup(func() { database.Close() }) + repo, err := database.InsertRepoWithID("repo-1", workDir, "https://github.com/test/repo", "main") + if err != nil { + t.Fatal(err) + } + run, err := database.InsertRun(repo.ID, "refs/heads/feature", headSHA, baseSHA) + if err != nil { + t.Fatal(err) + } return &pipeline.StepContext{ Ctx: context.Background(), - Run: &db.Run{ID: "run-1", RepoID: "repo-1", Branch: "refs/heads/feature", HeadSHA: headSHA, BaseSHA: baseSHA}, - Repo: &db.Repo{ID: "repo-1", WorkingPath: workDir, UpstreamURL: "https://github.com/test/repo", DefaultBranch: "main"}, + Run: run, + Repo: repo, // The executor resolves this from the app root in production. Tests get // a per-test directory so a step under test can never write evidence // into a shared location the next test would then observe. @@ -395,8 +403,10 @@ func fakeGlab(t *testing.T, mrViewJSON string) (env []string, logFile string) { return env, logFile } -// newTestContextWithDBRecords is like newTestContext but also inserts -// repo and run records into the database so GetRun works after updates. +// newTestContextWithDBRecords retains the explicit name used by tests whose +// assertions depend on persisted run state. newTestContext now always creates +// production-valid repo and run rows because post-review head adoption writes +// a foreign-keyed recovery boundary. func recordReviewApproval(t *testing.T, sctx *pipeline.StepContext, headSHA string) { t.Helper() if err := sctx.DB.UpdateRunReviewApprovedHeadSHA(sctx.Run.ID, headSHA); err != nil { @@ -408,20 +418,7 @@ func recordReviewApproval(t *testing.T, sctx *pipeline.StepContext, headSHA stri func newTestContextWithDBRecords(t *testing.T, ag agent.Agent, workDir, baseSHA, headSHA string, cmds config.Commands) *pipeline.StepContext { t.Helper() - sctx := newTestContext(t, ag, workDir, baseSHA, headSHA, cmds) - - // Insert repo + run records so DB queries work - repo, err := sctx.DB.InsertRepo(workDir, "https://github.com/test/repo", "main") - if err != nil { - t.Fatal(err) - } - run, err := sctx.DB.InsertRun(repo.ID, "refs/heads/feature", headSHA, baseSHA) - if err != nil { - t.Fatal(err) - } - sctx.Run = run - sctx.Repo = repo - return sctx + return newTestContext(t, ag, workDir, baseSHA, headSHA, cmds) } // fakeCIGH creates a fake gh binary that responds to CI-related diff --git a/internal/pipeline/steps/pipeline_delivery.go b/internal/pipeline/steps/pipeline_delivery.go index 24d3ad6..e87050f 100644 --- a/internal/pipeline/steps/pipeline_delivery.go +++ b/internal/pipeline/steps/pipeline_delivery.go @@ -1,8 +1,7 @@ package steps import ( - "fmt" - + "github.com/Blakeolson21/no-slop/internal/pipeline" "github.com/Blakeolson21/no-slop/internal/types" ) @@ -21,44 +20,7 @@ func pipelineDeliveryPhaseClause() string { // // Returns the filtered findings and how many items were dropped. func stripDeferredPipelineOwnedDeliveryFindings(findings Findings) (Findings, int) { - if len(findings.Items) == 0 { - return findings, 0 - } - kept := make([]Finding, 0, len(findings.Items)) - dropped := 0 - for _, item := range findings.Items { - if isDeferredPipelineOwnedDeliveryFinding(item) { - dropped++ - continue - } - kept = append(kept, item) - } - if dropped == 0 { - return findings, 0 - } - out := findings - out.Items = kept - out.Summary = filteredReviewSummary(kept) - switch findings.RiskScope { - case types.FindingsRiskScopePipelineOwnedDelivery: - out.RiskLevel = "low" - out.RiskRationale = "no delivery-independent review risk was reported" - out.RiskScope = types.FindingsRiskScopeSourceOrExternal - case types.FindingsRiskScopeSourceOrExternal: - default: - out.RiskRationale = "review risk retained after deferred delivery filtering" - } - return out, dropped -} - -func filteredReviewSummary(items []Finding) string { - if len(items) == 0 { - return "no review findings remain" - } - if len(items) == 1 { - return "1 review finding remains" - } - return fmt.Sprintf("%d review findings remain", len(items)) + return pipeline.FilterDeferredPipelineOwnedDeliveryFindings(findings) } // isDeferredPipelineOwnedDeliveryFinding reports whether a finding's claim is diff --git a/internal/pipeline/steps/pr.go b/internal/pipeline/steps/pr.go index e683b29..d9cea41 100644 --- a/internal/pipeline/steps/pr.go +++ b/internal/pipeline/steps/pr.go @@ -1,8 +1,10 @@ package steps import ( + "crypto/rand" "encoding/json" "fmt" + "io" "log/slog" "strings" "unicode/utf8" @@ -17,11 +19,14 @@ import ( ) // PRStep creates or updates a pull request via the provider CLI or API. -type PRStep struct{} +type PRStep struct { + publicationNonceReader io.Reader +} type prContent struct { - Title string `json:"title"` - Body string `json:"body"` + Title string `json:"title"` + Body string `json:"body"` + PublicationNonce string `json:"-"` } var prContentSchema = json.RawMessage(`{ @@ -89,35 +94,47 @@ func (s *PRStep) Execute(sctx *pipeline.StepContext) (*pipeline.StepOutcome, err } if existing != nil { sctx.Log(fmt.Sprintf("pull request already exists: %s, updating...", describePR(existing))) - updated, err := host.UpdatePR(ctx, existing, scm.PRContent(content)) + updated, err := host.UpdatePR(ctx, existing, scm.PRContent{Title: content.Title, Body: content.Body}) if err != nil { - sctx.Log(fmt.Sprintf("warning: failed to update PR: %v", err)) - updated = existing + return nil, fmt.Errorf("update pull request: %w", err) } + prURL := existing.URL if updated != nil && updated.URL != "" { - if err := sctx.DB.UpdateRunPRURL(sctx.Run.ID, updated.URL); err != nil { - slog.Warn("failed to persist PR URL", "run", sctx.Run.ID, "url", updated.URL, "err", err) - } - return &pipeline.StepOutcome{PRURL: updated.URL}, nil + prURL = updated.URL + } + if strings.TrimSpace(prURL) == "" { + return nil, fmt.Errorf("updated pull request has no URL") } - return &pipeline.StepOutcome{}, nil + if err := persistPublishedPR(sctx, provider, content.PublicationNonce, prURL); err != nil { + return nil, fmt.Errorf("persist updated pull request: %w", err) + } + return &pipeline.StepOutcome{PRURL: prURL}, nil } sctx.Log("creating pull request...") - created, err := host.CreatePR(ctx, branch, sctx.Repo.DefaultBranch, scm.PRContent(content)) + created, err := host.CreatePR(ctx, branch, sctx.Repo.DefaultBranch, scm.PRContent{Title: content.Title, Body: content.Body}) if err != nil { return nil, err } if created == nil || strings.TrimSpace(created.URL) == "" { - return &pipeline.StepOutcome{}, nil + return nil, fmt.Errorf("created pull request has no URL") } sctx.Log(fmt.Sprintf("created pull request: %s", created.URL)) - if err := sctx.DB.UpdateRunPRURL(sctx.Run.ID, created.URL); err != nil { - slog.Warn("failed to persist PR URL", "run", sctx.Run.ID, "url", created.URL, "err", err) + if err := persistPublishedPR(sctx, provider, content.PublicationNonce, created.URL); err != nil { + return nil, fmt.Errorf("persist created pull request: %w", err) } return &pipeline.StepOutcome{PRURL: created.URL}, nil } +func persistPublishedPR(sctx *pipeline.StepContext, provider scm.Provider, publicationNonce, prURL string) error { + if provider == scm.ProviderGitHub { + if err := persistExpectedAttestationPublication(sctx, publicationNonce); err != nil { + return fmt.Errorf("persist expected attestation boundary: %w", err) + } + } + return sctx.DB.UpdateRunPRURL(sctx.Run.ID, prURL) +} + func describePR(pr *scm.PR) string { if pr == nil { return "" @@ -132,13 +149,30 @@ func describePR(pr *scm.PR) string { } func (s *PRStep) buildPRContent(sctx *pipeline.StepContext, branch, baseSHA string, bodyLimit int) (prContent, error) { + publicationNonce, err := s.newPublicationNonce() + if err != nil { + return prContent{}, fmt.Errorf("generate PR attestation publication nonce: %w", err) + } + publicationMarker := publicationEventCommentPrefix + publicationNonce + pipelineAttestationCommentClosingToken + providerLimited := bodyLimit > 0 + providerBodyLimit := bodyLimit + githubBodyLimit := maxPullRequestBodyBytes - len(publicationMarker+"\n\n") + if providerLimited { + providerBodyLimit -= scm.PRBodyLen(publicationMarker + "\n\n") + } + if githubBodyLimit < 1 || (providerLimited && providerBodyLimit < 1) { + return prContent{}, fmt.Errorf("PR body limit cannot fit publication identity") + } ctx := sctx.Ctx diffStat, _ := git.Run(ctx, sctx.WorkDir, "diff", "--stat", baseSHA+".."+sctx.Run.HeadSHA) finalDiff, err := git.Run(ctx, sctx.WorkDir, "diff", "--name-status", baseSHA+".."+sctx.Run.HeadSHA) if err != nil { return prContent{}, fmt.Errorf("read final branch diff: %w", err) } - pipelineMD, riskLine, testingMD := s.buildPipelineSection(sctx) + pipelineMD, riskLine, testingMD, err := s.buildPipelineSection(sctx, publicationNonce) + if err != nil { + return prContent{}, err + } prompt := fmt.Sprintf(`Draft a pull request title and summary for the full branch delta. @@ -164,7 +198,7 @@ Diff stat: Final diff paths and statuses: %s%s%s`, branch, baseSHA, sctx.Run.HeadSHA, sctx.Repo.DefaultBranch, conventional.ReleaseTypeRule, diffStat, finalDiff, userIntentPromptSection(sctx), executionContextPromptSection()) - prompt += prBodyBudgetPromptSection(bodyLimit) + prompt += prBodyBudgetPromptSection(providerBodyLimit) result, err := sctx.Agent.Run(ctx, agent.RunOpts{ Prompt: prompt, @@ -174,7 +208,10 @@ Final diff paths and statuses: }) if err != nil { slog.Warn("agent failed for PR content, using fallback", "error", err) - return fallbackPRContent(sctx, finalDiff, riskLine, testingMD, pipelineMD, bodyLimit), nil + content := fallbackPRContentWithinLimits(sctx, finalDiff, riskLine, testingMD, pipelineMD, providerBodyLimit, githubBodyLimit) + content.Body = publicationMarker + "\n\n" + content.Body + content.PublicationNonce = publicationNonce + return content, nil } var content prContent @@ -190,28 +227,44 @@ Final diff paths and statuses: if content.Title != originalTitle { slog.Warn("tightened agent PR title type", "from", originalTitle, "to", content.Title) } - if bodyLimit > 0 { - content.Body = assemblePRBody(sctx, content.Body, riskLine, testingMD, pipelineMD, bodyLimit) + if providerBodyLimit > 0 { + content.Body = assemblePRBody(sctx, content.Body, riskLine, testingMD, pipelineMD, providerBodyLimit) } else { - content.Body = buildPRBody(content.Body, riskLine, testingMD, pipelineMD, sctx) + content.Body = buildPRBodyWithinLimit(content.Body, riskLine, testingMD, pipelineMD, sctx, githubBodyLimit) } + content.Body = publicationMarker + "\n\n" + content.Body + content.PublicationNonce = publicationNonce return content, nil } } } - return fallbackPRContent(sctx, finalDiff, riskLine, testingMD, pipelineMD, bodyLimit), nil + content = fallbackPRContentWithinLimits(sctx, finalDiff, riskLine, testingMD, pipelineMD, providerBodyLimit, githubBodyLimit) + content.Body = publicationMarker + "\n\n" + content.Body + content.PublicationNonce = publicationNonce + return content, nil +} + +func (s *PRStep) newPublicationNonce() (string, error) { + reader := s.publicationNonceReader + if reader == nil { + reader = rand.Reader + } + var nonce [16]byte + if _, err := io.ReadFull(reader, nonce[:]); err != nil { + return "", err + } + return fmt.Sprintf("%x", nonce[:]), nil } // buildPipelineSection queries step results and rounds from the DB and // produces the deterministic pipeline, risk, and testing sections. These are // scoped to this run's own steps and rounds, so they already describe only // the final terminal state each step reached in this run. -func (s *PRStep) buildPipelineSection(sctx *pipeline.StepContext) (pipelineMD, riskLine, testingMD string) { +func (s *PRStep) buildPipelineSection(sctx *pipeline.StepContext, publicationNonce string) (pipelineMD, riskLine, testingMD string, err error) { steps, err := sctx.DB.GetStepsByRun(sctx.Run.ID) if err != nil { - slog.Warn("failed to query step results for pipeline summary", "error", err) - return "", "", "" + return "", "", "", fmt.Errorf("query step results for pipeline summary: %w", err) } rounds := make(map[string][]*db.StepRound, len(steps)) @@ -224,9 +277,9 @@ func (s *PRStep) buildPipelineSection(sctx *pipeline.StepContext) (pipelineMD, r rounds[sr.ID] = r } - pipelineMD, riskLine = BuildPipelineSummary(steps, rounds, sctx.Run.HeadSHA) + pipelineMD, riskLine = buildPipelineSummary(steps, rounds, sctx.Run.HeadSHA, publicationNonce) testingMD = BuildTestingSummaryForPR(steps, rounds, sctx.Repo.UpstreamURL, sctx.Run.HeadSHA, sctx.WorkDir, testEvidenceDir(sctx), publishRunEvidence(sctx)) - return pipelineMD, riskLine, testingMD + return pipelineMD, riskLine, testingMD, nil } // unwrapNestedPRBody detects when the agent returned the body as a @@ -335,8 +388,12 @@ func appendGeneratedSections(body, riskLine, testingMD, pipelineMD string) strin } func buildPRBody(body, riskLine, testingMD, pipelineMD string, sctx *pipeline.StepContext) string { + return buildPRBodyWithinLimit(body, riskLine, testingMD, pipelineMD, sctx, maxPullRequestBodyBytes) +} + +func buildPRBodyWithinLimit(body, riskLine, testingMD, pipelineMD string, sctx *pipeline.StepContext, maxBytes int) string { body = stripGeneratedSections(body) - sections := appendGeneratedSectionsToCleanBody(body, riskLine, testingMD, pipelineMD) + sections := appendGeneratedSectionsToCleanBodyWithinLimit(body, riskLine, testingMD, pipelineMD, maxBytes) cleaned := cleanedUserIntent(sctx) if cleaned == "" { return sections @@ -344,17 +401,17 @@ func buildPRBody(body, riskLine, testingMD, pipelineMD string, sctx *pipeline.St intent := "## Intent\n\n" + cleaned separator := "\n\n" - if len(intent)+len(separator)+len(sections) <= maxPullRequestBodyBytes { + if len(intent)+len(separator)+len(sections) <= maxBytes { return intent + separator + sections } - sectionsBudget := maxPullRequestBodyBytes - len(separator) - len(intent) + sectionsBudget := maxBytes - len(separator) - len(intent) minimumSectionsBytes := len(pipelineSectionHeader(pipelineMD)) if sectionsBudget > 0 && (minimumSectionsBytes == 0 || sectionsBudget >= minimumSectionsBytes) { sections = appendGeneratedSectionsToCleanBodyWithinLimit(body, riskLine, testingMD, pipelineMD, sectionsBudget) return intent + separator + sections } - intentBudget := maxPullRequestBodyBytes - len(separator) - len(sections) + intentBudget := maxBytes - len(separator) - len(sections) if intentBudget <= 0 { return sections } @@ -1042,6 +1099,10 @@ func prependIntentSection(body string, sctx *pipeline.StepContext) string { } func fallbackPRContent(sctx *pipeline.StepContext, finalDiff, riskLine, testingMD, pipelineMD string, bodyLimit int) prContent { + return fallbackPRContentWithinLimits(sctx, finalDiff, riskLine, testingMD, pipelineMD, bodyLimit, maxPullRequestBodyBytes) +} + +func fallbackPRContentWithinLimits(sctx *pipeline.StepContext, finalDiff, riskLine, testingMD, pipelineMD string, bodyLimit, githubBodyLimit int) prContent { title := "chore: update pull request" diffSummary := strings.TrimSpace(finalDiff) body := "## What Changed\n\nFinal changed paths and statuses:\n\n```text\n" + escapeMarkdownFence(diffSummary) + "\n```" @@ -1051,7 +1112,7 @@ func fallbackPRContent(sctx *pipeline.StepContext, finalDiff, riskLine, testingM if bodyLimit > 0 { body = assemblePRBody(sctx, body, riskLine, testingMD, pipelineMD, bodyLimit) } else { - body = buildPRBody(body, riskLine, testingMD, pipelineMD, sctx) + body = buildPRBodyWithinLimit(body, riskLine, testingMD, pipelineMD, sctx, githubBodyLimit) } return prContent{ Title: title, diff --git a/internal/pipeline/steps/pr_test.go b/internal/pipeline/steps/pr_test.go index 281a349..25f47c4 100644 --- a/internal/pipeline/steps/pr_test.go +++ b/internal/pipeline/steps/pr_test.go @@ -1,6 +1,7 @@ package steps import ( + "bytes" "context" "encoding/json" "fmt" @@ -11,6 +12,7 @@ import ( "path/filepath" "strings" "testing" + "time" "unicode/utf8" "github.com/Blakeolson21/no-slop/internal/agent" @@ -49,8 +51,14 @@ func TestPRStep_GhNotAvailable(t *testing.T) { func TestPRStep_UpdatesExistingPR(t *testing.T) { t.Parallel() dir, baseSHA, headSHA := setupGitRepo(t) + boundary := time.Date(2026, 8, 23, 18, 42, 31, 0, time.UTC) + unrelatedMutation := boundary.Add(time.Minute) env, logFile := fakeGH(t, "https://github.com/test/repo/pull/42") + env = append(env, + "FAKE_CLI_GH_UPDATE_RESPONSE_AT="+boundary.Format(time.RFC3339Nano), + "FAKE_CLI_GH_PR_UPDATED_AT="+unrelatedMutation.Format(time.RFC3339Nano), + ) ag := &mockAgent{name: "test"} sctx := newTestContextWithDBRecords(t, ag, dir, baseSHA, headSHA, config.Commands{}) @@ -62,8 +70,25 @@ func TestPRStep_UpdatesExistingPR(t *testing.T) { if err := sctx.DB.UpdateStepStatus(reviewStep.ID, types.StepStatusCompleted); err != nil { t.Fatal(err) } + budget := &checkRerunBudget{ + spent: map[string]int{"build": 1}, + } + encoded, err := budget.marshal() + if err != nil { + t.Fatal(err) + } + if err := sctx.DB.SetRunCIRerunState(sctx.Run.ID, encoded); err != nil { + t.Fatal(err) + } + priorAttestation, err := json.Marshal(expectedAttestationState{HeadSHA: baseSHA, PublicationNonce: "ffeeddccbbaa99887766554433221100"}) + if err != nil { + t.Fatal(err) + } + if err := sctx.DB.SetRunCIAttestationState(sctx.Run.ID, string(priorAttestation)); err != nil { + t.Fatal(err) + } - step := &PRStep{} + step := &PRStep{publicationNonceReader: bytes.NewReader([]byte{0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff})} outcome, err := step.Execute(sctx) if err != nil { t.Fatal(err) @@ -72,23 +97,41 @@ func TestPRStep_UpdatesExistingPR(t *testing.T) { t.Error("pr step should never need approval") } - // Verify gh pr edit was called to update the PR body logData, err := os.ReadFile(logFile) if err != nil { t.Fatal(err) } ghLog := string(logData) - if !strings.Contains(ghLog, "pr edit") { - t.Errorf("expected gh pr edit to be called, got:\n%s", ghLog) + if !strings.Contains(ghLog, "api --method PATCH") { + t.Errorf("expected GitHub API update to be called, got:\n%s", ghLog) } if !strings.Contains(ghLog, "--body") { - t.Errorf("expected --body flag in gh pr edit, got:\n%s", ghLog) + t.Errorf("expected PR body on stdin, got:\n%s", ghLog) } if !strings.Contains(ghLog, noMistakesPRSignature) { t.Errorf("expected updated PR body to include no-slop signature, got:\n%s", ghLog) } + const publishedBodyMarker = "stdin --body " + publishedBodyAt := strings.LastIndex(ghLog, publishedBodyMarker) + if publishedBodyAt < 0 { + t.Fatalf("updated PR request body was not recorded:\n%s", ghLog) + } + var published struct { + Body string `json:"body"` + } + if err := json.Unmarshal([]byte(strings.TrimSpace(ghLog[publishedBodyAt+len(publishedBodyMarker):])), &published); err != nil { + t.Fatalf("parse updated PR request body: %v", err) + } + if nonce := parsePipelineAttestationForTest(t, published.Body).PublicationNonce; nonce != testPublicationNonce { + t.Fatalf("updated PR publication nonce = %q, want %q", nonce, testPublicationNonce) + } + if !strings.HasPrefix(published.Body, publicationEventCommentPrefix+testPublicationNonce+pipelineAttestationCommentClosingToken+"\n\n") { + t.Fatalf("updated PR body does not expose publication identity first: %q", published.Body) + } + if strings.Contains(ghLog, "pr view 42 --repo test/repo --json updatedAt") { + t.Fatalf("attestation publication used mutable PR state:\n%s", ghLog) + } - // Verify PR URL was stored run, err := sctx.DB.GetRun(sctx.Run.ID) if err != nil { t.Fatal(err) @@ -96,6 +139,41 @@ func TestPRStep_UpdatesExistingPR(t *testing.T) { if run.PRURL == nil || *run.PRURL != "https://github.com/test/repo/pull/42" { t.Errorf("PR URL = %v, want https://github.com/test/repo/pull/42", run.PRURL) } + encoded, err = sctx.DB.GetRunCIRerunState(sctx.Run.ID) + if err != nil { + t.Fatal(err) + } + persisted := &checkRerunBudget{} + if err := persisted.unmarshal(encoded); err != nil { + t.Fatal(err) + } + if persisted.used("build") != 1 { + t.Fatalf("persisted rerun budget = %#v", persisted) + } + encoded, err = sctx.DB.GetRunCIAttestationState(sctx.Run.ID) + if err != nil { + t.Fatal(err) + } + var attestation expectedAttestationState + if err := json.Unmarshal([]byte(encoded), &attestation); err != nil { + t.Fatal(err) + } + if attestation.HeadSHA != headSHA || attestation.PublicationNonce != testPublicationNonce { + t.Fatalf("persisted attestation expectation = %#v", attestation) + } +} + +func TestPRStep_FailsWhenExistingPRAttestationCannotBePublished(t *testing.T) { + dir, baseSHA, headSHA := setupGitRepo(t) + env, _ := fakeGH(t, "https://github.com/test/repo/pull/42") + env = append(env, "FAKE_CLI_GH_EDIT_ERROR=1") + sctx := newTestContextWithDBRecords(t, &mockAgent{name: "test"}, dir, baseSHA, headSHA, config.Commands{}) + sctx.Env = env + + _, err := (&PRStep{}).Execute(sctx) + if err == nil || !strings.Contains(err.Error(), "update pull request") { + t.Fatalf("PR update error = %v", err) + } } func TestPRStep_BitbucketUpdatesExistingPR(t *testing.T) { @@ -272,7 +350,7 @@ func TestPRStep_CreatesNewPR(t *testing.T) { t.Fatal(err) } - step := &PRStep{} + step := &PRStep{publicationNonceReader: bytes.NewReader([]byte{0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff})} outcome, err := step.Execute(sctx) if err != nil { t.Fatal(err) @@ -311,6 +389,32 @@ func TestPRStep_CreatesNewPR(t *testing.T) { if run.PRURL == nil || *run.PRURL != "https://github.com/test/repo/pull/99" { t.Errorf("PR URL = %v, want https://github.com/test/repo/pull/99", run.PRURL) } + encoded, err := sctx.DB.GetRunCIAttestationState(sctx.Run.ID) + if err != nil { + t.Fatal(err) + } + var attestation expectedAttestationState + if err := json.Unmarshal([]byte(encoded), &attestation); err != nil { + t.Fatal(err) + } + if attestation.HeadSHA != headSHA || attestation.PublicationNonce != testPublicationNonce { + t.Fatalf("created PR attestation expectation = %#v", attestation) + } + ci := &CIStep{} + if err := ci.loadExpectedAttestationState(sctx); err != nil { + t.Fatal(err) + } + stale := scm.Check{Name: requiredAttestationCheckName, Bucket: scm.CheckBucketFail, State: "FAILURE", Link: "other-pr"} + host := &attestationIdentityHost{identities: map[string]scm.CheckAttemptIdentity{ + "other-pr": {RunID: 1001, HeadSHA: headSHA, PublicationNonce: "ffeeddccbbaa99887766554433221100"}, + }} + filtered, err := ci.filterExpectedStaleAttestationChecks(sctx, host, []scm.Check{stale}) + if err != nil { + t.Fatal(err) + } + if len(filtered) != 1 || filtered[0].Bucket != scm.CheckBucketPending { + t.Fatalf("stale same-head check from another PR was accepted: %#v", filtered) + } } func TestPRStep_GitHubForkCreatesParentPRWithForkHead(t *testing.T) { @@ -809,7 +913,7 @@ func TestAssemblePRBody_RetainsAttestationWhenCoreExceedsAzureCap(t *testing.T) {StepName: types.StepReview, Status: types.StepStatusCompleted}, {StepName: types.StepTest, Status: types.StepStatusFailed}, } - attestation := buildPipelineAttestation(steps, testPipelineHeadSHA) + attestation := buildPipelineAttestation(steps, testPipelineHeadSHA, testPublicationNonce) pipelineMD := pipelineMarkdownForTest(strings.Repeat("review detail 😀 ", 1000)) pipelineMD = strings.Replace(pipelineMD, noMistakesPRSignature+"\n\n", noMistakesPRSignature+"\n\n"+attestation+"\n\n", 1) @@ -916,7 +1020,7 @@ func TestAppendGeneratedSections_RetainsPipelineAttestationWhenTruncated(t *test {StepName: types.StepReview, Status: types.StepStatusCompleted}, {StepName: types.StepTest, Status: types.StepStatusSkipped}, } - attestation := buildPipelineAttestation(steps, testPipelineHeadSHA) + attestation := buildPipelineAttestation(steps, testPipelineHeadSHA, testPublicationNonce) pipelineMD := pipelineMarkdownForTest(strings.Repeat("review round - "+strings.Repeat("x", 1000), 100)) pipelineMD = strings.Replace(pipelineMD, noMistakesPRSignature+"\n\n", noMistakesPRSignature+"\n\n"+attestation+"\n\n", 1) @@ -933,7 +1037,7 @@ func TestAppendGeneratedSections_RetainsAttestationWhenEssentialSectionsOverflow {StepName: types.StepReview, Status: types.StepStatusCompleted}, {StepName: types.StepTest, Status: types.StepStatusFailed}, } - attestation := buildPipelineAttestation(steps, testPipelineHeadSHA) + attestation := buildPipelineAttestation(steps, testPipelineHeadSHA, testPublicationNonce) pipelineMD := pipelineMarkdownForTest("review round 001") pipelineMD = strings.Replace(pipelineMD, noMistakesPRSignature+"\n\n", noMistakesPRSignature+"\n\n"+attestation+"\n\n", 1) diff --git a/internal/pipeline/steps/prsummary.go b/internal/pipeline/steps/prsummary.go index 7d6878a..1492c3f 100644 --- a/internal/pipeline/steps/prsummary.go +++ b/internal/pipeline/steps/prsummary.go @@ -2,6 +2,7 @@ package steps import ( "bytes" + "crypto/sha256" "encoding/json" "fmt" "html" @@ -22,18 +23,22 @@ const ( maxEmbeddedArtifactBytes = 16 * 1024 maxEmbeddedArtifactsTotalBytes = 32 * 1024 noMistakesPRSignature = "Updates from [git push no-slop](https://github.com/Blakeolson21/no-slop)" + legacyNoMistakesPRSignature = "Updates from [git push no-mistakes](https://github.com/Blakeolson21/no-slop)" + publicationEventCommentPrefix = "" ) type pipelineAttestation struct { - HeadSHA string `json:"head_sha"` - Steps []pipelineAttestationStep `json:"steps"` + HeadSHA string `json:"head_sha"` + PublicationNonce string `json:"publication_nonce"` + Steps []pipelineAttestationStep `json:"steps"` } type pipelineAttestationStep struct { - Step types.StepName `json:"step"` - Status types.StepStatus `json:"status"` + Step types.StepName `json:"step"` + Status types.StepStatus `json:"status"` + HeadSHA string `json:"head_sha"` } type testingArtifactRenderState struct { @@ -61,6 +66,11 @@ type testingSummaryOptions struct { // BuildPipelineSummary produces a deterministic markdown section from step results and rounds. func BuildPipelineSummary(steps []*db.StepResult, rounds map[string][]*db.StepRound, headSHA string) (string, string) { + digest := sha256.Sum256([]byte(strings.TrimSpace(headSHA))) + return buildPipelineSummary(steps, rounds, headSHA, fmt.Sprintf("%x", digest[:16])) +} + +func buildPipelineSummary(steps []*db.StepResult, rounds map[string][]*db.StepRound, headSHA, publicationNonce string) (string, string) { if len(steps) == 0 { return "", "" } @@ -86,7 +96,7 @@ func BuildPipelineSummary(steps []*db.StepResult, rounds map[string][]*db.StepRo b.WriteString("## Pipeline\n\n") b.WriteString(noMistakesPRSignature) b.WriteString("\n\n") - b.WriteString(buildPipelineAttestation(steps, headSHA)) + b.WriteString(buildPipelineAttestation(steps, headSHA, publicationNonce)) b.WriteString("\n\n") for i, detail := range detailBlocks { if i > 0 { @@ -100,20 +110,27 @@ func BuildPipelineSummary(steps []*db.StepResult, rounds map[string][]*db.StepRo } // buildPipelineAttestation records the exact step lifecycle snapshot available -// when no-mistakes writes the PR body. Its compact JSON is deliberately data -// only: consumers decide their own policy from the step names and statuses. -func buildPipelineAttestation(steps []*db.StepResult, headSHA string) string { +// when no-slop writes the PR body. Its compact JSON is deliberately data only: +// consumers decide their own policy from step names, statuses, and certified +// heads. +func buildPipelineAttestation(steps []*db.StepResult, headSHA, publicationNonce string) string { attestation := pipelineAttestation{ - HeadSHA: headSHA, - Steps: make([]pipelineAttestationStep, 0, len(steps)), + HeadSHA: headSHA, + PublicationNonce: publicationNonce, + Steps: make([]pipelineAttestationStep, 0, len(steps)), } for _, sr := range steps { if sr == nil { continue } + certifiedHeadSHA := "" + if sr.CertifiedHeadSHA != nil { + certifiedHeadSHA = *sr.CertifiedHeadSHA + } attestation.Steps = append(attestation.Steps, pipelineAttestationStep{ - Step: sr.StepName, - Status: sr.Status, + Step: sr.StepName, + Status: sr.Status, + HeadSHA: certifiedHeadSHA, }) } sort.SliceStable(attestation.Steps, func(i, j int) bool { diff --git a/internal/pipeline/steps/prsummary_test.go b/internal/pipeline/steps/prsummary_test.go index 28464cd..5d328bd 100644 --- a/internal/pipeline/steps/prsummary_test.go +++ b/internal/pipeline/steps/prsummary_test.go @@ -14,18 +14,9 @@ import ( ) const testPipelineHeadSHA = "0123456789abcdef0123456789abcdef01234567" +const testPublicationNonce = "00112233445566778899aabbccddeeff" -func TestNoSlopRequiredWorkflowChecksPipelineSignature(t *testing.T) { - t.Parallel() - - workflow, err := os.ReadFile(filepath.Join("..", "..", "..", ".github", "workflows", "no-slop-required.yml")) - if err != nil { - t.Fatalf("read required workflow: %v", err) - } - if !strings.Contains(string(workflow), "canonical_marker='"+noMistakesPRSignature+"'") { - t.Fatalf("required workflow does not check the generated PR signature %q", noMistakesPRSignature) - } -} +func testCertifiedHead(sha string) *string { return &sha } func TestBuildPipelineSummary_AllClean(t *testing.T) { t.Parallel() @@ -70,14 +61,14 @@ func TestBuildPipelineSummary_EmitsStructuredStepAttestation(t *testing.T) { steps := []*db.StepResult{ {ID: "ci", StepName: types.StepCI, Status: types.StepStatusPending}, - {ID: "document", StepName: types.StepDocument, Status: types.StepStatusSkipped}, - {ID: "review", StepName: types.StepReview, Status: types.StepStatusCompleted}, - {ID: "test", StepName: types.StepTest, Status: types.StepStatusFailed}, - {ID: "rebase", StepName: types.StepRebase, Status: types.StepStatusCompleted}, + {ID: "document", StepName: types.StepDocument, Status: types.StepStatusSkipped, CertifiedHeadSHA: testCertifiedHead("document-head")}, + {ID: "review", StepName: types.StepReview, Status: types.StepStatusCompleted, CertifiedHeadSHA: testCertifiedHead("review-head")}, + {ID: "test", StepName: types.StepTest, Status: types.StepStatusFailed, CertifiedHeadSHA: testCertifiedHead("test-head")}, + {ID: "rebase", StepName: types.StepRebase, Status: types.StepStatusCompleted, CertifiedHeadSHA: testCertifiedHead("rebase-head")}, {ID: "lint", StepName: types.StepLint, Status: types.StepStatusAwaitingApproval}, - {ID: "push", StepName: types.StepPush, Status: types.StepStatusCompleted}, + {ID: "push", StepName: types.StepPush, Status: types.StepStatusCompleted, CertifiedHeadSHA: testCertifiedHead("push-head")}, {ID: "pr", StepName: types.StepPR, Status: types.StepStatusRunning}, - {ID: "intent", StepName: types.StepIntent, Status: types.StepStatusSkipped}, + {ID: "intent", StepName: types.StepIntent, Status: types.StepStatusSkipped, CertifiedHeadSHA: testCertifiedHead("intent-head")}, } got, _ := BuildPipelineSummary(steps, nil, testPipelineHeadSHA) @@ -96,10 +87,12 @@ func TestBuildPipelineSummary_EmitsStructuredStepAttestation(t *testing.T) { t.Fatalf("attestation comment is not closed:\n%s", got) } var attestation struct { - HeadSHA string `json:"head_sha"` - Steps []struct { - Step types.StepName `json:"step"` - Status types.StepStatus `json:"status"` + HeadSHA string `json:"head_sha"` + PublicationNonce string `json:"publication_nonce"` + Steps []struct { + Step types.StepName `json:"step"` + Status types.StepStatus `json:"status"` + HeadSHA string `json:"head_sha"` } `json:"steps"` } payload := got[start+len(prefix) : start+end] @@ -109,27 +102,31 @@ func TestBuildPipelineSummary_EmitsStructuredStepAttestation(t *testing.T) { if attestation.HeadSHA != testPipelineHeadSHA { t.Fatalf("attested head = %q, want %q", attestation.HeadSHA, testPipelineHeadSHA) } + if !validPublicationNonce(attestation.PublicationNonce) { + t.Fatalf("publication nonce = %q, want valid nonce", attestation.PublicationNonce) + } want := []struct { step types.StepName status types.StepStatus + head string }{ - {types.StepIntent, types.StepStatusSkipped}, - {types.StepRebase, types.StepStatusCompleted}, - {types.StepReview, types.StepStatusCompleted}, - {types.StepTest, types.StepStatusFailed}, - {types.StepDocument, types.StepStatusSkipped}, - {types.StepLint, types.StepStatusAwaitingApproval}, - {types.StepPush, types.StepStatusCompleted}, - {types.StepPR, types.StepStatusRunning}, - {types.StepCI, types.StepStatusPending}, + {types.StepIntent, types.StepStatusSkipped, "intent-head"}, + {types.StepRebase, types.StepStatusCompleted, "rebase-head"}, + {types.StepReview, types.StepStatusCompleted, "review-head"}, + {types.StepTest, types.StepStatusFailed, "test-head"}, + {types.StepDocument, types.StepStatusSkipped, "document-head"}, + {types.StepLint, types.StepStatusAwaitingApproval, ""}, + {types.StepPush, types.StepStatusCompleted, "push-head"}, + {types.StepPR, types.StepStatusRunning, ""}, + {types.StepCI, types.StepStatusPending, ""}, } if len(attestation.Steps) != len(want) { t.Fatalf("attested %d steps, want %d: %+v", len(attestation.Steps), len(want), attestation.Steps) } for i, wantStep := range want { - if gotStep := attestation.Steps[i]; gotStep.Step != wantStep.step || gotStep.Status != wantStep.status { - t.Errorf("attested step %d = (%q, %q), want (%q, %q)", i, gotStep.Step, gotStep.Status, wantStep.step, wantStep.status) + if gotStep := attestation.Steps[i]; gotStep.Step != wantStep.step || gotStep.Status != wantStep.status || gotStep.HeadSHA != wantStep.head { + t.Errorf("attested step %d = (%q, %q, %q), want (%q, %q, %q)", i, gotStep.Step, gotStep.Status, gotStep.HeadSHA, wantStep.step, wantStep.status, wantStep.head) } } diff --git a/internal/pipeline/steps/rebase.go b/internal/pipeline/steps/rebase.go index d9ddcbf..87b713d 100644 --- a/internal/pipeline/steps/rebase.go +++ b/internal/pipeline/steps/rebase.go @@ -506,6 +506,10 @@ func updateHeadSHA(ctx context.Context, sctx *pipeline.StepContext) (*pipeline.S } if headSHA != "" && headSHA != sctx.Run.HeadSHA { oldHead := sctx.Run.HeadSHA + rollbackRange, err := pipeline.RemapUncertifiedPipelineRangeAfterRebase(sctx, oldHead, headSHA) + if err != nil { + return nil, fmt.Errorf("remap uncertified review range before rebase adoption: %w", err) + } // Anchor the rebased head on the gate branch ref before recording it. // The pipeline worktree is detached, so without this the recorded head // is referenced by nothing: the worktree is removed at the end of the @@ -516,9 +520,13 @@ func updateHeadSHA(ctx context.Context, sctx *pipeline.StepContext) (*pipeline.S // the ref non-fast-forward, so an unanchored write here would destroy a // commit a concurrent push landed on the branch. if err := adoptBranchRef(sctx, headSHA); err != nil { + if rollbackRange != nil { + if rollbackErr := rollbackRange(); rollbackErr != nil { + return nil, fmt.Errorf("%w; restore uncertified review range: %v", err, rollbackErr) + } + } return nil, err } - pipeline.RemapUncertifiedPipelineRangeAfterRebase(sctx, oldHead, headSHA) sctx.Run.HeadSHA = headSHA if err := sctx.DB.UpdateRunHeadSHA(sctx.Run.ID, headSHA); err != nil { return nil, err diff --git a/internal/pipeline/steps/rebase_test.go b/internal/pipeline/steps/rebase_test.go index 107867d..823f85d 100644 --- a/internal/pipeline/steps/rebase_test.go +++ b/internal/pipeline/steps/rebase_test.go @@ -13,6 +13,7 @@ import ( "github.com/Blakeolson21/no-slop/internal/agent" "github.com/Blakeolson21/no-slop/internal/config" "github.com/Blakeolson21/no-slop/internal/pipeline" + "github.com/Blakeolson21/no-slop/internal/types" ) func TestRebaseStep_ConflictTriesAllTargets(t *testing.T) { @@ -460,6 +461,9 @@ func TestRebaseStep_RemapsUncertifiedRangeWhenHeadRewritten(t *testing.T) { sctx := newTestContextWithDBRecords(t, ag, dir, baseSHA, toSHA, config.Commands{}) sctx.Run.Branch = "refs/heads/feature" sctx.Repo.UpstreamURL = upstream + if _, err := sctx.DB.InsertStepResult(sctx.Run.ID, types.StepReview); err != nil { + t.Fatal(err) + } if err := sctx.DB.UpsertUncertifiedPipelineRange(sctx.Repo.ID, sctx.Run.Branch, fromSHA, toSHA, sctx.Run.ID); err != nil { t.Fatal(err) } @@ -495,3 +499,34 @@ func TestRebaseStep_RemapsUncertifiedRangeWhenHeadRewritten(t *testing.T) { t.Fatalf("bind after rebase remap from=%q to=%q, want from=%q to=%q", bind.UncertifiedFromSHA, bind.UncertifiedToSHA, got.FromSHA, got.ToSHA) } } + +func TestUpdateHeadSHARefusesAdoptionWhenUncertifiedRangeCannotMap(t *testing.T) { + dir, baseSHA, oldHead := setupGitRepo(t) + gitCmd(t, dir, "checkout", "--detach", baseSHA) + if err := os.WriteFile(filepath.Join(dir, "rewritten.txt"), []byte("rewritten\n"), 0o644); err != nil { + t.Fatal(err) + } + gitCmd(t, dir, "add", "-A") + gitCmd(t, dir, "commit", "-m", "rewrite feature") + + sctx := newTestContextWithDBRecords(t, &mockAgent{name: "test"}, dir, baseSHA, oldHead, config.Commands{}) + if err := sctx.DB.UpsertUncertifiedPipelineRange(sctx.Repo.ID, sctx.Run.Branch, "missing-range-start", oldHead, sctx.Run.ID); err != nil { + t.Fatal(err) + } + if _, err := updateHeadSHA(context.Background(), sctx); err == nil || !strings.Contains(err.Error(), "remap uncertified review range") { + t.Fatalf("updateHeadSHA() error = %v, want mandatory remap failure", err) + } + if got := gitCmd(t, dir, "rev-parse", "refs/heads/feature"); got != oldHead { + t.Fatalf("feature ref = %s, want unchanged %s", got, oldHead) + } + if sctx.Run.HeadSHA != oldHead { + t.Fatalf("in-memory run head = %s, want unchanged %s", sctx.Run.HeadSHA, oldHead) + } + stored, err := sctx.DB.GetRun(sctx.Run.ID) + if err != nil { + t.Fatal(err) + } + if stored.HeadSHA != oldHead { + t.Fatalf("persisted run head = %s, want unchanged %s", stored.HeadSHA, oldHead) + } +} diff --git a/internal/pipeline/steps/review.go b/internal/pipeline/steps/review.go index e039725..8236244 100644 --- a/internal/pipeline/steps/review.go +++ b/internal/pipeline/steps/review.go @@ -18,6 +18,10 @@ type ReviewStep struct{} func (s *ReviewStep) Name() types.StepName { return types.StepReview } +// FindingsMayBeScopeLimited tells the executor that a later review round's +// silence is not authority to discard findings that were shown but not fixed. +func (s *ReviewStep) FindingsMayBeScopeLimited() bool { return true } + func (s *ReviewStep) Execute(sctx *pipeline.StepContext) (*pipeline.StepOutcome, error) { ctx := sctx.Ctx baseSHA := resolveBranchBaseSHA(ctx, sctx.WorkDir, sctx.Run.BaseSHA, sctx.Repo.DefaultBranch) @@ -213,6 +217,7 @@ Rules: - Only comment on things that genuinely matter. - Do NOT report styling, formatting, linting, compilation, or type-checking issues. - If the change is clean, return an empty findings array. +- For the same underlying finding from a previous round, copy both its ID and continuity token into prior_id and prior_continuity_token. Omit both fields for every new finding. - For each finding, set the action field to one of: - "ask-user": the finding is about functional requirements or product behavior, or otherwise challenges the author's deliberate intent. Even if it seems obviously wrong, we should ask the user for review. Examples: "this feature seems unnecessary", "this hardcoded value should be configurable", "this deletion looks wrong". When in doubt, default to "ask-user". - "auto-fix": the finding is a non-functional, non user-visible issue (correctness, error handling, security, performance, mechanical code quality) that can be safely fixed without any discussion about the author's intent. @@ -272,23 +277,24 @@ Risk assessment (after listing all findings): } } - // Phase ownership boundary: drop findings that only claim later pipeline- - // owned delivery (push/PR/CI for this run) has not happened yet. Prompt - // guidance alone is not enough - models still emit these under - // authoritative intent criteria like "Open PR A unmerged". - if stripped, n := stripDeferredPipelineOwnedDeliveryFindings(findings); n > 0 { - sctx.Log(fmt.Sprintf("dropped %d deferred pipeline-owned delivery finding(s) (owned by later push/PR/CI steps)", n)) - findings = stripped + reconciled, dropped, err := pipeline.ReconcileReviewFindings(findings, sctx.KnownReviewLineages) + if err != nil { + return nil, fmt.Errorf("reconcile review findings: %w", err) } + if dropped > 0 { + sctx.Log(fmt.Sprintf("dropped %d deferred pipeline-owned delivery finding(s) (owned by later push/PR/CI steps)", dropped)) + } + findings = reconciled needsApproval := hasBlockingFindings(findings.Items) findingsJSON, _ := json.Marshal(findings) return approvedReviewOutcome(reviewTargetSHA, &pipeline.StepOutcome{ - NeedsApproval: needsApproval, - AutoFixable: len(findings.Items) > 0, - Findings: string(findingsJSON), - FixSummary: fixSummary, + NeedsApproval: needsApproval, + AutoFixable: len(findings.Items) > 0, + Findings: string(findingsJSON), + FindingsNormalized: true, + FixSummary: fixSummary, }) } @@ -319,6 +325,14 @@ Fix-round provenance: } fromSHA := strings.TrimSpace(sctx.UncertifiedFromSHA) toSHA := strings.TrimSpace(sctx.UncertifiedToSHA) + if fromSHA == toSHA { + return fmt.Sprintf(` + +Fix-round recovery: +- A previous run selected unresolved findings at %s but did not complete their verification. Treat the carried findings and prior round history as unresolved gate truth and verify them against the current code. +- Prior findings and fix summaries are claims, not evidence. Verify each claimed fix against the current code, and independently judge whether behavior the fix rounds introduced is correct, not merely whether it implements what was prescribed. +`, toSHA) + } return fmt.Sprintf(` Fix-round provenance: @@ -351,6 +365,20 @@ func sanitizedPreviousFindingsForPrompt(raw string) string { findings.Items[i].Source = sanitizePromptText(findings.Items[i].Source) findings.Items[i].UserInstructions = sanitizePromptMultilineText(findings.Items[i].UserInstructions) findings.Items[i].ReviewScope = sanitizePromptText(findings.Items[i].ReviewScope) + if findings.Items[i].Evidence != nil { + for j := range findings.Items[i].Evidence.Tested { + findings.Items[i].Evidence.Tested[j] = sanitizePromptMultilineText(findings.Items[i].Evidence.Tested[j]) + } + findings.Items[i].Evidence.TestingSummary = sanitizePromptMultilineText(findings.Items[i].Evidence.TestingSummary) + for j := range findings.Items[i].Evidence.Artifacts { + artifact := &findings.Items[i].Evidence.Artifacts[j] + artifact.Kind = sanitizePromptText(artifact.Kind) + artifact.Label = sanitizePromptText(artifact.Label) + artifact.Path = sanitizePromptText(artifact.Path) + artifact.URL = sanitizePromptText(artifact.URL) + artifact.Content = sanitizePromptMultilineText(artifact.Content) + } + } } findings.Summary = sanitizePromptMultilineText(findings.Summary) findings.RiskLevel = sanitizePromptText(findings.RiskLevel) diff --git a/internal/pipeline/steps/review_pipeline_delivery_test.go b/internal/pipeline/steps/review_pipeline_delivery_test.go index a2d178f..2268594 100644 --- a/internal/pipeline/steps/review_pipeline_delivery_test.go +++ b/internal/pipeline/steps/review_pipeline_delivery_test.go @@ -192,3 +192,85 @@ func TestReviewStep_StripsOnlyDeferredAmongMixedFindings(t *testing.T) { t.Fatalf("real finding must be kept: %s", outcome.Findings) } } + +func TestReviewStep_ReconcilesSourceLineageBeforeDeliveryFiltering(t *testing.T) { + t.Parallel() + dir, baseSHA, headSHA := setupGitRepo(t) + + const priorID = "review-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + const priorToken = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + prior := Findings{ + Items: []Finding{{ + ID: priorID, + IDGenerated: true, + ContinuityToken: priorToken, + Severity: "error", + File: "loader.go", + Line: 42, + Description: "unsafe loader can expose credentials", + Action: types.ActionAskUser, + ReviewScope: types.FindingReviewScopeSource, + }}, + Tested: []string{"reproduced credential exposure"}, + TestingSummary: "The source defect is reproducible.", + RiskLevel: "high", + RiskRationale: "Credentials can be exposed.", + RiskScope: types.FindingsRiskScopeSourceOrExternal, + } + priorRaw, err := json.Marshal(prior) + if err != nil { + t.Fatal(err) + } + + ag := &mockAgent{ + name: "test", + runFn: func(ctx context.Context, opts agent.RunOpts) (*agent.Result, error) { + fresh := Findings{ + Items: []Finding{{ + PriorID: priorID, + PriorContinuityToken: priorToken, + Severity: "info", + File: "loader.go", + Line: 44, + Description: "unsafe loader can expose credentials", + Action: types.ActionNoOp, + ReviewScope: types.FindingReviewScopePipelineOwnedDelivery, + }}, + Tested: []string{"narrow loader check"}, + TestingSummary: "The narrow path passed.", + RiskLevel: "low", + RiskRationale: "No delivery risk remains.", + RiskScope: types.FindingsRiskScopePipelineOwnedDelivery, + } + encoded, _ := json.Marshal(fresh) + return &agent.Result{Output: encoded}, nil + }, + } + + sctx := newTestContextWithDBRecords(t, ag, dir, baseSHA, headSHA, config.Commands{}) + sctx.KnownReviewLineages = string(priorRaw) + outcome, err := (&ReviewStep{}).Execute(sctx) + if err != nil { + t.Fatal(err) + } + if !outcome.NeedsApproval { + t.Fatal("reappeared source lineage must retain its blocking gate") + } + merged, err := types.ParseFindingsJSON(outcome.Findings) + if err != nil { + t.Fatal(err) + } + if len(merged.Items) != 1 { + t.Fatalf("findings = %#v, want one reconciled source lineage", merged.Items) + } + got := merged.Items[0] + if got.ID != priorID || got.ContinuityToken != priorToken || got.Action != types.ActionAskUser || got.Severity != "error" || got.ReviewScope != types.FindingReviewScopeSource { + t.Fatalf("reconciled finding = %#v", got) + } + if merged.RiskLevel != "high" || merged.RiskScope != types.FindingsRiskScopeSourceOrExternal { + t.Fatalf("reconciled risk = %q/%q", merged.RiskLevel, merged.RiskScope) + } + if !strings.Contains(merged.TestingSummary, "source defect is reproducible") || !strings.Contains(merged.TestingSummary, "narrow path passed") || len(merged.Tested) != 2 { + t.Fatalf("reconciled evidence = tested %#v, summary %q", merged.Tested, merged.TestingSummary) + } +} diff --git a/internal/pipeline/steps/review_session_test.go b/internal/pipeline/steps/review_session_test.go index 2e40b79..8eccfb2 100644 --- a/internal/pipeline/steps/review_session_test.go +++ b/internal/pipeline/steps/review_session_test.go @@ -272,7 +272,27 @@ func TestReviewLoop_ParkRespondFixKeepsRoleSessions(t *testing.T) { }() waitForReviewStatus(t, database, run.ID, types.StepStatusAwaitingApproval) - if err := exec.Respond(types.StepReview, types.ActionFix, []string{"f-1"}); err != nil { + steps, err := database.GetStepsByRun(run.ID) + if err != nil { + t.Fatal(err) + } + var selectedID string + for _, step := range steps { + if step.StepName != types.StepReview || step.FindingsJSON == nil { + continue + } + findings, parseErr := types.ParseFindingsJSON(*step.FindingsJSON) + if parseErr != nil { + t.Fatal(parseErr) + } + if len(findings.Items) == 1 { + selectedID = findings.Items[0].ID + } + } + if selectedID == "" { + t.Fatal("parked review did not expose a selectable finding ID") + } + if err := exec.Respond(types.StepReview, types.ActionFix, []string{selectedID}); err != nil { t.Fatalf("respond: %v", err) } diff --git a/internal/pipeline/steps/review_test.go b/internal/pipeline/steps/review_test.go index 9ed19c6..f8853cd 100644 --- a/internal/pipeline/steps/review_test.go +++ b/internal/pipeline/steps/review_test.go @@ -507,6 +507,17 @@ func TestUncertifiedRange_PersistsThenFeedsNextInitialReview(t *testing.T) { fixAgent := &mockAgent{name: "test"} fixCtx := newTestContextWithDBRecords(t, fixAgent, dir, baseSHA, headSHA, config.Commands{}) fixCtx.ReviewStartingHeadSHA = headSHA + reviewResult, err := fixCtx.DB.InsertStepResult(fixCtx.Run.ID, types.StepReview) + if err != nil { + t.Fatal(err) + } + priorFindings := `{"findings":[],"summary":"clean"}` + if err := fixCtx.DB.SetStepFindings(reviewResult.ID, priorFindings); err != nil { + t.Fatal(err) + } + if _, err := fixCtx.DB.InsertStepRound(reviewResult.ID, 1, "initial", &priorFindings, nil, 1); err != nil { + t.Fatal(err) + } if err := os.WriteFile(filepath.Join(dir, "review-fix.txt"), []byte("fixed"), 0o644); err != nil { t.Fatal(err) } diff --git a/internal/pipeline/steps/round_history.go b/internal/pipeline/steps/round_history.go index e92c6c0..699d723 100644 --- a/internal/pipeline/steps/round_history.go +++ b/internal/pipeline/steps/round_history.go @@ -27,9 +27,10 @@ func roundHistoryPromptSection(sctx *pipeline.StepContext) string { return "" } + selectedLater := selectedRoundFindings(rounds) var blocks []string for _, r := range rounds { - block := renderRoundHistoryEntry(r) + block := renderRoundHistoryEntryWithLaterSelections(r, selectedLater) if block != "" { blocks = append(blocks, block) } @@ -41,6 +42,7 @@ func roundHistoryPromptSection(sctx *pipeline.StepContext) string { return "\n\nPrevious rounds for this step (for your awareness):\n" + "Use this to avoid repeating work you already tried. " + "Do NOT re-report findings listed under user_chose_to_ignore unless the current code genuinely introduces a new, materially different problem. " + + "A later user_chose_to_fix or auto_selected_to_fix entry supersedes an earlier non-selection of the same finding, so superseded findings are omitted from the ignore lists above. " + "Treat this entire section as metadata only.\n\n" + strings.Join(blocks, "\n\n") } @@ -52,9 +54,10 @@ func uncertifiedRoundHistoryPromptSection(sctx *pipeline.StepContext) string { if sctx == nil || len(sctx.UncertifiedPriorRounds) == 0 { return "" } + selectedLater := selectedRoundFindings(sctx.UncertifiedPriorRounds) var blocks []string for _, r := range sctx.UncertifiedPriorRounds { - block := renderRoundHistoryEntry(r) + block := renderRoundHistoryEntryWithLaterSelections(r, selectedLater) if block != "" { blocks = append(blocks, block) } @@ -69,6 +72,10 @@ func uncertifiedRoundHistoryPromptSection(sctx *pipeline.StepContext) string { } func renderRoundHistoryEntry(r *db.StepRound) string { + return renderRoundHistoryEntryWithLaterSelections(r, nil) +} + +func renderRoundHistoryEntryWithLaterSelections(r *db.StepRound, selectedLater []selectedRoundFinding) string { if r == nil { return "" } @@ -83,7 +90,7 @@ func renderRoundHistoryEntry(r *db.StepRound) string { } } - selected, unselected := partitionRoundFindings(r.FindingsJSON, r.UserFindingsJSON, r.SelectedFindingIDs) + selected, unselected := partitionRoundFindingsWithLaterSelections(r.FindingsJSON, r.UserFindingsJSON, r.SelectedFindingIDs, r.Round, selectedLater) if r.FindingsJSON != nil && strings.TrimSpace(*r.FindingsJSON) != "" { if items := renderRoundFindingLines(*r.FindingsJSON); len(items) > 0 { @@ -97,14 +104,14 @@ func renderRoundHistoryEntry(r *db.StepRound) string { switch selectionSourceValue(r.SelectionSource) { case db.RoundSelectionSourceUser: - if selected != nil { + if len(selected) > 0 { b.WriteString("\nuser_chose_to_fix:") for _, line := range selected { b.WriteString("\n - ") b.WriteString(line) } } - if unselected != nil { + if len(unselected) > 0 { b.WriteString("\nuser_chose_to_ignore:") for _, line := range unselected { b.WriteString("\n - ") @@ -112,7 +119,7 @@ func renderRoundHistoryEntry(r *db.StepRound) string { } } case db.RoundSelectionSourceAutoFix: - if selected != nil { + if len(selected) > 0 { b.WriteString("\nauto_selected_to_fix:") for _, line := range selected { b.WriteString("\n - ") @@ -125,8 +132,14 @@ func renderRoundHistoryEntry(r *db.StepRound) string { } type roundFindingLine struct { - ID string - Line string + ID string + Finding types.Finding + Line string +} + +type selectedRoundFinding struct { + Finding types.Finding + Round int } func renderRoundFindingLines(raw string) []string { @@ -147,6 +160,7 @@ func parseRoundFindingLines(raw string) []roundFindingLine { for _, item := range findings.Items { payload := struct { ID string `json:"id,omitempty"` + ContinuityToken string `json:"continuity_token,omitempty"` Severity string `json:"severity,omitempty"` File string `json:"file,omitempty"` Line int `json:"line,omitempty"` @@ -156,6 +170,7 @@ func parseRoundFindingLines(raw string) []roundFindingLine { UserInstructions string `json:"user_instructions,omitempty"` }{ ID: sanitizePromptText(item.ID), + ContinuityToken: sanitizePromptText(item.ContinuityToken), Severity: sanitizePromptText(item.Severity), File: sanitizePromptText(item.File), Line: item.Line, @@ -168,7 +183,7 @@ func parseRoundFindingLines(raw string) []roundFindingLine { if err != nil { continue } - lines = append(lines, roundFindingLine{ID: item.ID, Line: string(encoded)}) + lines = append(lines, roundFindingLine{ID: item.ID, Finding: item, Line: string(encoded)}) } return lines } @@ -179,6 +194,10 @@ func parseRoundFindingLines(raw string) []roundFindingLine { // unavailable, so the caller can omit the line entirely rather than emit a // misleading empty set. func partitionRoundFindings(findingsJSON *string, userFindingsJSON *string, selectedJSON *string) (selected []string, unselected []string) { + return partitionRoundFindingsWithLaterSelections(findingsJSON, userFindingsJSON, selectedJSON, 0, nil) +} + +func partitionRoundFindingsWithLaterSelections(findingsJSON *string, userFindingsJSON *string, selectedJSON *string, round int, selectedLater []selectedRoundFinding) (selected []string, unselected []string) { if findingsJSON == nil || strings.TrimSpace(*findingsJSON) == "" { return nil, nil } @@ -216,6 +235,9 @@ func partitionRoundFindings(findingsJSON *string, userFindingsJSON *string, sele if item.ID != "" && selectedSet[item.ID] { continue } + if findingSelectedLater(item.Finding, allFindings, round, selectedLater) { + continue + } unselected = append(unselected, item.Line) } for id := range selectedSet { @@ -226,6 +248,106 @@ func partitionRoundFindings(findingsJSON *string, userFindingsJSON *string, sele return selected, unselected } +func selectedRoundFindings(rounds []*db.StepRound) []selectedRoundFinding { + var selected []selectedRoundFinding + for _, round := range rounds { + if round == nil || round.SelectedFindingIDs == nil || round.FindingsJSON == nil { + continue + } + var ids []string + if err := json.Unmarshal([]byte(*round.SelectedFindingIDs), &ids); err != nil { + continue + } + selectedIDs := make(map[string]bool, len(ids)) + for _, id := range ids { + if id != "" { + selectedIDs[id] = true + } + } + raw := round.FindingsJSON + if round.UserFindingsJSON != nil && strings.TrimSpace(*round.UserFindingsJSON) != "" { + raw = round.UserFindingsJSON + } + for _, item := range parseRoundFindingLines(*raw) { + if selectedIDs[item.ID] { + selected = append(selected, selectedRoundFinding{Finding: item.Finding, Round: round.Round}) + } + } + } + return selected +} + +func findingSelectedLater(item types.Finding, roundItems []roundFindingLine, round int, selectedLater []selectedRoundFinding) bool { + var candidates []types.Finding + for _, selected := range selectedLater { + if selected.Round > round { + candidates = append(candidates, selected.Finding) + } + } + if len(candidates) == 0 { + return false + } + current := make([]types.Finding, 0, len(roundItems)) + for _, candidate := range roundItems { + current = append(current, candidate.Finding) + } + currentCounts := types.CountFindingFingerprints(current) + candidateCounts := types.CountFindingFingerprints(candidates) + currentIdentityCounts := countRoundFindingIdentities(current) + candidateIdentityCounts := countRoundFindingIdentities(candidates) + currentOccurrenceCounts := types.CountFindingOccurrences(current) + candidateOccurrenceCounts := make(map[string]map[int]int) + for _, selected := range selectedLater { + if selected.Round <= round || !selected.Finding.HasOccurrence() { + continue + } + if candidateOccurrenceCounts[selected.Finding.OccurrenceToken] == nil { + candidateOccurrenceCounts[selected.Finding.OccurrenceToken] = make(map[int]int) + } + candidateOccurrenceCounts[selected.Finding.OccurrenceToken][selected.Round]++ + } + for _, candidate := range candidates { + if types.FindingOccurrenceCorroborates(item, candidate) && currentOccurrenceCounts[item.OccurrenceToken] == 1 && occurrenceUniqueWithinLaterRounds(candidateOccurrenceCounts[candidate.OccurrenceToken]) { + return true + } + if item.HasLineage() && candidate.HasLineage() { + if types.FindingIDCorroborates(item, candidate) { + return true + } + continue + } + identity := item.Identity() + if identity == candidate.Identity() && currentIdentityCounts[identity] == 1 && candidateIdentityCounts[identity] == 1 { + return true + } + fingerprint := item.Fingerprint() + if fingerprint == candidate.Fingerprint() && currentCounts[fingerprint] == 1 && candidateCounts[fingerprint] == 1 { + return true + } + } + return false +} + +func occurrenceUniqueWithinLaterRounds(counts map[int]int) bool { + if len(counts) == 0 { + return false + } + for _, count := range counts { + if count != 1 { + return false + } + } + return true +} + +func countRoundFindingIdentities(items []types.Finding) map[types.FindingIdentity]int { + counts := make(map[types.FindingIdentity]int, len(items)) + for _, item := range items { + counts[item.Identity()]++ + } + return counts +} + func selectionSourceValue(source *string) string { if source == nil { return "" diff --git a/internal/pipeline/steps/round_history_test.go b/internal/pipeline/steps/round_history_test.go index 75c88f0..eae7272 100644 --- a/internal/pipeline/steps/round_history_test.go +++ b/internal/pipeline/steps/round_history_test.go @@ -123,6 +123,283 @@ func TestRoundHistoryPromptSection_DoesNotTreatAutoFixFilteringAsUserIgnore(t *t } } +func TestRoundHistoryPromptSection_LaterSelectionSupersedesEarlierIgnore(t *testing.T) { + sctx, stepID := newRoundHistoryContext(t) + + initial := `{"findings":[{"id":"review-1","severity":"error","description":"unsafe loader","action":"ask-user"},{"id":"review-2","severity":"warning","description":"hardcoded timeout","action":"ask-user"}],"summary":"2"}` + r1, err := sctx.DB.InsertStepRound(stepID, 1, "initial", &initial, nil, 1) + if err != nil { + t.Fatal(err) + } + selectedFirst := `["review-1"]` + if err := sctx.DB.SetStepRoundSelection(r1.ID, &selectedFirst, db.RoundSelectionSourceUser); err != nil { + t.Fatal(err) + } + + carried := `{"findings":[{"id":"review-2","severity":"warning","description":"hardcoded timeout","action":"ask-user"}],"summary":"1 outstanding finding"}` + r2, err := sctx.DB.InsertStepRound(stepID, 2, "auto_fix", &carried, nil, 1) + if err != nil { + t.Fatal(err) + } + selectedLater := `["review-2"]` + if err := sctx.DB.SetStepRoundSelection(r2.ID, &selectedLater, db.RoundSelectionSourceUser); err != nil { + t.Fatal(err) + } + + got := roundHistoryPromptSection(sctx) + if strings.Contains(got, "user_chose_to_ignore:") { + t.Fatalf("a later-selected finding is still labeled ignored:\n%s", got) + } + if strings.Count(got, `user_chose_to_fix:`) != 2 { + t.Fatalf("both selections must be visible to the verifier:\n%s", got) + } +} + +func TestRoundHistoryPromptSection_RawIDReuseDoesNotSupersedeEarlierIgnore(t *testing.T) { + sctx, stepID := newRoundHistoryContext(t) + + initial := `{"findings":[{"id":"review-1","severity":"error","file":"loader.go","line":10,"description":"unsafe loader","action":"ask-user"}]}` + r1, err := sctx.DB.InsertStepRound(stepID, 1, "initial", &initial, nil, 1) + if err != nil { + t.Fatal(err) + } + none := `[]` + if err := sctx.DB.SetStepRoundSelection(r1.ID, &none, db.RoundSelectionSourceUser); err != nil { + t.Fatal(err) + } + + later := `{"findings":[{"id":"review-1","severity":"error","file":"auth.go","line":20,"description":"token leak","action":"ask-user"}]}` + r2, err := sctx.DB.InsertStepRound(stepID, 2, "recovery", &later, nil, 1) + if err != nil { + t.Fatal(err) + } + selected := `["review-1"]` + if err := sctx.DB.SetStepRoundSelection(r2.ID, &selected, db.RoundSelectionSourceUser); err != nil { + t.Fatal(err) + } + + got := roundHistoryPromptSection(sctx) + if !strings.Contains(got, "user_chose_to_ignore:") || !strings.Contains(got, `"description":"unsafe loader"`) { + t.Fatalf("unrelated raw ID reuse erased earlier history:\n%s", got) + } +} + +func TestRoundHistoryPromptSection_LegacyUniqueStructureSupersedesEarlierIgnore(t *testing.T) { + sctx, stepID := newRoundHistoryContext(t) + + initial := `{"findings":[{"id":"review-1","severity":"error","file":"loader.go","line":10,"description":"unsafe loader","action":"ask-user"}]}` + r1, err := sctx.DB.InsertStepRound(stepID, 1, "initial", &initial, nil, 1) + if err != nil { + t.Fatal(err) + } + none := `[]` + if err := sctx.DB.SetStepRoundSelection(r1.ID, &none, db.RoundSelectionSourceUser); err != nil { + t.Fatal(err) + } + + later := `{"findings":[{"id":"review-9","severity":"error","file":"loader.go","line":25,"description":"unsafe loader","action":"ask-user"}]}` + r2, err := sctx.DB.InsertStepRound(stepID, 2, "recovery", &later, nil, 1) + if err != nil { + t.Fatal(err) + } + selected := `["review-9"]` + if err := sctx.DB.SetStepRoundSelection(r2.ID, &selected, db.RoundSelectionSourceUser); err != nil { + t.Fatal(err) + } + + got := roundHistoryPromptSection(sctx) + if strings.Contains(got, "user_chose_to_ignore:") { + t.Fatalf("unique legacy continuation remained ignored:\n%s", got) + } +} + +func TestRoundHistoryPromptSection_AmbiguousLegacyStructurePreservesHistory(t *testing.T) { + sctx, stepID := newRoundHistoryContext(t) + + initial := `{"findings":[{"id":"review-1","severity":"error","file":"loader.go","line":10,"description":"unsafe loader","action":"ask-user"},{"id":"review-2","severity":"error","file":"loader.go","line":20,"description":"unsafe loader","action":"ask-user"}]}` + r1, err := sctx.DB.InsertStepRound(stepID, 1, "initial", &initial, nil, 1) + if err != nil { + t.Fatal(err) + } + none := `[]` + if err := sctx.DB.SetStepRoundSelection(r1.ID, &none, db.RoundSelectionSourceUser); err != nil { + t.Fatal(err) + } + + later := `{"findings":[{"id":"review-9","severity":"error","file":"loader.go","line":30,"description":"unsafe loader","action":"ask-user"}]}` + r2, err := sctx.DB.InsertStepRound(stepID, 2, "recovery", &later, nil, 1) + if err != nil { + t.Fatal(err) + } + selected := `["review-9"]` + if err := sctx.DB.SetStepRoundSelection(r2.ID, &selected, db.RoundSelectionSourceUser); err != nil { + t.Fatal(err) + } + + got := roundHistoryPromptSection(sctx) + if !strings.Contains(got, "user_chose_to_ignore:") || strings.Count(got, `"description":"unsafe loader"`) < 3 { + t.Fatalf("ambiguous legacy history was collapsed:\n%s", got) + } +} + +func TestRoundHistoryPromptSection_AmbiguousExactLegacyStructurePreservesHistory(t *testing.T) { + sctx, stepID := newRoundHistoryContext(t) + + initial := `{"findings":[{"id":"review-1","severity":"error","file":"loader.go","line":10,"description":"unsafe loader","action":"ask-user"},{"id":"review-2","severity":"error","file":"loader.go","line":10,"description":"unsafe loader","action":"ask-user"}]}` + r1, err := sctx.DB.InsertStepRound(stepID, 1, "initial", &initial, nil, 1) + if err != nil { + t.Fatal(err) + } + none := `[]` + if err := sctx.DB.SetStepRoundSelection(r1.ID, &none, db.RoundSelectionSourceUser); err != nil { + t.Fatal(err) + } + + later := `{"findings":[{"id":"review-9","severity":"error","file":"loader.go","line":10,"description":"unsafe loader","action":"ask-user"}]}` + r2, err := sctx.DB.InsertStepRound(stepID, 2, "recovery", &later, nil, 1) + if err != nil { + t.Fatal(err) + } + selected := `["review-9"]` + if err := sctx.DB.SetStepRoundSelection(r2.ID, &selected, db.RoundSelectionSourceUser); err != nil { + t.Fatal(err) + } + + got := roundHistoryPromptSection(sctx) + if !strings.Contains(got, "user_chose_to_ignore:") || strings.Count(got, `"description":"unsafe loader"`) < 3 { + t.Fatalf("ambiguous exact legacy history was collapsed:\n%s", got) + } +} + +func TestRoundHistoryPromptSection_AmbiguousLaterExactStructurePreservesHistory(t *testing.T) { + sctx, stepID := newRoundHistoryContext(t) + + initial := `{"findings":[{"id":"review-1","severity":"error","file":"loader.go","line":10,"description":"unsafe loader","action":"ask-user"}]}` + r1, err := sctx.DB.InsertStepRound(stepID, 1, "initial", &initial, nil, 1) + if err != nil { + t.Fatal(err) + } + none := `[]` + if err := sctx.DB.SetStepRoundSelection(r1.ID, &none, db.RoundSelectionSourceUser); err != nil { + t.Fatal(err) + } + + later := `{"findings":[{"id":"review-8","severity":"error","file":"loader.go","line":10,"description":"unsafe loader","action":"ask-user"},{"id":"review-9","severity":"error","file":"loader.go","line":10,"description":"unsafe loader","action":"ask-user"}]}` + r2, err := sctx.DB.InsertStepRound(stepID, 2, "recovery", &later, nil, 1) + if err != nil { + t.Fatal(err) + } + selected := `["review-8","review-9"]` + if err := sctx.DB.SetStepRoundSelection(r2.ID, &selected, db.RoundSelectionSourceUser); err != nil { + t.Fatal(err) + } + + got := roundHistoryPromptSection(sctx) + if !strings.Contains(got, "user_chose_to_ignore:") || strings.Count(got, `"description":"unsafe loader"`) < 3 { + t.Fatalf("ambiguous later exact history was collapsed:\n%s", got) + } +} + +func TestRoundHistoryPromptSection_OccurrenceTokenSupersedesOnlySelectedLegacyIgnore(t *testing.T) { + sctx, stepID := newRoundHistoryContext(t) + + initial := `{"findings":[{"id":"legacy-a","occurrence_token":"occurrence-a","severity":"error","file":"loader.go","line":10,"description":"unsafe loader","action":"ask-user"},{"id":"legacy-b","occurrence_token":"occurrence-b","severity":"error","file":"loader.go","line":10,"description":"unsafe loader","action":"ask-user"}]}` + r1, err := sctx.DB.InsertStepRound(stepID, 1, "initial", &initial, nil, 1) + if err != nil { + t.Fatal(err) + } + none := `[]` + if err := sctx.DB.SetStepRoundSelection(r1.ID, &none, db.RoundSelectionSourceUser); err != nil { + t.Fatal(err) + } + + later := `{"findings":[{"id":"later-a","occurrence_token":"occurrence-a","severity":"error","file":"loader.go","line":10,"description":"unsafe loader","action":"ask-user"}]}` + r2, err := sctx.DB.InsertStepRound(stepID, 2, "recovery", &later, nil, 1) + if err != nil { + t.Fatal(err) + } + selected := `["later-a"]` + if err := sctx.DB.SetStepRoundSelection(r2.ID, &selected, db.RoundSelectionSourceUser); err != nil { + t.Fatal(err) + } + + got := roundHistoryPromptSection(sctx) + roundOneEnd := strings.Index(got, "\n\nRound 2") + if roundOneEnd < 0 { + t.Fatalf("missing second round:\n%s", got) + } + roundOne := got[:roundOneEnd] + ignoreAt := strings.Index(roundOne, "user_chose_to_ignore:") + if ignoreAt < 0 { + t.Fatalf("missing first-round ignore list:\n%s", got) + } + ignored := roundOne[ignoreAt:] + if strings.Contains(ignored, `"id":"legacy-a"`) { + t.Fatalf("later-selected occurrence A remained ignored:\n%s", got) + } + if !strings.Contains(ignored, `"id":"legacy-b"`) { + t.Fatalf("unselected occurrence B disappeared from ignore history:\n%s", got) + } +} + +func TestRoundHistoryPromptSection_RepeatedOccurrenceSelectionsRemainAuthoritative(t *testing.T) { + sctx, stepID := newRoundHistoryContext(t) + + initial := `{"findings":[{"id":"legacy-a","occurrence_token":"occurrence-a","severity":"error","file":"loader.go","line":10,"description":"unsafe loader","action":"ask-user"},{"id":"legacy-b","occurrence_token":"occurrence-b","severity":"error","file":"loader.go","line":10,"description":"unsafe loader","action":"ask-user"}]}` + r1, err := sctx.DB.InsertStepRound(stepID, 1, "initial", &initial, nil, 1) + if err != nil { + t.Fatal(err) + } + none := `[]` + if err := sctx.DB.SetStepRoundSelection(r1.ID, &none, db.RoundSelectionSourceUser); err != nil { + t.Fatal(err) + } + + for round := 2; round <= 3; round++ { + later := `{"findings":[{"id":"later-a","occurrence_token":"occurrence-a","severity":"error","file":"loader.go","line":10,"description":"unsafe loader","action":"ask-user"}]}` + record, err := sctx.DB.InsertStepRound(stepID, round, "recovery", &later, nil, 1) + if err != nil { + t.Fatal(err) + } + selected := `["later-a"]` + if err := sctx.DB.SetStepRoundSelection(record.ID, &selected, db.RoundSelectionSourceUser); err != nil { + t.Fatal(err) + } + } + + got := roundHistoryPromptSection(sctx) + roundOneEnd := strings.Index(got, "\n\nRound 2") + if roundOneEnd < 0 { + t.Fatalf("missing later rounds:\n%s", got) + } + roundOne := got[:roundOneEnd] + ignoreAt := strings.Index(roundOne, "user_chose_to_ignore:") + if ignoreAt < 0 { + t.Fatalf("missing first-round ignore list:\n%s", got) + } + ignored := roundOne[ignoreAt:] + if strings.Contains(ignored, `"id":"legacy-a"`) || !strings.Contains(ignored, `"id":"legacy-b"`) { + t.Fatalf("repeated selection corrupted occurrence history:\n%s", got) + } +} + +func TestUncertifiedRoundHistoryPromptSection_ReconcilesLaterSelections(t *testing.T) { + initial := `{"findings":[{"id":"review-1","severity":"error","description":"unsafe loader","action":"ask-user"},{"id":"review-2","severity":"warning","description":"hardcoded timeout","action":"ask-user"}]}` + selectedFirst := `["review-1"]` + later := `{"findings":[{"id":"review-2","severity":"warning","description":"hardcoded timeout","action":"ask-user"}]}` + selectedLater := `["review-2"]` + selectionSource := db.RoundSelectionSourceUser + sctx := &pipeline.StepContext{UncertifiedPriorRounds: []*db.StepRound{ + {Round: 1, Trigger: "initial", FindingsJSON: &initial, SelectedFindingIDs: &selectedFirst, SelectionSource: &selectionSource}, + {Round: 2, Trigger: "auto_fix", FindingsJSON: &later, SelectedFindingIDs: &selectedLater, SelectionSource: &selectionSource}, + }} + + got := uncertifiedRoundHistoryPromptSection(sctx) + if strings.Contains(got, "user_chose_to_ignore:") || strings.Count(got, "user_chose_to_fix:") != 2 { + t.Fatalf("uncertified history did not reconcile later selection:\n%s", got) + } +} + func TestRoundHistoryPromptSection_IncludesSourceAndUserInstructions(t *testing.T) { sctx, stepID := newRoundHistoryContext(t) round1 := `{"findings":[{"id":"review-1","severity":"error","description":"panic risk","action":"auto-fix"},{"id":"review-2","severity":"warning","description":"secondary","action":"auto-fix"}],"summary":"2"}` diff --git a/internal/pipeline/steps/steps_test.go b/internal/pipeline/steps/steps_test.go index 527e0e5..01b4a8f 100644 --- a/internal/pipeline/steps/steps_test.go +++ b/internal/pipeline/steps/steps_test.go @@ -56,6 +56,10 @@ func handleFakeCLI(mode string) { fakeGitStatusErrorHandler(args) case "git-remote-error": fakeGitRemoteErrorHandler(args) + case "git-fail-verify-after-push": + fakeGitFailVerifyAfterPushHandler(args) + case "git-fail-after-push": + fakeGitFailAfterPushHandler(args) case "ci-gh": fakeCIGHHandler(args) case "ci-gh-seq": @@ -92,7 +96,7 @@ func logFakeCLIStdinBody(args []string, logFile string) { func argsUseStdinBodyFile(args []string) bool { for i := 0; i+1 < len(args); i++ { - if args[i] == "--body-file" && args[i+1] == "-" { + if (args[i] == "--body-file" || args[i] == "--input") && args[i+1] == "-" { return true } } @@ -126,13 +130,31 @@ func fakeGHHandler(args []string) { os.Exit(0) } if len(args) >= 2 && args[0] == "pr" && args[1] == "view" { + if strings.Contains(strings.Join(args, " "), "updatedAt") { + updatedAt := os.Getenv("FAKE_CLI_GH_PR_UPDATED_AT") + if updatedAt == "" { + os.Exit(1) + } + fmt.Println(updatedAt) + os.Exit(0) + } if prURL != "" { fmt.Println(prURL) os.Exit(0) } os.Exit(1) } - if len(args) >= 2 && args[0] == "pr" && args[1] == "edit" { + if len(args) >= 2 && args[0] == "api" && args[1] == "--method" { + if os.Getenv("FAKE_CLI_GH_EDIT_ERROR") != "" { + fmt.Fprintln(os.Stderr, "injected PR update failure") + os.Exit(1) + } + updatedAt := os.Getenv("FAKE_CLI_GH_UPDATE_RESPONSE_AT") + if updatedAt == "" { + os.Exit(1) + } + number := extractTrailingNumber(prURL) + fmt.Printf("{\"number\":%d,\"html_url\":%q,\"updated_at\":%q}\n", number, prURL, updatedAt) os.Exit(0) } if len(args) >= 2 && args[0] == "pr" && args[1] == "create" { @@ -208,6 +230,68 @@ func fakeGitRemoteErrorHandler(args []string) { fakeGitForward(args, realGit) } +func fakeGitFailVerifyAfterPushHandler(args []string) { + realGit := os.Getenv("FAKE_CLI_REAL_GIT") + marker := os.Getenv("FAKE_CLI_PUSH_MARKER") + if len(args) > 0 && args[0] == "ls-remote" { + if _, err := os.Stat(marker); err == nil { + fmt.Fprintln(os.Stderr, "post-push verification unavailable") + os.Exit(1) + } + } + cmd := exec.Command(realGit, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + if err := cmd.Run(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode() >= 0 { + os.Exit(exitErr.ExitCode()) + } + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if len(args) > 0 && args[0] == "push" { + if err := os.WriteFile(marker, []byte("pushed"), 0o644); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + } + os.Exit(0) +} + +func fakeGitFailAfterPushHandler(args []string) { + realGit := os.Getenv("FAKE_CLI_REAL_GIT") + marker := os.Getenv("FAKE_CLI_PUSH_MARKER") + if len(args) > 0 && args[0] == "ls-remote" { + if _, err := os.Stat(marker); err == nil { + fmt.Fprintln(os.Stderr, "push reconciliation unavailable") + os.Exit(1) + } + } + cmd := exec.Command(realGit, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + if err := cmd.Run(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode() >= 0 { + os.Exit(exitErr.ExitCode()) + } + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if len(args) > 0 && args[0] == "push" { + if err := os.WriteFile(marker, []byte("pushed"), 0o644); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Fprintln(os.Stderr, "transport closed after remote update") + os.Exit(1) + } + os.Exit(0) +} + func fakeGitForward(args []string, realGit string) { if realGit == "" { fmt.Fprintln(os.Stderr, "missing FAKE_CLI_REAL_GIT") @@ -328,6 +412,14 @@ func fakeCIGHHandler(args []string) { if len(args) >= 2 && args[0] == "auth" && args[1] == "status" { os.Exit(0) } + if strings.Contains(joined, "pr view") && strings.Contains(joined, "--json title,body") { + body := "## Pipeline\n\n" + noMistakesPRSignature + "\n\n" + pipelineAttestationCommentPrefix + `{"head_sha":"stale","steps":[]}` + pipelineAttestationCommentClosingToken + fmt.Printf("{\"title\":\"test\",\"body\":%s}\n", strconv.Quote(body)) + os.Exit(0) + } + if strings.Contains(joined, "pr edit") { + os.Exit(0) + } if strings.Contains(joined, "pr view") && strings.Contains(joined, "--json mergeable") { if mergeableErr != "" { fmt.Fprintln(os.Stderr, mergeableErr) @@ -386,6 +478,14 @@ func fakeCIGHSequenceHandler(args []string) { if len(args) >= 2 && args[0] == "auth" && args[1] == "status" { os.Exit(0) } + if strings.Contains(joined, "pr view") && strings.Contains(joined, "--json title,body") { + body := "## Pipeline\n\n" + noMistakesPRSignature + "\n\n" + pipelineAttestationCommentPrefix + `{"head_sha":"stale","steps":[]}` + pipelineAttestationCommentClosingToken + fmt.Printf("{\"title\":\"test\",\"body\":%s}\n", strconv.Quote(body)) + os.Exit(0) + } + if strings.Contains(joined, "pr edit") { + os.Exit(0) + } if strings.Contains(joined, "pr view") && strings.Contains(joined, "--json mergeable") { if mergeableErr != "" { fmt.Fprintln(os.Stderr, mergeableErr) diff --git a/internal/pipeline/uncertified.go b/internal/pipeline/uncertified.go index 791b0e6..0c07d2f 100644 --- a/internal/pipeline/uncertified.go +++ b/internal/pipeline/uncertified.go @@ -2,8 +2,11 @@ package pipeline import ( "context" + "encoding/json" + "errors" "fmt" "log/slog" + "os/exec" "strconv" "strings" @@ -12,205 +15,254 @@ import ( "github.com/Blakeolson21/no-slop/internal/types" ) -// BindUncertifiedPipelineRange copies a persisted uncertified fixer range +// BindUncertifiedPipelineRange copies a persisted uncertified recovery boundary // onto the review step context when this run's head is that range's tip or a -// descendant of it. Missing objects fail open: the run continues without the -// provenance clause and a bounded warning is logged. Never blocks the run. -func BindUncertifiedPipelineRange(sctx *StepContext) { +// descendant of it. Unreadable commit ancestry or persisted review truth +// blocks replacement review. +func BindUncertifiedPipelineRange(sctx *StepContext) error { if sctx == nil || sctx.DB == nil || sctx.Repo == nil || sctx.Run == nil || sctx.Fixing { - return + return nil } rng, err := sctx.DB.GetUncertifiedPipelineRange(sctx.Repo.ID, sctx.Run.Branch) if err != nil { - slog.Warn("failed to read uncertified pipeline range; not applying provenance", "repo_id", sctx.Repo.ID, "error", err) - return + return fmt.Errorf("read uncertified pipeline range: %w", err) } if rng == nil { - return + return nil } head := strings.TrimSpace(sctx.Run.HeadSHA) if head == "" { head = strings.TrimSpace(sctx.ReviewStartingHeadSHA) } - if !commitIsSelfOrAncestor(sctx.Ctx, sctx.WorkDir, rng.ToSHA, head) { + inLineage, err := commitIsSelfOrAncestor(sctx.Ctx, sctx.WorkDir, rng.ToSHA, head) + if err != nil { + return fmt.Errorf("verify uncertified pipeline range ancestry: %w", err) + } + if !inLineage { warnUncertifiedRangeSkipped(sctx, rng, "uncertified range %s..%s not in gate; not applying provenance") - return + return nil + } + priorRounds, priorFindings, priorLineages, err := loadUncertifiedPriorReview(sctx.DB, rng.SourceRunID, rng.SelectionApplied) + if err != nil { + return err } sctx.UncertifiedFromSHA = rng.FromSHA sctx.UncertifiedToSHA = rng.ToSHA sctx.UncertifiedSourceRunID = rng.SourceRunID - sctx.UncertifiedPriorRounds = loadUncertifiedPriorRounds(sctx.DB, rng.SourceRunID) + sctx.UncertifiedPriorRounds = priorRounds + sctx.UncertifiedPriorFindings = priorFindings + sctx.UncertifiedPriorLineages = priorLineages + return nil } -// PersistUncertifiedPipelineRange records the fixer commit span after a -// review fix round commits and before its re-review completes. -func PersistUncertifiedPipelineRange(sctx *StepContext, fromSHA, toSHA string) { +// PersistUncertifiedPipelineRange records a post-review commit span until a +// review of the new head completes. +func PersistUncertifiedPipelineRange(sctx *StepContext, fromSHA, toSHA string) error { + _, err := PersistUncertifiedPipelineRangeWithRollback(sctx, fromSHA, toSHA) + return err +} + +func PersistUncertifiedPipelineRangeWithRollback(sctx *StepContext, fromSHA, toSHA string) (func() error, error) { if sctx == nil || sctx.DB == nil || sctx.Repo == nil || sctx.Run == nil { - return + return nil, fmt.Errorf("persist uncertified pipeline range: missing pipeline context") } fromSHA = strings.TrimSpace(fromSHA) toSHA = strings.TrimSpace(toSHA) if fromSHA == "" || toSHA == "" || fromSHA == toSHA { - return + return nil, fmt.Errorf("persist uncertified pipeline range: invalid commit range") } existing, err := sctx.DB.GetUncertifiedPipelineRange(sctx.Repo.ID, sctx.Run.Branch) if err != nil { - slog.Warn("failed to read uncertified pipeline range before persist", "run_id", sctx.Run.ID, "error", err) - existing = nil + return nil, fmt.Errorf("read uncertified pipeline range before persist: %w", err) } - if existing != nil && strings.TrimSpace(existing.FromSHA) != "" && - uncertifiedRangeStillInLineage(sctx, existing.ToSHA, fromSHA, toSHA) { - fromSHA = existing.FromSHA + if existing != nil && strings.TrimSpace(existing.FromSHA) != "" { + inLineage, err := uncertifiedRangeStillInLineage(sctx, existing.ToSHA, fromSHA, toSHA) + if err != nil { + return nil, fmt.Errorf("verify uncertified pipeline range lineage before persist: %w", err) + } + if inLineage { + fromSHA = existing.FromSHA + } } if err := sctx.DB.UpsertUncertifiedPipelineRange(sctx.Repo.ID, sctx.Run.Branch, fromSHA, toSHA, sctx.Run.ID); err != nil { - slog.Warn("failed to persist uncertified pipeline range", "run_id", sctx.Run.ID, "error", err) - if sctx.Log != nil { - sctx.Log("warning: failed to persist uncertified fixer commit range") + return nil, err + } + current := db.UncertifiedPipelineRange{ + RepoID: sctx.Repo.ID, + Branch: sctx.Run.Branch, + FromSHA: fromSHA, + ToSHA: toSHA, + SourceRunID: sctx.Run.ID, + SelectionApplied: true, + } + rollback := func() error { + restored, err := sctx.DB.RestoreUncertifiedPipelineRangeIfCurrent(current, existing) + if err != nil { + return err + } + if !restored { + return fmt.Errorf("uncertified pipeline range changed before rollback") } + return nil } + return rollback, nil } -// ClearUncertifiedPipelineRangeIfCertified drops the branch marker once a -// full review has completed. A completed review of the current head certifies -// the previously uncertified fixer commits on this branch. -func ClearUncertifiedPipelineRangeIfCertified(ctx context.Context, database *db.DB, repoID, branch, approvedHead, workDir string) { +func certifiedUncertifiedPipelineRange(ctx context.Context, database *db.DB, repoID, branch, approvedHead, workDir string) (*db.UncertifiedPipelineRange, error) { if database == nil { - return + return nil, nil } rng, err := database.GetUncertifiedPipelineRange(repoID, branch) if err != nil { - slog.Warn("failed to read uncertified pipeline range before clear", "repo_id", repoID, "error", err) - return + return nil, fmt.Errorf("read uncertified pipeline range before certification: %w", err) } if rng == nil { - return + return nil, nil } approvedHead = strings.TrimSpace(approvedHead) if approvedHead == "" { - return - } - if rng.ToSHA != approvedHead && !commitIsSelfOrAncestor(ctx, workDir, rng.ToSHA, approvedHead) { - return + return nil, fmt.Errorf("certify uncertified pipeline range: missing approved head") } - if err := database.DeleteUncertifiedPipelineRange(repoID, branch); err != nil { - slog.Warn("failed to clear uncertified pipeline range after certified review", "repo_id", repoID, "error", err) + if rng.ToSHA != approvedHead { + inLineage, err := commitIsSelfOrAncestor(ctx, workDir, rng.ToSHA, approvedHead) + if err != nil { + return nil, fmt.Errorf("verify uncertified pipeline range before certification: %w", err) + } + if !inLineage { + return nil, nil + } } + return rng, nil } // RemapUncertifiedPipelineRangeAfterRebase rewrites a persisted uncertified // range onto the new head when rebase replaced a head that contained it. -// Fast-forwards and missing objects leave the row unchanged and never block. -func RemapUncertifiedPipelineRangeAfterRebase(sctx *StepContext, oldHead, newHead string) { +func RemapUncertifiedPipelineRangeAfterRebase(sctx *StepContext, oldHead, newHead string) (func() error, error) { if sctx == nil || sctx.DB == nil || sctx.Repo == nil || sctx.Run == nil { - return + return nil, fmt.Errorf("remap uncertified pipeline range: missing pipeline context") } oldHead = strings.TrimSpace(oldHead) newHead = strings.TrimSpace(newHead) if oldHead == "" || newHead == "" || oldHead == newHead { - return - } - if commitIsSelfOrAncestor(sctx.Ctx, sctx.WorkDir, oldHead, newHead) { - return + return nil, nil } rng, err := sctx.DB.GetUncertifiedPipelineRange(sctx.Repo.ID, sctx.Run.Branch) if err != nil { - slog.Warn("failed to read uncertified pipeline range before rebase remap", "run_id", sctx.Run.ID, "error", err) - return + return nil, fmt.Errorf("read uncertified pipeline range before rebase remap: %w", err) } if rng == nil { - return - } - if !commitIsSelfOrAncestor(sctx.Ctx, sctx.WorkDir, rng.ToSHA, oldHead) { - return - } - if commitIsSelfOrAncestor(sctx.Ctx, sctx.WorkDir, rng.ToSHA, newHead) { - return - } - fromBehind, ok := commitBehindCount(sctx.Ctx, sctx.WorkDir, rng.FromSHA, oldHead) - if !ok { - warnUncertifiedRemapSkipped(sctx, rng) - return - } - toBehind, ok := commitBehindCount(sctx.Ctx, sctx.WorkDir, rng.ToSHA, oldHead) - if !ok { - warnUncertifiedRemapSkipped(sctx, rng) - return - } - newFrom, ok := commitNthAncestor(sctx.Ctx, sctx.WorkDir, newHead, fromBehind) - if !ok { - warnUncertifiedRemapSkipped(sctx, rng) - return - } - newTo, ok := commitNthAncestor(sctx.Ctx, sctx.WorkDir, newHead, toBehind) - if !ok || newFrom == "" || newTo == "" || newFrom == newTo { - warnUncertifiedRemapSkipped(sctx, rng) - return - } - if err := sctx.DB.UpsertUncertifiedPipelineRange(sctx.Repo.ID, sctx.Run.Branch, newFrom, newTo, rng.SourceRunID); err != nil { - slog.Warn("failed to remap uncertified pipeline range after rebase", "run_id", sctx.Run.ID, "error", err) - if sctx.Log != nil { - sctx.Log("warning: failed to remap uncertified fixer commit range after rebase") + return nil, nil + } + oldInNew, err := commitIsSelfOrAncestor(sctx.Ctx, sctx.WorkDir, oldHead, newHead) + if err != nil { + return nil, fmt.Errorf("verify rebased head ancestry: %w", err) + } + if oldInNew { + return nil, nil + } + rangeInOld, err := commitIsSelfOrAncestor(sctx.Ctx, sctx.WorkDir, rng.ToSHA, oldHead) + if err != nil { + return nil, fmt.Errorf("verify uncertified range against pre-rebase head: %w", err) + } + if !rangeInOld { + return nil, nil + } + rangeInNew, err := commitIsSelfOrAncestor(sctx.Ctx, sctx.WorkDir, rng.ToSHA, newHead) + if err != nil { + return nil, fmt.Errorf("verify uncertified range against rebased head: %w", err) + } + if rangeInNew { + return nil, nil + } + fromBehind, err := commitBehindCount(sctx.Ctx, sctx.WorkDir, rng.FromSHA, oldHead) + if err != nil { + return nil, fmt.Errorf("map uncertified range start %s after rebase: %w", rng.FromSHA, err) + } + toBehind, err := commitBehindCount(sctx.Ctx, sctx.WorkDir, rng.ToSHA, oldHead) + if err != nil { + return nil, fmt.Errorf("map uncertified range end %s after rebase: %w", rng.ToSHA, err) + } + newFrom, err := commitNthAncestor(sctx.Ctx, sctx.WorkDir, newHead, fromBehind) + if err != nil { + return nil, fmt.Errorf("resolve remapped uncertified range start after rebase: %w", err) + } + newTo, err := commitNthAncestor(sctx.Ctx, sctx.WorkDir, newHead, toBehind) + if err != nil || newFrom == "" || newTo == "" || newFrom == newTo { + return nil, fmt.Errorf("resolve remapped uncertified range end after rebase") + } + if err := sctx.DB.UpsertUncertifiedPipelineRangeState(sctx.Repo.ID, sctx.Run.Branch, newFrom, newTo, rng.SourceRunID, rng.SelectionApplied); err != nil { + return nil, fmt.Errorf("persist remapped uncertified pipeline range: %w", err) + } + current := db.UncertifiedPipelineRange{RepoID: rng.RepoID, Branch: rng.Branch, FromSHA: newFrom, ToSHA: newTo, SourceRunID: rng.SourceRunID, SelectionApplied: rng.SelectionApplied} + rollback := func() error { + restored, err := sctx.DB.RestoreUncertifiedPipelineRangeIfCurrent(current, rng) + if err != nil { + return err } + if !restored { + return fmt.Errorf("uncertified pipeline range changed before rollback") + } + return nil } + return rollback, nil } -func uncertifiedRangeStillInLineage(sctx *StepContext, existingTo, newFrom, newTo string) bool { +func uncertifiedRangeStillInLineage(sctx *StepContext, existingTo, newFrom, newTo string) (bool, error) { if sctx == nil { - return false + return false, fmt.Errorf("missing pipeline context") } - return commitIsSelfOrAncestor(sctx.Ctx, sctx.WorkDir, existingTo, newFrom) || - commitIsSelfOrAncestor(sctx.Ctx, sctx.WorkDir, existingTo, newTo) -} - -func warnUncertifiedRemapSkipped(sctx *StepContext, rng *db.UncertifiedPipelineRange) { - msg := fmt.Sprintf("uncertified range %s..%s could not be remapped after rebase; not updating provenance", rng.FromSHA, rng.ToSHA) - slog.Warn(msg, "repo_id", sctx.Repo.ID, "branch", sctx.Run.Branch) - if sctx.Log != nil { - sctx.Log("warning: " + msg) + inFrom, err := commitIsSelfOrAncestor(sctx.Ctx, sctx.WorkDir, existingTo, newFrom) + if err != nil || inFrom { + return inFrom, err } + return commitIsSelfOrAncestor(sctx.Ctx, sctx.WorkDir, existingTo, newTo) } -func commitBehindCount(ctx context.Context, workDir, ancestor, descendent string) (int, bool) { - if !commitIsSelfOrAncestor(ctx, workDir, ancestor, descendent) { - return 0, false +func commitBehindCount(ctx context.Context, workDir, ancestor, descendent string) (int, error) { + inLineage, err := commitIsSelfOrAncestor(ctx, workDir, ancestor, descendent) + if err != nil { + return 0, err + } + if !inLineage { + return 0, fmt.Errorf("%s is not an ancestor of %s", ancestor, descendent) } if strings.TrimSpace(ancestor) == strings.TrimSpace(descendent) { - return 0, true + return 0, nil } if ctx == nil { ctx = context.Background() } out, err := git.Run(ctx, workDir, "rev-list", "--count", ancestor+".."+descendent) if err != nil { - return 0, false + return 0, err } n, err := strconv.Atoi(strings.TrimSpace(out)) if err != nil || n < 0 { - return 0, false + return 0, fmt.Errorf("invalid commit distance %q", out) } - return n, true + return n, nil } -func commitNthAncestor(ctx context.Context, workDir, sha string, n int) (string, bool) { +func commitNthAncestor(ctx context.Context, workDir, sha string, n int) (string, error) { sha = strings.TrimSpace(sha) if sha == "" || n < 0 || workDir == "" { - return "", false + return "", fmt.Errorf("invalid commit ancestor request") } if n == 0 { - return sha, true + return sha, nil } if ctx == nil { ctx = context.Background() } out, err := git.Run(ctx, workDir, "rev-parse", "--verify", fmt.Sprintf("%s~%d", sha, n)) if err != nil { - return "", false + return "", err } out = strings.TrimSpace(out) if out == "" { - return "", false + return "", fmt.Errorf("resolved empty commit ancestor") } - return out, true + return out, nil } func warnUncertifiedRangeSkipped(sctx *StepContext, rng *db.UncertifiedPipelineRange, format string) { @@ -221,42 +273,74 @@ func warnUncertifiedRangeSkipped(sctx *StepContext, rng *db.UncertifiedPipelineR } } -func commitIsSelfOrAncestor(ctx context.Context, workDir, ancestor, descendent string) bool { +func commitIsSelfOrAncestor(ctx context.Context, workDir, ancestor, descendent string) (bool, error) { ancestor = strings.TrimSpace(ancestor) descendent = strings.TrimSpace(descendent) if ancestor == "" || descendent == "" || workDir == "" { - return false + return false, fmt.Errorf("commit ancestry requires worktree and two commits") } if ancestor == descendent { - return true + return true, nil } if ctx == nil { ctx = context.Background() } _, err := git.Run(ctx, workDir, "merge-base", "--is-ancestor", ancestor, descendent) - return err == nil + if err == nil { + return true, nil + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { + return false, nil + } + return false, err +} + +type uncertifiedReviewStore interface { + GetStepsByRun(string) ([]*db.StepResult, error) + GetRoundsByStep(string) ([]*db.StepRound, error) + GetLatestStepRoundSelection(string) (*string, error) } -func loadUncertifiedPriorRounds(database *db.DB, sourceRunID string) []*db.StepRound { +func loadUncertifiedPriorReview(database uncertifiedReviewStore, sourceRunID string, selectionApplied bool) ([]*db.StepRound, string, string, error) { sourceRunID = strings.TrimSpace(sourceRunID) if database == nil || sourceRunID == "" { - return nil + return nil, "", "", fmt.Errorf("load uncertified review: missing source run") } steps, err := database.GetStepsByRun(sourceRunID) if err != nil { - slog.Warn("failed to read uncertified source-run steps", "run_id", sourceRunID, "error", err) - return nil + return nil, "", "", fmt.Errorf("read uncertified source-run steps: %w", err) } for _, step := range steps { if step.StepName != types.StepReview { continue } + findings := "" + lineages := "" + if step.FindingsJSON != nil { + findings = *step.FindingsJSON + if _, err := types.ParseFindingsJSON(findings); err != nil { + return nil, "", "", fmt.Errorf("read uncertified source-run findings: %w", err) + } + lineages = findings + } + selectedRaw, err := database.GetLatestStepRoundSelection(step.ID) + if err != nil { + return nil, "", "", fmt.Errorf("read uncertified source-run selection: %w", err) + } + if selectedRaw != nil && selectionApplied { + var selected []string + if err := json.Unmarshal([]byte(*selectedRaw), &selected); err != nil { + return nil, "", "", fmt.Errorf("read uncertified source-run selection: %w", err) + } + findings = excludeFindingsJSON(findings, selected) + } rounds, err := database.GetRoundsByStep(step.ID) if err != nil { slog.Warn("failed to read uncertified source-run review rounds", "run_id", sourceRunID, "error", err) - return nil + return nil, findings, lineages, nil } - return rounds + return rounds, findings, lineages, nil } - return nil + return nil, "", "", fmt.Errorf("uncertified source run %s has no review step", sourceRunID) } diff --git a/internal/pipeline/uncertified_test.go b/internal/pipeline/uncertified_test.go index 737c7e6..7f3ec95 100644 --- a/internal/pipeline/uncertified_test.go +++ b/internal/pipeline/uncertified_test.go @@ -2,18 +2,28 @@ package pipeline import ( "context" + "errors" "fmt" "strings" "testing" + "time" "github.com/Blakeolson21/no-slop/internal/config" + "github.com/Blakeolson21/no-slop/internal/db" "github.com/Blakeolson21/no-slop/internal/git" "github.com/Blakeolson21/no-slop/internal/types" ) func TestExecutor_BindsUncertifiedRangeOntoInitialReview(t *testing.T) { database, p, run, repo := setupTest(t) - if err := database.UpsertUncertifiedPipelineRange(repo.ID, run.Branch, "from-sha", run.HeadSHA, "source-run"); err != nil { + source, err := database.InsertRun(repo.ID, run.Branch, "older", "base") + if err != nil { + t.Fatal(err) + } + if _, err := database.InsertStepResult(source.ID, types.StepReview); err != nil { + t.Fatal(err) + } + if err := database.UpsertUncertifiedPipelineRange(repo.ID, run.Branch, "from-sha", run.HeadSHA, source.ID); err != nil { t.Fatal(err) } var gotFrom, gotTo, gotSource string @@ -30,11 +40,156 @@ func TestExecutor_BindsUncertifiedRangeOntoInitialReview(t *testing.T) { if fixing { t.Fatal("initial review ran in fix mode") } - if gotFrom != "from-sha" || gotTo != run.HeadSHA || gotSource != "source-run" { + if gotFrom != "from-sha" || gotTo != run.HeadSHA || gotSource != source.ID { t.Fatalf("initial review bound from=%q to=%q source=%q", gotFrom, gotTo, gotSource) } } +type failingUncertifiedReviewStore struct { + steps []*db.StepResult + stepsErr error + roundsErr error + selection *string + selectErr error +} + +func (s *failingUncertifiedReviewStore) GetStepsByRun(string) ([]*db.StepResult, error) { + return s.steps, s.stepsErr +} + +func (s *failingUncertifiedReviewStore) GetRoundsByStep(string) ([]*db.StepRound, error) { + return nil, s.roundsErr +} + +func (s *failingUncertifiedReviewStore) GetLatestStepRoundSelection(string) (*string, error) { + return s.selection, s.selectErr +} + +func TestLoadUncertifiedPriorReviewKeepsEffectiveFindingsWhenRoundsFail(t *testing.T) { + findings := `{"findings":[{"id":"review-b","severity":"error","description":"unresolved defect","action":"ask-user"}]}` + store := &failingUncertifiedReviewStore{ + steps: []*db.StepResult{{ID: "review-step", StepName: types.StepReview, FindingsJSON: &findings}}, + roundsErr: errors.New("round history unavailable"), + } + rounds, got, lineages, err := loadUncertifiedPriorReview(store, "source-run", false) + if err != nil { + t.Fatal(err) + } + if rounds != nil || got != findings || lineages != findings { + t.Fatalf("loadUncertifiedPriorReview() = (%#v, %q, %q), want nil rounds and effective findings", rounds, got, lineages) + } +} + +func TestLoadUncertifiedPriorReviewFailsWhenEffectiveTruthCannotBeRead(t *testing.T) { + store := &failingUncertifiedReviewStore{stepsErr: errors.New("step truth unavailable")} + if _, _, _, err := loadUncertifiedPriorReview(store, "source-run", false); err == nil || !strings.Contains(err.Error(), "source-run steps") { + t.Fatalf("loadUncertifiedPriorReview() error = %v, want critical read failure", err) + } +} + +func TestLoadUncertifiedPriorReviewFailsWhenSelectionCannotBeRead(t *testing.T) { + findings := `{"findings":[{"id":"review-a","severity":"error","description":"selected defect","action":"auto-fix"}]}` + store := &failingUncertifiedReviewStore{ + steps: []*db.StepResult{{ID: "review-step", StepName: types.StepReview, FindingsJSON: &findings}}, + selectErr: errors.New("selection unavailable"), + } + if _, _, _, err := loadUncertifiedPriorReview(store, "source-run", false); err == nil || !strings.Contains(err.Error(), "source-run selection") { + t.Fatalf("loadUncertifiedPriorReview() error = %v, want critical selection failure", err) + } +} + +func TestLoadUncertifiedPriorReviewPreservesSelectedTruthBeforeFixAdoption(t *testing.T) { + findings := `{"findings":[{"id":"review-a","severity":"error","description":"selected defect","action":"auto-fix"},{"id":"review-b","severity":"warning","description":"unselected defect","action":"ask-user"}]}` + selection := `["review-a"]` + store := &failingUncertifiedReviewStore{ + steps: []*db.StepResult{{ID: "review-step", StepName: types.StepReview, FindingsJSON: &findings}}, + selection: &selection, + } + _, got, _, err := loadUncertifiedPriorReview(store, "source-run", false) + if err != nil { + t.Fatal(err) + } + parsed, err := types.ParseFindingsJSON(got) + if err != nil { + t.Fatal(err) + } + if len(parsed.Items) != 2 { + t.Fatalf("same-head recovery findings = %#v, want selected and unselected truth", parsed.Items) + } +} + +func TestBindUncertifiedPipelineRangeUsesDurableSelectionProgress(t *testing.T) { + database, _, source, repo := setupTest(t) + dir := t.TempDir() + initGitRepo(t, dir) + h0 := currentSHA(t, dir) + writeTestFile(t, dir, "fix.txt", "first\n") + execGit(t, dir, "add", ".") + execGit(t, dir, "commit", "-m", "first fix") + h1 := currentSHA(t, dir) + writeTestFile(t, dir, "fix.txt", "second\n") + execGit(t, dir, "add", ".") + execGit(t, dir, "commit", "-m", "second fix") + h2 := currentSHA(t, dir) + if err := database.UpdateRunHeadSHA(source.ID, h1); err != nil { + t.Fatal(err) + } + source.HeadSHA = h1 + + review, err := database.InsertStepResult(source.ID, types.StepReview) + if err != nil { + t.Fatal(err) + } + findings := `{"findings":[{"id":"review-a","severity":"error","description":"selected defect","action":"auto-fix"},{"id":"review-b","severity":"warning","description":"unselected defect","action":"ask-user"}]}` + round, err := database.InsertEffectiveReviewStepRoundWithProvenance(review.ID, 1, "initial", &findings, nil, h1, h1, "", nil, nil, 1) + if err != nil { + t.Fatal(err) + } + selected := `["review-a"]` + if err := database.PersistReviewFixSelection(db.ReviewFixSelection{ + RoundID: round.ID, StepResultID: review.ID, RepoID: repo.ID, Branch: source.Branch, + FromSHA: h0, HeadSHA: h1, SourceRunID: source.ID, RoundFindingsJSON: findings, + StepFindingsJSON: findings, SelectedFindingIDs: &selected, SelectionSource: db.RoundSelectionSourceAutoFix, + }); err != nil { + t.Fatal(err) + } + + beforeRun, err := database.InsertRun(repo.ID, source.Branch, h1, source.BaseSHA) + if err != nil { + t.Fatal(err) + } + before := &StepContext{Ctx: context.Background(), DB: database, Repo: repo, Run: beforeRun, WorkDir: dir} + if err := BindUncertifiedPipelineRange(before); err != nil { + t.Fatal(err) + } + beforeFindings, err := types.ParseFindingsJSON(before.UncertifiedPriorFindings) + if err != nil { + t.Fatal(err) + } + if len(beforeFindings.Items) != 2 { + t.Fatalf("pre-adoption recovery findings = %#v", beforeFindings.Items) + } + + if err := PersistUncertifiedPipelineRange(&StepContext{Ctx: context.Background(), DB: database, Repo: repo, Run: source, WorkDir: dir}, h1, h2); err != nil { + t.Fatal(err) + } + afterRun, err := database.InsertRun(repo.ID, source.Branch, h2, source.BaseSHA) + if err != nil { + t.Fatal(err) + } + after := &StepContext{Ctx: context.Background(), DB: database, Repo: repo, Run: afterRun, WorkDir: dir} + if err := BindUncertifiedPipelineRange(after); err != nil { + t.Fatal(err) + } + afterFindings, err := types.ParseFindingsJSON(after.UncertifiedPriorFindings) + if err != nil { + t.Fatal(err) + } + if len(afterFindings.Items) != 1 || afterFindings.Items[0].ID != "review-b" { + t.Fatalf("post-adoption recovery findings = %#v", afterFindings.Items) + } +} + func TestBindUncertifiedPipelineRange_CopiesOntoStepContext(t *testing.T) { database, _, run, repo := setupTest(t) if err := database.UpsertUncertifiedPipelineRange(repo.ID, run.Branch, "from-sha", run.HeadSHA, "source-run"); err != nil { @@ -72,27 +227,89 @@ func TestBindUncertifiedPipelineRange_CopiesOntoStepContext(t *testing.T) { } } -func TestBindUncertifiedPipelineRange_MissingFromGateWarnsAndContinues(t *testing.T) { +func TestExecutor_RestoresUncertifiedPriorRunEffectiveFindings(t *testing.T) { + database, p, run, repo := setupTest(t) + source, err := database.InsertRun(repo.ID, run.Branch, "older", "base") + if err != nil { + t.Fatal(err) + } + sourceReview, err := database.InsertStepResult(source.ID, types.StepReview) + if err != nil { + t.Fatal(err) + } + prior := `{"findings":[{"id":"review-a","id_generated":true,"continuity_token":"token-a","severity":"error","description":"selected defect","action":"auto-fix"},{"id":"review-b","id_generated":true,"continuity_token":"token-b","severity":"error","description":"unresolved defect","action":"ask-user"}]}` + round, err := database.InsertEffectiveReviewStepRoundWithProvenance(sourceReview.ID, 1, "initial", &prior, nil, "older", "older", "", nil, nil, 10) + if err != nil { + t.Fatal(err) + } + selected := `["review-a"]` + if err := database.SetStepRoundSelection(round.ID, &selected, db.RoundSelectionSourceAutoFix); err != nil { + t.Fatal(err) + } + if err := database.UpsertUncertifiedPipelineRange(repo.ID, run.Branch, "from-sha", run.HeadSHA, source.ID); err != nil { + t.Fatal(err) + } + + step := &scopeLimitedAdaptiveCallStep{adaptiveCallStep{name: types.StepReview, fn: func(*StepContext) (*StepOutcome, error) { + return &StepOutcome{ReviewApprovedHeadSHA: run.HeadSHA}, nil + }}} + exec := NewExecutor(database, p, &config.Config{}, nil, []Step{step}, nil) + done := make(chan error, 1) + go func() { done <- exec.Execute(context.Background(), run, repo, t.TempDir()) }() + waitForStepStatus(t, database, run.ID, types.StepReview, types.StepStatusAwaitingApproval) + if err := exec.Respond(types.StepReview, types.ActionApprove, nil); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("executor timed out") + } + + steps, err := database.GetStepsByRun(run.ID) + if err != nil { + t.Fatal(err) + } + if len(steps) != 1 || steps[0].FindingsJSON == nil { + t.Fatalf("replacement review = %#v", steps) + } + got, err := types.ParseFindingsJSON(*steps[0].FindingsJSON) + if err != nil { + t.Fatal(err) + } + if len(got.Items) != 1 || got.Items[0].ID != "review-b" || got.Items[0].Description != "unresolved defect" { + t.Fatalf("restored findings = %#v", got.Items) + } +} + +func TestBindUncertifiedPipelineRange_MissingCommitFailsClosed(t *testing.T) { database, _, run, repo := setupTest(t) if err := database.UpsertUncertifiedPipelineRange(repo.ID, run.Branch, "from-missing", "to-missing", "source-run"); err != nil { t.Fatal(err) } - var logs []string sctx := &StepContext{ Ctx: context.Background(), DB: database, Repo: repo, Run: run, WorkDir: t.TempDir(), - Log: func(line string) { logs = append(logs, line) }, } - BindUncertifiedPipelineRange(sctx) + err := BindUncertifiedPipelineRange(sctx) + if err == nil || !strings.Contains(err.Error(), "verify uncertified pipeline range ancestry") { + t.Fatalf("BindUncertifiedPipelineRange() error = %v, want ancestry failure", err) + } if sctx.UncertifiedToSHA != "" || sctx.UncertifiedFromSHA != "" { t.Fatalf("missing range was applied: from=%q to=%q", sctx.UncertifiedFromSHA, sctx.UncertifiedToSHA) } - joined := strings.Join(logs, "\n") - if !strings.Contains(joined, "uncertified range from-missing..to-missing not in gate; not applying provenance") { - t.Fatalf("logs = %q, want skip warning", joined) + got, getErr := database.GetUncertifiedPipelineRange(repo.ID, run.Branch) + if getErr != nil { + t.Fatal(getErr) + } + if got == nil || got.FromSHA != "from-missing" || got.ToSHA != "to-missing" { + t.Fatalf("failed ancestry probe changed range: %#v", got) } } @@ -378,6 +595,34 @@ func TestPersistUncertifiedPipelineRange_ReplacesRangeWhenHistoryDiverged(t *tes } } +func TestPersistUncertifiedPipelineRange_IndeterminateLineagePreservesRange(t *testing.T) { + database, _, run, repo := setupTest(t) + dir := t.TempDir() + initGitRepo(t, dir) + h0 := currentSHA(t, dir) + writeTestFile(t, dir, "fix.txt", "fix\n") + execGit(t, dir, "add", ".") + execGit(t, dir, "commit", "-m", "fix") + h1 := currentSHA(t, dir) + if err := database.UpsertUncertifiedPipelineRange(repo.ID, run.Branch, h0, h1, run.ID); err != nil { + t.Fatal(err) + } + + err := PersistUncertifiedPipelineRange(&StepContext{ + Ctx: context.Background(), DB: database, Repo: repo, Run: run, WorkDir: dir, + }, "missing-object", h0) + if err == nil || !strings.Contains(err.Error(), "verify uncertified pipeline range lineage") { + t.Fatalf("PersistUncertifiedPipelineRange() error = %v, want indeterminate lineage failure", err) + } + got, getErr := database.GetUncertifiedPipelineRange(repo.ID, run.Branch) + if getErr != nil { + t.Fatal(getErr) + } + if got == nil || got.FromSHA != h0 || got.ToSHA != h1 || got.SourceRunID != run.ID { + t.Fatalf("indeterminate lineage overwrote range: %#v", got) + } +} + func TestRemapUncertifiedPipelineRangeAfterRebase_RewrittenHeadStaysBindable(t *testing.T) { database, _, run, repo := setupTest(t) dir := t.TempDir() @@ -392,6 +637,9 @@ func TestRemapUncertifiedPipelineRangeAfterRebase_RewrittenHeadStaysBindable(t * execGit(t, dir, "add", ".") execGit(t, dir, "commit", "-m", "fixer") toSHA := currentSHA(t, dir) + if _, err := database.InsertStepResult(run.ID, types.StepReview); err != nil { + t.Fatal(err) + } if err := database.UpsertUncertifiedPipelineRange(repo.ID, run.Branch, fromSHA, toSHA, run.ID); err != nil { t.Fatal(err) } @@ -416,7 +664,9 @@ func TestRemapUncertifiedPipelineRangeAfterRebase_RewrittenHeadStaysBindable(t * WorkDir: dir, } sctx.Run.HeadSHA = newHead - RemapUncertifiedPipelineRangeAfterRebase(sctx, toSHA, newHead) + if _, err := RemapUncertifiedPipelineRangeAfterRebase(sctx, toSHA, newHead); err != nil { + t.Fatal(err) + } got, err := database.GetUncertifiedPipelineRange(repo.ID, run.Branch) if err != nil { @@ -435,7 +685,7 @@ func TestRemapUncertifiedPipelineRangeAfterRebase_RewrittenHeadStaysBindable(t * } } -func TestRemapUncertifiedPipelineRangeAfterRebase_LeavesRangeWhenOldHeadDidNotContainIt(t *testing.T) { +func TestRemapUncertifiedPipelineRangeAfterRebase_MissingRangeCommitFailsClosed(t *testing.T) { database, _, run, repo := setupTest(t) dir := t.TempDir() initGitRepo(t, dir) @@ -454,13 +704,15 @@ func TestRemapUncertifiedPipelineRangeAfterRebase_LeavesRangeWhenOldHeadDidNotCo execGit(t, dir, "commit", "-m", "rewrite") newHead := currentSHA(t, dir) - RemapUncertifiedPipelineRangeAfterRebase(&StepContext{ + if _, err := RemapUncertifiedPipelineRangeAfterRebase(&StepContext{ Ctx: context.Background(), DB: database, Repo: repo, Run: run, WorkDir: dir, - }, oldHead, newHead) + }, oldHead, newHead); err == nil || !strings.Contains(err.Error(), "verify uncertified range against pre-rebase head") { + t.Fatalf("RemapUncertifiedPipelineRangeAfterRebase() error = %v, want missing-object failure", err) + } got, err := database.GetUncertifiedPipelineRange(repo.ID, run.Branch) if err != nil { diff --git a/internal/scm/github/github.go b/internal/scm/github/github.go index 2900716..fde6a3e 100644 --- a/internal/scm/github/github.go +++ b/internal/scm/github/github.go @@ -8,10 +8,12 @@ import ( "fmt" "net/url" "os/exec" + "regexp" "strings" "time" "github.com/Blakeolson21/no-slop/internal/scm" + "github.com/Blakeolson21/no-slop/internal/shellenv" ) // CmdFactory builds an exec.Cmd in the caller's workdir with the caller's env. @@ -26,6 +28,8 @@ type Host struct { forkOwner string // fork owner for cross-repository PR heads } +var publicationNoncePattern = regexp.MustCompile(`^no-slop-required\|(?:opened|edited|synchronize|reopened)\|PR #[0-9]+ event [0-9]+ \(run [0-9]+\)\|`) + // New builds a Host. cliAvailable reports whether the gh binary is // resolvable on the caller's PATH (possibly overridden by env). host is the // repo's GitHub hostname; when set the availability check is scoped to it via @@ -267,14 +271,67 @@ func (h *Host) UpdatePR(ctx context.Context, pr *scm.PR, content scm.PRContent) if err != nil { return nil, err } - args := append([]string{"pr", "edit", selector}, h.repoArgs()...) - args = append(args, "--title", content.Title, "--body-file", "-") + repo, number, err := h.prAPIIdentity(pr, selector) + if err != nil { + return nil, err + } + payload, err := json.Marshal(struct { + Title string `json:"title"` + Body string `json:"body"` + }{Title: content.Title, Body: content.Body}) + if err != nil { + return nil, fmt.Errorf("marshal pull request update: %w", err) + } + args := []string{"api"} + if h.host != "" && !strings.EqualFold(h.host, "github.com") { + args = append(args, "--hostname", h.host) + } + args = append(args, "--method", "PATCH", "repos/"+repo+"/pulls/"+number, "--input", "-") cmd := h.cmd(ctx, "gh", args...) - cmd.Stdin = strings.NewReader(content.Body) - if out, err := cmd.CombinedOutput(); err != nil { - return nil, fmt.Errorf("gh pr edit: %s: %w", strings.TrimSpace(string(out)), err) + cmd.Stdin = strings.NewReader(string(payload)) + shellenv.ConfigureShellCommand(cmd) + out, err := shellenv.CombinedOutputShellCommand(cmd) + if err != nil { + return nil, fmt.Errorf("gh api update pull request: %s: %w", strings.TrimSpace(string(out)), err) } - return pr, nil + var response struct { + Number int `json:"number"` + URL string `json:"html_url"` + } + if err := json.Unmarshal(out, &response); err != nil { + return nil, fmt.Errorf("parse updated pull request: %w", err) + } + updated := &scm.PR{Number: number} + if response.Number != 0 { + updated.Number = fmt.Sprintf("%d", response.Number) + } + updated.URL = response.URL + if updated.URL == "" && pr != nil { + updated.URL = pr.URL + } + return updated, nil +} + +func (h *Host) prAPIIdentity(pr *scm.PR, selector string) (string, string, error) { + number := strings.TrimSpace(selector) + if strings.Contains(number, "://") { + var err error + number, err = scm.ExtractPRNumber(number) + if err != nil { + return "", "", err + } + } + repo := strings.TrimSpace(h.repo) + if h.host != "" { + repo = strings.TrimPrefix(repo, h.host+"/") + } + if repo == "" { + repo = RepoSlug(pr.URL) + } + if repo == "" { + return "", "", errors.New("no repository known; refusing to update pull request") + } + return repo, number, nil } func (h *Host) GetPRState(ctx context.Context, pr *scm.PR) (scm.PRState, error) { @@ -336,6 +393,102 @@ func (h *Host) GetChecks(ctx context.Context, pr *scm.PR) ([]scm.Check, error) { return checks, nil } +func (h *Host) GetCheckAttemptIdentity(ctx context.Context, check scm.Check) (scm.CheckAttemptIdentity, error) { + runID, _, ok := actionsRerunTarget(check.Link) + if !ok { + return scm.CheckAttemptIdentity{}, fmt.Errorf("check link does not identify a GitHub Actions run: %s", check.Link) + } + args := append([]string{"run", "view", runID}, h.repoArgs()...) + args = append(args, "--json", "databaseId,number,attempt,event,headSha,displayTitle") + cmd := h.cmd(ctx, "gh", args...) + shellenv.ConfigureShellCommand(cmd) + out, err := shellenv.OutputShellCommand(cmd) + if err != nil { + return scm.CheckAttemptIdentity{}, fmt.Errorf("gh run view: %w", err) + } + var raw struct { + RunID int64 `json:"databaseId"` + RunNumber int64 `json:"number"` + RunAttempt int `json:"attempt"` + Event string `json:"event"` + HeadSHA string `json:"headSha"` + DisplayTitle string `json:"displayTitle"` + } + if err := json.Unmarshal(out, &raw); err != nil { + return scm.CheckAttemptIdentity{}, fmt.Errorf("parse GitHub Actions run identity: %w", err) + } + if raw.RunID == 0 || raw.RunNumber == 0 { + return scm.CheckAttemptIdentity{}, fmt.Errorf("GitHub Actions run identity is incomplete for %s", check.Link) + } + publicationNonce, err := parsePublicationNonce([]byte(raw.DisplayTitle)) + if err != nil { + return scm.CheckAttemptIdentity{}, err + } + return scm.CheckAttemptIdentity{ + RunID: raw.RunID, + RunNumber: raw.RunNumber, + RunAttempt: raw.RunAttempt, + Event: strings.TrimSpace(raw.Event), + HeadSHA: strings.TrimSpace(raw.HeadSHA), + PublicationNonce: publicationNonce, + }, nil +} + +func (h *Host) FindAttestationPublicationIdentity(ctx context.Context, headSHA, publicationNonce string) (scm.CheckAttemptIdentity, bool, error) { + args := append([]string{"run", "list", "--workflow", "no-slop-required.yml", "--commit", headSHA, "--limit", "1000"}, h.repoArgs()...) + args = append(args, "--json", "databaseId,number,attempt,event,headSha,displayTitle") + cmd := h.cmd(ctx, "gh", args...) + shellenv.ConfigureShellCommand(cmd) + out, err := shellenv.OutputShellCommand(cmd) + if err != nil { + return scm.CheckAttemptIdentity{}, false, fmt.Errorf("gh run list attestation publications: %w", err) + } + var runs []struct { + RunID int64 `json:"databaseId"` + RunNumber int64 `json:"number"` + RunAttempt int `json:"attempt"` + Event string `json:"event"` + HeadSHA string `json:"headSha"` + DisplayTitle string `json:"displayTitle"` + } + if err := json.Unmarshal(out, &runs); err != nil { + return scm.CheckAttemptIdentity{}, false, fmt.Errorf("parse GitHub Actions attestation publications: %w", err) + } + var found scm.CheckAttemptIdentity + for _, run := range runs { + nonce, err := parsePublicationNonce([]byte(run.DisplayTitle)) + if err != nil { + return scm.CheckAttemptIdentity{}, false, err + } + if nonce != publicationNonce || strings.TrimSpace(run.HeadSHA) != strings.TrimSpace(headSHA) { + continue + } + if run.RunID <= 0 || run.RunNumber <= 0 { + return scm.CheckAttemptIdentity{}, false, fmt.Errorf("GitHub Actions publication identity is incomplete") + } + if found.RunNumber != 0 && found.RunNumber <= run.RunNumber { + continue + } + found = scm.CheckAttemptIdentity{ + RunID: run.RunID, + RunNumber: run.RunNumber, + RunAttempt: run.RunAttempt, + Event: strings.TrimSpace(run.Event), + HeadSHA: strings.TrimSpace(run.HeadSHA), + PublicationNonce: nonce, + } + } + return found, found.RunID != 0, nil +} + +func parsePublicationNonce(providerIdentity []byte) (string, error) { + match := publicationNoncePattern.FindSubmatch(providerIdentity) + if len(match) == 0 { + return "", nil + } + return string(match[1]), nil +} + // RerunCheck re-runs the Actions job behind check for the same commit, so a // check the provider cancelled rather than failed can be retried without a new // push. The job is identified from the check's details link, which is the only diff --git a/internal/scm/github/github_process_unix_test.go b/internal/scm/github/github_process_unix_test.go new file mode 100644 index 0000000..c87df0c --- /dev/null +++ b/internal/scm/github/github_process_unix_test.go @@ -0,0 +1,55 @@ +//go:build unix + +package github + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/Blakeolson21/no-slop/internal/scm" +) + +func TestUpdatePRReapsLeakedGrandchild(t *testing.T) { + dir := t.TempDir() + pidFile := filepath.Join(dir, "grandchild.pid") + heartbeat := filepath.Join(dir, "heartbeat") + factory := func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + script := "( i=0; while true; do echo $i > " + heartbeat + "; i=$((i+1)); sleep 0.05; done ) >/dev/null 2>&1 & " + + "child=$!; while [ ! -f " + heartbeat + " ]; do sleep 0.01; done; echo $child > " + pidFile + "; " + + "echo '{\"number\":42,\"html_url\":\"https://github.com/test/repo/pull/42\",\"updated_at\":\"2026-08-23T18:42:31Z\"}'; exit 0" + return exec.CommandContext(ctx, "/bin/sh", "-c", script) + } + host := New(factory, nil, "", "test/repo") + + if _, err := host.UpdatePR(context.Background(), &scm.PR{Number: "42"}, scm.PRContent{Title: "title", Body: "body"}); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(pidFile) + if err != nil { + t.Fatal(err) + } + pid, err := strconv.Atoi(strings.TrimSpace(string(raw))) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = syscall.Kill(pid, syscall.SIGKILL) }) + before, err := os.ReadFile(heartbeat) + if err != nil { + t.Fatal(err) + } + time.Sleep(250 * time.Millisecond) + after, err := os.ReadFile(heartbeat) + if err != nil { + t.Fatal(err) + } + if string(after) != string(before) { + t.Fatalf("grandchild pid %d survived UpdatePR: heartbeat advanced from %q to %q", pid, before, after) + } +} diff --git a/internal/scm/github/github_test.go b/internal/scm/github/github_test.go index e041452..1f3cea1 100644 --- a/internal/scm/github/github_test.go +++ b/internal/scm/github/github_test.go @@ -112,6 +112,97 @@ func TestGetChecksPassesRepoFlag(t *testing.T) { } } +func TestGetCheckAttemptIdentityReadsImmutableRunIdentityWithLegacyTitle(t *testing.T) { + t.Parallel() + + host := New(githubTestCmdFactory(map[string]githubTestResponse{ + "gh run view 900 --repo test/repo --json databaseId,number,attempt,event,headSha,displayTitle": { + stdout: `{"databaseId":900,"number":42,"attempt":3,"event":"pull_request","headSha":"abc123","displayTitle":"legacy workflow title"}` + "\n", + }, + }), nil, "", "test/repo") + + identity, err := host.GetCheckAttemptIdentity(context.Background(), scm.Check{Bucket: scm.CheckBucketPass, Link: "https://github.com/test/repo/actions/runs/900/job/12"}) + if err != nil { + t.Fatal(err) + } + if identity.RunID != 900 || identity.RunNumber != 42 || identity.RunAttempt != 3 || identity.Event != "pull_request" || identity.HeadSHA != "abc123" || identity.PublicationNonce != "" { + t.Fatalf("identity = %#v", identity) + } +} + +func TestGetCheckAttemptIdentityReadsPublicationFromCancelledRunMetadata(t *testing.T) { + t.Parallel() + + const nonce = "00112233445566778899aabbccddeeff" + host := New(githubTestCmdFactory(map[string]githubTestResponse{ + "gh run view 901 --repo test/repo --json databaseId,number,attempt,event,headSha,displayTitle": { + stdout: `{"databaseId":901,"number":43,"attempt":1,"event":"pull_request","headSha":"abc123","displayTitle":"no-slop-required|edited|PR #42 event 43 (run 901)| PR body"}` + "\n", + }, + }), nil, "", "test/repo") + + identity, err := host.GetCheckAttemptIdentity(context.Background(), scm.Check{Bucket: scm.CheckBucketCancel, Link: "https://github.com/test/repo/actions/runs/901/job/13"}) + if err != nil { + t.Fatal(err) + } + if identity.RunID != 901 || identity.PublicationNonce != nonce { + t.Fatalf("cancelled run identity = %#v", identity) + } +} + +func TestParsePublicationNonceReadsOnlyOwnedDisplayTitlePrefix(t *testing.T) { + t.Parallel() + + const current = "00112233445566778899aabbccddeeff" + title := "no-slop-required|edited|PR #42 event 43 (run 901)| body quoting " + got, err := parsePublicationNonce([]byte(title)) + if err != nil { + t.Fatal(err) + } + if got != current { + t.Fatalf("publication nonce = %q, want %q", got, current) + } +} + +func TestFindAttestationPublicationIdentityDoesNotRequireJobCheck(t *testing.T) { + t.Parallel() + + const nonce = "00112233445566778899aabbccddeeff" + const head = "abc123" + host := New(githubTestCmdFactory(map[string]githubTestResponse{ + "gh run list --workflow no-slop-required.yml --commit abc123 --limit 1000 --repo test/repo --json databaseId,number,attempt,event,headSha,displayTitle": { + stdout: `[{"databaseId":900,"number":42,"attempt":1,"event":"pull_request","headSha":"abc123","displayTitle":"unrelated mutation "},{"databaseId":901,"number":43,"attempt":1,"event":"pull_request","headSha":"abc123","displayTitle":"no-slop-required|edited|PR #42 event 43 (run 901)| body"}]` + "\n", + }, + }), nil, "", "test/repo") + + identity, found, err := host.FindAttestationPublicationIdentity(context.Background(), head, nonce) + if err != nil { + t.Fatal(err) + } + if !found || identity.RunID != 901 || identity.RunNumber != 43 || identity.PublicationNonce != nonce || identity.HeadSHA != head { + t.Fatalf("publication identity = (%#v, %v)", identity, found) + } +} + +func TestFindAttestationPublicationIdentityUsesEarliestMatchingRun(t *testing.T) { + t.Parallel() + + const nonce = "00112233445566778899aabbccddeeff" + const head = "abc123" + host := New(githubTestCmdFactory(map[string]githubTestResponse{ + "gh run list --workflow no-slop-required.yml --commit abc123 --limit 1000 --repo test/repo --json databaseId,number,attempt,event,headSha,displayTitle": { + stdout: `[{"databaseId":901,"number":44,"attempt":1,"event":"pull_request","headSha":"abc123","displayTitle":"no-slop-required|edited|PR #42 event 44 (run 901)| later mutation"},{"databaseId":902,"number":43,"attempt":1,"event":"pull_request","headSha":"abc123","displayTitle":"no-slop-required|edited|PR #42 event 43 (run 902)| publication"}]` + "\n", + }, + }), nil, "", "test/repo") + + identity, found, err := host.FindAttestationPublicationIdentity(context.Background(), head, nonce) + if err != nil { + t.Fatal(err) + } + if !found || identity.RunID != 902 || identity.RunNumber != 43 { + t.Fatalf("publication identity = (%#v, %v), want earliest run", identity, found) + } +} + func TestGetPRStatePassesRepoFlag(t *testing.T) { t.Parallel() @@ -158,8 +249,9 @@ func TestUpdatePRStreamsBodyThroughStdin(t *testing.T) { const body = "## What Changed\n\n- update existing pull request bodies without long argv" host := New(githubTestCmdFactory(map[string]githubTestResponse{ - "gh pr edit 42 --repo test/repo --title fix: cap body --body-file -": { - wantStdin: body, + "gh api --method PATCH repos/test/repo/pulls/42 --input -": { + stdout: `{"number":42,"html_url":"https://github.com/test/repo/pull/42","updated_at":"2026-08-23T18:42:31Z"}`, + wantStdin: `{"title":"fix: cap body","body":"## What Changed\n\n- update existing pull request bodies without long argv"}`, }, }), nil, "", "test/repo") @@ -171,20 +263,16 @@ func TestUpdatePRStreamsBodyThroughStdin(t *testing.T) { if err != nil { t.Fatalf("UpdatePR() error = %v", err) } - if updated != pr { - t.Fatalf("UpdatePR() = %+v, want original PR", updated) + if updated == nil || updated.Number != "42" || updated.URL != pr.URL { + t.Fatalf("UpdatePR() = %+v", updated) } } -// UpdatePR shares the same explicit-PR selector boundary as the read methods: -// when the number is absent it must target the canonical PR URL, never an empty -// positional that makes `gh pr edit` resolve the cwd branch (main) from the -// detached bare gate repo and edit the wrong PR. func TestUpdatePRTargetsKnownPRByURLWhenNumberMissing(t *testing.T) { t.Parallel() var recorded [][]string - host := New(recordingCmdFactory("", &recorded), nil, "", "test/repo") + host := New(recordingCmdFactory(`{"number":123,"html_url":"https://github.com/test/repo/pull/123","updated_at":"2026-08-23T18:42:31Z"}`, &recorded), nil, "", "test/repo") prURL := "https://github.com/test/repo/pull/123" if _, err := host.UpdatePR(context.Background(), &scm.PR{URL: prURL}, scm.PRContent{ @@ -197,18 +285,27 @@ func TestUpdatePRTargetsKnownPRByURLWhenNumberMissing(t *testing.T) { t.Fatalf("expected exactly one gh invocation, got %d: %v", len(recorded), recorded) } got := recorded[0] - // argv is: gh pr edit --repo ... - if len(got) < 4 || got[1] != "pr" || got[2] != "edit" { + if len(got) < 6 || got[1] != "api" || got[2] != "--method" || got[3] != "PATCH" { t.Fatalf("unexpected argv: %v", got) } - if selector := got[3]; selector != prURL { - t.Fatalf("edit selector = %q, want the known PR URL %q (empty selector makes gh resolve the cwd branch)", selector, prURL) + if endpoint := got[4]; endpoint != "repos/test/repo/pulls/123" { + t.Fatalf("update endpoint = %q", endpoint) + } +} + +func TestUpdatePRScopesAtomicPublicationToEnterpriseHost(t *testing.T) { + t.Parallel() + host := New(githubTestCmdFactory(map[string]githubTestResponse{ + "gh api --hostname ghe.example.com --method PATCH repos/org/repo/pulls/42 --input -": { + stdout: `{"number":42,"html_url":"https://ghe.example.com/org/repo/pull/42","updated_at":"2026-08-23T18:42:31Z"}`, + wantStdin: `{"title":"fix: publish","body":"body"}`, + }, + }), nil, "ghe.example.com", "ghe.example.com/org/repo") + if _, err := host.UpdatePR(context.Background(), &scm.PR{Number: "42"}, scm.PRContent{Title: "fix: publish", Body: "body"}); err != nil { + t.Fatal(err) } } -// UpdatePR must fail closed exactly like the read methods: with neither number -// nor URL it refuses to shell out rather than running an argument-less -// `gh pr edit` that would edit the inferred cwd branch's PR. func TestUpdatePRFailsClosedWithoutIdentity(t *testing.T) { t.Parallel() diff --git a/internal/scm/host.go b/internal/scm/host.go index 095cba3..737cb54 100644 --- a/internal/scm/host.go +++ b/internal/scm/host.go @@ -152,6 +152,23 @@ type Check struct { Link string } +type CheckAttemptIdentity struct { + RunID int64 + RunNumber int64 + RunAttempt int + Event string + HeadSHA string + PublicationNonce string +} + +type CheckAttemptIdentityReader interface { + GetCheckAttemptIdentity(ctx context.Context, check Check) (CheckAttemptIdentity, error) +} + +type AttestationPublicationIdentityReader interface { + FindAttestationPublicationIdentity(ctx context.Context, headSHA, publicationNonce string) (CheckAttemptIdentity, bool, error) +} + // Failing reports whether the check is in a failed bucket. func (c Check) Failing() bool { return c.Bucket == CheckBucketFail } diff --git a/internal/types/findings.go b/internal/types/findings.go index 83541ba..71ee1ab 100644 --- a/internal/types/findings.go +++ b/internal/types/findings.go @@ -1,6 +1,7 @@ package types import ( + "crypto/rand" "encoding/json" "fmt" "strings" @@ -40,20 +41,132 @@ const ( // Finding represents a single review, test, lint, or PR comment finding. type Finding struct { - ID string `json:"id,omitempty"` - Severity string `json:"severity"` - File string `json:"file,omitempty"` - Line int `json:"line,omitempty"` - Description string `json:"description"` - Action string `json:"action"` - Source string `json:"source,omitempty"` - UserInstructions string `json:"user_instructions,omitempty"` - ReviewScope string `json:"review_scope,omitempty"` + ID string `json:"id,omitempty"` + IDGenerated bool `json:"id_generated,omitempty"` + ContinuityToken string `json:"continuity_token,omitempty"` + OccurrenceToken string `json:"occurrence_token,omitempty"` + PriorID string `json:"prior_id,omitempty"` + PriorContinuityToken string `json:"prior_continuity_token,omitempty"` + Severity string `json:"severity"` + File string `json:"file,omitempty"` + Line int `json:"line,omitempty"` + Description string `json:"description"` + Action string `json:"action"` + Source string `json:"source,omitempty"` + UserInstructions string `json:"user_instructions,omitempty"` + ReviewScope string `json:"review_scope,omitempty"` + Evidence *FindingEvidence `json:"evidence,omitempty"` // Category separates the combined document+lint housekeeping pass's // findings into their owning gates. Empty everywhere else. Category string `json:"category,omitempty"` } +type FindingIdentity struct { + File string + Line int + Description string +} + +func (f Finding) Identity() FindingIdentity { + return FindingIdentity{File: f.File, Line: f.Line, Description: f.Description} +} + +func (f Finding) Fingerprint() FindingIdentity { + identity := f.Identity() + identity.Line = 0 + return identity +} + +func CountFindingFingerprints(items []Finding) map[FindingIdentity]int { + counts := make(map[FindingIdentity]int, len(items)) + for _, item := range items { + counts[item.Fingerprint()]++ + } + return counts +} + +func StableFindingIDs(items []Finding) map[string][]Finding { + ids := make(map[string][]Finding, len(items)) + for _, item := range items { + if item.HasLineage() { + ids[item.ID] = append(ids[item.ID], item) + } + } + return ids +} + +func FindingMatches(item Finding, stableIDs map[string][]Finding, itemOccurrenceCounts, candidateOccurrenceCounts map[string]int, itemIdentityCounts, candidateIdentityCounts, itemFingerprintCounts, candidateFingerprintCounts map[FindingIdentity]int) bool { + if item.HasLineage() { + for _, candidate := range stableIDs[item.ID] { + if FindingIDCorroborates(item, candidate) { + return true + } + } + return false + } + if item.HasOccurrence() && itemOccurrenceCounts[item.OccurrenceToken] == 1 && candidateOccurrenceCounts[item.OccurrenceToken] == 1 { + return true + } + identity := item.Identity() + if itemIdentityCounts[identity] == 1 && candidateIdentityCounts[identity] == 1 { + return true + } + fingerprint := item.Fingerprint() + return itemFingerprintCounts[fingerprint] == 1 && candidateFingerprintCounts[fingerprint] == 1 +} + +func FindingIDCorroborates(item, candidate Finding) bool { + return item.HasLineage() && candidate.HasLineage() && item.ID == candidate.ID && item.ContinuityToken == candidate.ContinuityToken +} + +func (f Finding) HasLineage() bool { + return f.IDGenerated && f.ID != "" && f.ContinuityToken != "" +} + +func (f Finding) HasOccurrence() bool { + return !f.HasLineage() && f.OccurrenceToken != "" +} + +func FindingOccurrenceCorroborates(item, candidate Finding) bool { + return item.HasOccurrence() && candidate.HasOccurrence() && item.OccurrenceToken == candidate.OccurrenceToken +} + +func CountFindingOccurrences(items []Finding) map[string]int { + counts := make(map[string]int, len(items)) + for _, item := range items { + if item.HasOccurrence() { + counts[item.OccurrenceToken]++ + } + } + return counts +} + +func EnsureFindingOccurrenceTokens(findings Findings) (Findings, error) { + used := make(map[string]bool, len(findings.Items)) + counts := make(map[string]int, len(findings.Items)) + for _, item := range findings.Items { + if item.OccurrenceToken != "" { + used[item.OccurrenceToken] = true + counts[item.OccurrenceToken]++ + if counts[item.OccurrenceToken] > 1 { + return Findings{}, fmt.Errorf("duplicate finding occurrence token") + } + } + } + for i := range findings.Items { + item := &findings.Items[i] + if item.HasLineage() || item.OccurrenceToken != "" { + continue + } + token, err := newFindingContinuityToken(used) + if err != nil { + return Findings{}, err + } + item.OccurrenceToken = token + } + return findings, nil +} + // TestArtifact describes evidence produced by the test step for human review. type TestArtifact struct { Kind string `json:"kind,omitempty"` @@ -63,42 +176,56 @@ type TestArtifact struct { Content string `json:"content,omitempty"` } +type FindingEvidence struct { + Tested []string `json:"tested,omitempty"` + TestingSummary string `json:"testing_summary,omitempty"` + Artifacts []TestArtifact `json:"artifacts,omitempty"` +} + type findingWire struct { - ID string `json:"id,omitempty"` - Severity string `json:"severity"` - File string `json:"file,omitempty"` - Line int `json:"line,omitempty"` - Description string `json:"description"` - Action string `json:"action"` - Source string `json:"source,omitempty"` - UserInstructions string `json:"user_instructions,omitempty"` - ReviewScope string `json:"review_scope,omitempty"` - Category string `json:"category,omitempty"` - RequiresHumanReview *bool `json:"requires_human_review,omitempty"` + ID string `json:"id,omitempty"` + IDGenerated bool `json:"id_generated,omitempty"` + ContinuityToken string `json:"continuity_token,omitempty"` + OccurrenceToken string `json:"occurrence_token,omitempty"` + PriorID string `json:"prior_id,omitempty"` + PriorContinuityToken string `json:"prior_continuity_token,omitempty"` + Severity string `json:"severity"` + File string `json:"file,omitempty"` + Line int `json:"line,omitempty"` + Description string `json:"description"` + Action string `json:"action"` + Source string `json:"source,omitempty"` + UserInstructions string `json:"user_instructions,omitempty"` + ReviewScope string `json:"review_scope,omitempty"` + Evidence *FindingEvidence `json:"evidence,omitempty"` + Category string `json:"category,omitempty"` + RequiresHumanReview *bool `json:"requires_human_review,omitempty"` } // Findings is the structured findings payload exchanged across pipeline, IPC, and TUI. type Findings struct { - Items []Finding `json:"findings"` - Summary string `json:"summary"` - Tested []string `json:"tested,omitempty"` - TestingSummary string `json:"testing_summary,omitempty"` - Artifacts []TestArtifact `json:"artifacts,omitempty"` - RiskLevel string `json:"risk_level"` - RiskRationale string `json:"risk_rationale"` - RiskScope string `json:"risk_scope,omitempty"` + Items []Finding `json:"findings"` + Summary string `json:"summary"` + Tested []string `json:"tested,omitempty"` + TestingSummary string `json:"testing_summary,omitempty"` + Artifacts []TestArtifact `json:"artifacts,omitempty"` + SharedEvidence *FindingEvidence `json:"shared_evidence,omitempty"` + RiskLevel string `json:"risk_level"` + RiskRationale string `json:"risk_rationale"` + RiskScope string `json:"risk_scope,omitempty"` } type findingsWire struct { - Items []Finding `json:"findings"` - Legacy []Finding `json:"items"` - Summary string `json:"summary"` - Tested []string `json:"tested"` - TestingSummary string `json:"testing_summary"` - Artifacts []TestArtifact `json:"artifacts"` - RiskLevel string `json:"risk_level"` - RiskRationale string `json:"risk_rationale"` - RiskScope string `json:"risk_scope"` + Items []Finding `json:"findings"` + Legacy []Finding `json:"items"` + Summary string `json:"summary"` + Tested []string `json:"tested"` + TestingSummary string `json:"testing_summary"` + Artifacts []TestArtifact `json:"artifacts"` + SharedEvidence *FindingEvidence `json:"shared_evidence"` + RiskLevel string `json:"risk_level"` + RiskRationale string `json:"risk_rationale"` + RiskScope string `json:"risk_scope"` } // ParseFindingsJSON decodes findings JSON, accepting current and legacy item @@ -112,18 +239,184 @@ func ParseFindingsJSON(raw string) (Findings, error) { if len(items) == 0 && len(wire.Legacy) > 0 { items = wire.Legacy } - return Findings{Items: items, Summary: wire.Summary, Tested: wire.Tested, TestingSummary: wire.TestingSummary, Artifacts: wire.Artifacts, RiskLevel: wire.RiskLevel, RiskRationale: wire.RiskRationale, RiskScope: wire.RiskScope}, nil + return Findings{Items: items, Summary: wire.Summary, Tested: wire.Tested, TestingSummary: wire.TestingSummary, Artifacts: wire.Artifacts, SharedEvidence: wire.SharedEvidence, RiskLevel: wire.RiskLevel, RiskRationale: wire.RiskRationale, RiskScope: wire.RiskScope}, nil +} + +// NormalizeFindings replaces reviewer-local IDs with pipeline-owned lineage IDs. +func NormalizeFindings(findings Findings, prefix string, existing []Finding) (Findings, error) { + if prefix != "review" { + return normalizeNonReviewFindings(findings, prefix, existing) + } + type lineageClaim struct { + id string + token string + } + allowed := make(map[lineageClaim][]Finding, len(existing)) + used := make(map[string]bool, len(existing)+len(findings.Items)) + usedTokens := make(map[string]bool, len(existing)+len(findings.Items)) + for _, item := range existing { + if item.ID != "" { + used[item.ID] = true + } + if item.ContinuityToken != "" { + usedTokens[item.ContinuityToken] = true + } + if item.HasLineage() { + claim := lineageClaim{id: item.ID, token: item.ContinuityToken} + allowed[claim] = append(allowed[claim], item) + } + } + corroborated := make(map[lineageClaim][]int, len(findings.Items)) + for i := range findings.Items { + item := findings.Items[i] + claim := lineageClaim{id: item.PriorID, token: item.PriorContinuityToken} + matches := allowed[claim] + if claim.id != "" && claim.token != "" && len(matches) == 1 && findingSemanticallyCorroborates(item, matches[0]) { + corroborated[claim] = append(corroborated[claim], i) + } + } + for i := range findings.Items { + item := &findings.Items[i] + claim := lineageClaim{id: item.PriorID, token: item.PriorContinuityToken} + matches := allowed[claim] + if claim.id != "" && claim.token != "" && len(matches) == 1 && len(corroborated[claim]) == 1 && corroborated[claim][0] == i { + item.ID = matches[0].ID + item.IDGenerated = true + item.ContinuityToken = matches[0].ContinuityToken + item.OccurrenceToken = "" + item.PriorID = "" + item.PriorContinuityToken = "" + continue + } + id, err := newFindingLineageID(prefix, used) + if err != nil { + return Findings{}, err + } + token, err := newFindingContinuityToken(usedTokens) + if err != nil { + return Findings{}, err + } + item.ID = id + item.IDGenerated = true + item.ContinuityToken = token + item.OccurrenceToken = "" + } + return findings, nil +} + +func NormalizeUserFindings(findings Findings, existing []Finding) (Findings, error) { + existingByID := make(map[string][]Finding, len(existing)) + usedIDs := make(map[string]bool, len(existing)+len(findings.Items)) + usedTokens := make(map[string]bool, len(existing)+len(findings.Items)) + for _, item := range existing { + if item.ID != "" { + existingByID[item.ID] = append(existingByID[item.ID], item) + usedIDs[item.ID] = true + } + if item.ContinuityToken != "" { + usedTokens[item.ContinuityToken] = true + } + } + for _, item := range findings.Items { + if item.Source == FindingSourceUser && !item.HasLineage() { + continue + } + if item.ID != "" { + usedIDs[item.ID] = true + } + if item.ContinuityToken != "" { + usedTokens[item.ContinuityToken] = true + } + } + for i := range findings.Items { + if findings.Items[i].HasLineage() { + findings.Items[i].OccurrenceToken = "" + } + } + counter := 0 + for i := range findings.Items { + item := &findings.Items[i] + if item.Source != FindingSourceUser || item.HasLineage() { + continue + } + continuations := make([]Finding, 0, 1) + for _, candidate := range existingByID[item.ID] { + if candidate.Source == FindingSourceUser && candidate.Identity() == item.Identity() { + continuations = append(continuations, candidate) + } + } + if len(continuations) == 1 { + item.ID = continuations[0].ID + item.IDGenerated = true + item.ContinuityToken = continuations[0].ContinuityToken + item.OccurrenceToken = "" + } + if item.ID == "" || (usedIDs[item.ID] && len(continuations) != 1) { + item.ID, counter = nextUserFindingID(usedIDs, counter) + } else { + usedIDs[item.ID] = true + } + if item.ContinuityToken == "" { + token, err := newFindingContinuityToken(usedTokens) + if err != nil { + return Findings{}, err + } + item.ContinuityToken = token + } + item.IDGenerated = true + item.OccurrenceToken = "" + item.PriorID = "" + item.PriorContinuityToken = "" + } + return findings, nil +} + +func findingSemanticallyCorroborates(item, candidate Finding) bool { + return strings.TrimSpace(item.Description) != "" && item.Fingerprint() == candidate.Fingerprint() } -// NormalizeFindings assigns deterministic IDs to findings that do not have one yet. -func NormalizeFindings(findings Findings, prefix string) Findings { +func normalizeNonReviewFindings(findings Findings, prefix string, _ []Finding) (Findings, error) { for i := range findings.Items { - if findings.Items[i].ID != "" { + if findings.Items[i].ID == "" { + findings.Items[i].ID = prefix + "-" + itoa(i+1) + } + findings.Items[i].IDGenerated = false + findings.Items[i].ContinuityToken = "" + findings.Items[i].OccurrenceToken = "" + findings.Items[i].PriorID = "" + findings.Items[i].PriorContinuityToken = "" + } + return findings, nil +} + +func newFindingContinuityToken(used map[string]bool) (string, error) { + for { + var random [16]byte + if _, err := rand.Read(random[:]); err != nil { + return "", fmt.Errorf("generate finding continuity token: %w", err) + } + token := fmt.Sprintf("%x", random) + if used[token] { + continue + } + used[token] = true + return token, nil + } +} + +func newFindingLineageID(prefix string, used map[string]bool) (string, error) { + for { + var random [16]byte + if _, err := rand.Read(random[:]); err != nil { + return "", fmt.Errorf("generate finding lineage: %w", err) + } + id := fmt.Sprintf("%s-%x", prefix, random) + if used[id] { continue } - findings.Items[i].ID = prefix + "-" + itoa(i+1) + used[id] = true + return id, nil } - return findings } // FilterFindings keeps only findings whose IDs are included in ids. @@ -135,7 +428,7 @@ func FilterFindings(findings Findings, ids []string) Findings { for _, id := range ids { selected[id] = true } - filtered := Findings{Summary: findings.Summary, Tested: findings.Tested, TestingSummary: findings.TestingSummary, Artifacts: findings.Artifacts, RiskLevel: findings.RiskLevel, RiskRationale: findings.RiskRationale, RiskScope: findings.RiskScope} + filtered := Findings{Summary: findings.Summary, Tested: findings.Tested, TestingSummary: findings.TestingSummary, Artifacts: findings.Artifacts, SharedEvidence: findings.SharedEvidence, RiskLevel: findings.RiskLevel, RiskRationale: findings.RiskRationale, RiskScope: findings.RiskScope} for _, item := range findings.Items { if selected[item.ID] { filtered.Items = append(filtered.Items, item) @@ -156,7 +449,7 @@ func ExcludeFindings(findings Findings, ids []string) Findings { for _, id := range ids { excluded[id] = true } - result := Findings{Summary: findings.Summary, Tested: findings.Tested, TestingSummary: findings.TestingSummary, Artifacts: findings.Artifacts, RiskLevel: findings.RiskLevel, RiskRationale: findings.RiskRationale, RiskScope: findings.RiskScope} + result := Findings{Summary: findings.Summary, Tested: findings.Tested, TestingSummary: findings.TestingSummary, Artifacts: findings.Artifacts, SharedEvidence: findings.SharedEvidence, RiskLevel: findings.RiskLevel, RiskRationale: findings.RiskRationale, RiskScope: findings.RiskScope} for _, item := range findings.Items { if !excluded[item.ID] { result.Items = append(result.Items, item) @@ -169,7 +462,7 @@ func ExcludeFindings(findings Findings, ids []string) Findings { // Action is "auto-fix". These are safe for automatic fixing without // user involvement. func AutoFixableFindings(findings Findings) Findings { - result := Findings{Summary: findings.Summary, Tested: findings.Tested, TestingSummary: findings.TestingSummary, Artifacts: findings.Artifacts, RiskLevel: findings.RiskLevel, RiskRationale: findings.RiskRationale, RiskScope: findings.RiskScope} + result := Findings{Summary: findings.Summary, Tested: findings.Tested, TestingSummary: findings.TestingSummary, Artifacts: findings.Artifacts, SharedEvidence: findings.SharedEvidence, RiskLevel: findings.RiskLevel, RiskRationale: findings.RiskRationale, RiskScope: findings.RiskScope} for _, item := range findings.Items { if item.ActionOrDefault() == ActionAutoFix { result.Items = append(result.Items, item) @@ -188,6 +481,7 @@ func MergeUserOverrides(findings Findings, instructions map[string]string, added Tested: findings.Tested, TestingSummary: findings.TestingSummary, Artifacts: findings.Artifacts, + SharedEvidence: findings.SharedEvidence, RiskLevel: findings.RiskLevel, RiskRationale: findings.RiskRationale, RiskScope: findings.RiskScope, @@ -335,6 +629,11 @@ func (f *Finding) UnmarshalJSON(data []byte) error { return err } f.ID = wire.ID + f.IDGenerated = wire.IDGenerated + f.ContinuityToken = wire.ContinuityToken + f.OccurrenceToken = wire.OccurrenceToken + f.PriorID = wire.PriorID + f.PriorContinuityToken = wire.PriorContinuityToken f.Severity = wire.Severity f.File = wire.File f.Line = wire.Line @@ -343,6 +642,7 @@ func (f *Finding) UnmarshalJSON(data []byte) error { f.Source = wire.Source f.UserInstructions = wire.UserInstructions f.ReviewScope = wire.ReviewScope + f.Evidence = wire.Evidence f.Category = wire.Category if f.Action == "" && wire.RequiresHumanReview != nil { if *wire.RequiresHumanReview { diff --git a/internal/types/findings_test.go b/internal/types/findings_test.go index 444bacd..b087a2e 100644 --- a/internal/types/findings_test.go +++ b/internal/types/findings_test.go @@ -516,3 +516,151 @@ func TestFinding_Action_Values(t *testing.T) { } } } + +func TestNormalizeFindingsPersistsGeneratedIDProvenance(t *testing.T) { + findings, err := NormalizeFindings(Findings{Items: []Finding{ + {Severity: "error", Description: "generated"}, + {ID: "stable-defect", Severity: "warning", Description: "explicit"}, + }}, "review", nil) + if err != nil { + t.Fatal(err) + } + if !findings.Items[0].IDGenerated || !strings.HasPrefix(findings.Items[0].ID, "review-") { + t.Fatalf("first lineage = %#v", findings.Items[0]) + } + if !findings.Items[1].IDGenerated || !strings.HasPrefix(findings.Items[1].ID, "review-") || findings.Items[1].ID == "stable-defect" || findings.Items[1].ID == findings.Items[0].ID { + t.Fatalf("second lineage = %#v", findings.Items[1]) + } + if findings.Items[0].ContinuityToken == "" || findings.Items[1].ContinuityToken == "" || findings.Items[0].ContinuityToken == findings.Items[1].ContinuityToken { + t.Fatalf("continuity tokens = %#v", findings.Items) + } + raw, err := MarshalFindingsJSON(findings) + if err != nil { + t.Fatal(err) + } + parsed, err := ParseFindingsJSON(raw) + if err != nil { + t.Fatal(err) + } + if !parsed.Items[0].HasLineage() || !parsed.Items[1].HasLineage() { + t.Fatalf("round-trip provenance = %#v", parsed.Items) + } +} + +func TestNormalizeFindingsKeepsNonReviewIdentitySemantics(t *testing.T) { + findings, err := NormalizeFindings(Findings{Items: []Finding{ + {Severity: "error", Description: "generated"}, + {ID: "stable-test", Severity: "warning", Description: "explicit"}, + }}, "test", nil) + if err != nil { + t.Fatal(err) + } + if findings.Items[0].ID != "test-1" || findings.Items[1].ID != "stable-test" { + t.Fatalf("non-review IDs = %#v", findings.Items) + } + for _, item := range findings.Items { + if item.HasLineage() || item.ContinuityToken != "" || item.IDGenerated { + t.Fatalf("non-review finding acquired review lineage: %#v", item) + } + } +} + +func TestNormalizeFindingsRequiresCorroboratedPriorLineageClaim(t *testing.T) { + prior, err := NormalizeFindings(Findings{Items: []Finding{{ID: "review-1", File: "manager.go", Line: 80, Description: "credentials are invalidated prematurely"}}}, "review", nil) + if err != nil { + t.Fatal(err) + } + lineage := prior.Items[0].ID + token := prior.Items[0].ContinuityToken + fresh, err := NormalizeFindings(Findings{Items: []Finding{ + {PriorID: lineage, PriorContinuityToken: token, File: "manager.go", Line: 88, Description: "credentials are invalidated prematurely"}, + {ID: lineage, PriorID: lineage, PriorContinuityToken: "wrong", File: "auth.go", Line: 12, Description: "authentication token leaks in logs"}, + }}, "review", prior.Items) + if err != nil { + t.Fatal(err) + } + if fresh.Items[0].ID != lineage || !FindingIDCorroborates(fresh.Items[0], prior.Items[0]) { + t.Fatalf("continued lineage = %#v, want %q", fresh.Items[0], lineage) + } + if fresh.Items[1].ID == lineage || fresh.Items[1].ID == fresh.Items[0].ID { + t.Fatalf("uncorroborated lineage claim was accepted: %#v", fresh.Items) + } +} + +func TestNormalizeFindingsPreservesUnrelatedClaimAsNewLineage(t *testing.T) { + prior, err := NormalizeFindings(Findings{Items: []Finding{{File: "loader.go", Description: "unsafe loader"}}}, "review", nil) + if err != nil { + t.Fatal(err) + } + fresh, err := NormalizeFindings(Findings{Items: []Finding{{ + PriorID: prior.Items[0].ID, + PriorContinuityToken: prior.Items[0].ContinuityToken, + File: "auth.go", + Description: "authentication token leaks in logs", + }}}, "review", prior.Items) + if err != nil { + t.Fatal(err) + } + if fresh.Items[0].ID == prior.Items[0].ID || FindingIDCorroborates(fresh.Items[0], prior.Items[0]) { + t.Fatalf("unrelated finding inherited prior lineage: %#v", fresh.Items[0]) + } +} + +func TestNormalizeFindingsPreservesRewordingAtSameLocation(t *testing.T) { + prior, err := NormalizeFindings(Findings{Items: []Finding{{File: "loader.go", Line: 42, Description: "unsafe loader"}}}, "review", nil) + if err != nil { + t.Fatal(err) + } + fresh, err := NormalizeFindings(Findings{Items: []Finding{{ + PriorID: prior.Items[0].ID, + PriorContinuityToken: prior.Items[0].ContinuityToken, + File: "loader.go", + Line: 42, + Description: "loader permits traversal outside its root", + }}}, "review", prior.Items) + if err != nil { + t.Fatal(err) + } + if FindingIDCorroborates(fresh.Items[0], prior.Items[0]) { + t.Fatalf("description change inherited prior lineage: %#v", fresh.Items[0]) + } + if fresh.Items[0].PriorID != prior.Items[0].ID || fresh.Items[0].PriorContinuityToken != prior.Items[0].ContinuityToken { + t.Fatalf("rejected claim provenance was lost: %#v", fresh.Items[0]) + } +} + +func TestNormalizeFindingsPreservesAmbiguousDuplicateClaims(t *testing.T) { + prior, err := NormalizeFindings(Findings{Items: []Finding{{File: "loader.go", Description: "unsafe loader"}}}, "review", nil) + if err != nil { + t.Fatal(err) + } + claim := Finding{PriorID: prior.Items[0].ID, PriorContinuityToken: prior.Items[0].ContinuityToken, File: "loader.go", Description: "unsafe loader"} + fresh, err := NormalizeFindings(Findings{Items: []Finding{claim, claim}}, "review", prior.Items) + if err != nil { + t.Fatal(err) + } + if fresh.Items[0].ID == prior.Items[0].ID || fresh.Items[1].ID == prior.Items[0].ID || fresh.Items[0].ID == fresh.Items[1].ID { + t.Fatalf("ambiguous claims reused lineage: %#v", fresh.Items) + } +} + +func TestNormalizeUserFindingsAssignsDurableLineage(t *testing.T) { + findings, err := NormalizeUserFindings(Findings{Items: []Finding{{ + ID: "user-1", + Severity: "error", + Description: "operator-added defect", + Action: ActionAskUser, + Source: FindingSourceUser, + UserInstructions: "preserve the compatibility path", + }}}, nil) + if err != nil { + t.Fatal(err) + } + item := findings.Items[0] + if item.ID != "user-1" || !item.HasLineage() || len(item.ContinuityToken) != 32 { + t.Fatalf("user finding lineage = %#v", item) + } + if item.Source != FindingSourceUser || item.UserInstructions != "preserve the compatibility path" || item.Action != ActionAskUser { + t.Fatalf("user finding semantics changed: %#v", item) + } +} diff --git a/workflow_no_slop_required_test.go b/workflow_no_slop_required_test.go index 845f8a9..5d83130 100644 --- a/workflow_no_slop_required_test.go +++ b/workflow_no_slop_required_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "context" + "encoding/json" "os" "os/exec" "slices" @@ -19,6 +20,7 @@ import ( ) const requiredWorkflowStepTimeout = 10 * time.Second +const requiredWorkflowTestHeadSHA = "0123456789abcdef0123456789abcdef01234567" // TestNoSlopRequiredWorkflowExemptsReleaseAutomation pins the exemption // logic so the release pipeline (release-please via GITHUB_TOKEN) and @@ -50,9 +52,9 @@ func TestNoSlopRequiredWorkflowChecksSignatureMarker(t *testing.T) { t.Fatal("generated pipeline body fixture did not contain canonical signature") } got := executeRequiredWorkflowFixture(t, workflow, []requiredWorkflowEvent{ - {Action: "opened", Body: pipelineBody, HeadSHA: "head", PRNumber: 1, RunID: 1, RunNumber: 1}, - {Action: "edited", Body: legacyPipelineBody, HeadSHA: "head", PRNumber: 1, RunID: 2, RunNumber: 2}, - {Action: "edited", Body: "body without a generated pipeline section", HeadSHA: "head", PRNumber: 1, RunID: 3, RunNumber: 3}, + {Action: "opened", Body: pipelineBody, HeadSHA: requiredWorkflowTestHeadSHA, PRNumber: 1, RunID: 1, RunNumber: 1}, + {Action: "edited", Body: legacyPipelineBody, HeadSHA: requiredWorkflowTestHeadSHA, PRNumber: 1, RunID: 2, RunNumber: 2}, + {Action: "edited", Body: "body without a generated pipeline section", HeadSHA: requiredWorkflowTestHeadSHA, PRNumber: 1, RunID: 3, RunNumber: 3}, }) want := []requiredWorkflowResult{ {RunID: 1, RunNumber: 1, Action: "opened", Executed: true, Conclusion: "success"}, @@ -64,6 +66,132 @@ func TestNoSlopRequiredWorkflowChecksSignatureMarker(t *testing.T) { } } +// TestNoSlopRequiredWorkflowAcceptsHistoricalLegacyMarker executes the +// required check against a fully attested body written before the project and +// repository were renamed. Existing PRs can legitimately retain that marker +// while a current no-slop run refreshes their attestation. +func TestNoSlopRequiredWorkflowAcceptsHistoricalLegacyMarker(t *testing.T) { + workflow := loadRequiredWorkflow(t) + body := strings.Replace( + generatedPipelineBody(t), + "Updates from [git push no-slop](https://github.com/Blakeolson21/no-slop)", + "Updates from [git push no-mistakes](https://github.com/kunchenguid/no-mistakes)", + 1, + ) + got := executeRequiredWorkflowFixture(t, workflow, []requiredWorkflowEvent{{ + Action: "edited", Body: body, HeadSHA: requiredWorkflowTestHeadSHA, PRNumber: 4, RunID: 4, RunNumber: 4, + }}) + if got[0].Conclusion != "success" { + t.Fatalf("historical legacy body concluded %q, want success", got[0].Conclusion) + } +} + +// TestNoSlopRequiredWorkflowEnforcesCompletedPipelineAttestation executes the +// repository's required-check script as GitHub would. A signature proves only +// which tool wrote the body; merge authority additionally requires a v1 +// attestation bound to this head with every required pre-publication gate done. +func TestNoSlopRequiredWorkflowEnforcesCompletedPipelineAttestation(t *testing.T) { + workflow := loadRequiredWorkflow(t) + signatureOnly := "## Pipeline\n\nUpdates from [git push no-slop](https://github.com/Blakeolson21/no-slop)\n" + historicalSignatureOnly := "## Pipeline\n\nUpdates from [git push no-mistakes](https://github.com/kunchenguid/no-mistakes)\n" + + tests := []struct { + name string + body string + headSHA string + want string + }{ + {name: "signature only", body: signatureOnly, want: "failure"}, + {name: "historical signature only", body: historicalSignatureOnly, want: "failure"}, + {name: "review missing", body: generatedPipelineBodyWithStatuses(t, "", types.StepStatusCompleted, types.StepStatusCompleted), want: "failure"}, + {name: "test failed", body: generatedPipelineBodyWithStatuses(t, types.StepStatusCompleted, types.StepStatusFailed, types.StepStatusCompleted), want: "failure"}, + {name: "document skipped", body: generatedPipelineBodyWithStatuses(t, types.StepStatusCompleted, types.StepStatusCompleted, types.StepStatusSkipped), want: "failure"}, + {name: "stale head", body: generatedPipelineBody(t), headSHA: "ffffffffffffffffffffffffffffffffffffffff", want: "failure"}, + {name: "review certified stale head", body: generatedPipelineBodyWithStaleReviewCertification(t), want: "failure"}, + {name: "quoted malformed attestation before owned pipeline", body: insertAfterPublicationMarker(t, generatedPipelineBody(t), "## Intent\n\nQuoted legacy data: "), want: "success"}, + {name: "quoted semantically invalid owned tuple before owned pipeline", body: generatedPipelineBodyWithQuotedInvalidAttestation(t), want: "success"}, + {name: "pipeline heading in generated detail", body: generatedPipelineBody(t) + "\n\nFinding detail\n## Pipeline\n\nnot an owned attestation", want: "success"}, + {name: "all required steps completed", body: generatedPipelineBody(t), want: "success"}, + } + + for i, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + headSHA := tc.headSHA + if headSHA == "" { + headSHA = requiredWorkflowTestHeadSHA + } + got := executeRequiredWorkflowFixture(t, workflow, []requiredWorkflowEvent{{ + Action: "opened", Body: tc.body, HeadSHA: headSHA, PRNumber: 797, RunID: int64(100 + i), RunNumber: int64(100 + i), + }}) + if got[0].Conclusion != tc.want { + t.Fatalf("conclusion = %q, want %q", got[0].Conclusion, tc.want) + } + }) + } +} + +// TestNoSlopRequiredWorkflowAcceptsInitialLegacyV1Publication exercises the +// rollout boundary where the installed no-slop binary opens the PR that first +// introduces publication nonces. That binary can emit only the original v1 +// attestation: one SHA-bound payload with completed step statuses. The +// allowance is deliberately limited to the canonical marker on publication +// events produced by an installed pre-nonce binary. Synchronize may carry the +// pre-repair head because that older daemon cannot republish after its CI fix; +// opened events stay head-bound and body edits require the nonce-bearing format. +func TestNoSlopRequiredWorkflowAcceptsInitialLegacyV1Publication(t *testing.T) { + workflow := loadRequiredWorkflow(t) + legacyBody := legacyV1PipelineBody(t, generatedPipelineBody(t)) + malformedHeadLegacyBody := strings.Replace( + legacyBody, + `"head_sha":"`+requiredWorkflowTestHeadSHA+`"`, + `"head_sha":"not-a-sha"`, + 1, + ) + if malformedHeadLegacyBody == legacyBody { + t.Fatal("legacy body fixture did not contain its generated head") + } + incompleteLegacyBody := legacyV1PipelineBody(t, generatedPipelineBodyWithStatuses( + t, + types.StepStatusCompleted, + types.StepStatusFailed, + types.StepStatusCompleted, + )) + historicalBody := strings.Replace( + legacyBody, + "Updates from [git push no-slop](https://github.com/Blakeolson21/no-slop)", + "Updates from [git push no-mistakes](https://github.com/kunchenguid/no-mistakes)", + 1, + ) + + got := executeRequiredWorkflowFixture(t, workflow, []requiredWorkflowEvent{ + {Action: "opened", Body: legacyBody, HeadSHA: requiredWorkflowTestHeadSHA, PRNumber: 5, RunID: 500, RunNumber: 50}, + {Action: "synchronize", Body: legacyBody, HeadSHA: requiredWorkflowTestHeadSHA, PRNumber: 6, RunID: 600, RunNumber: 60}, + {Action: "synchronize", Body: legacyBody, HeadSHA: "ffffffffffffffffffffffffffffffffffffffff", PRNumber: 7, RunID: 700, RunNumber: 70}, + {Action: "edited", Body: legacyBody, HeadSHA: requiredWorkflowTestHeadSHA, PRNumber: 8, RunID: 800, RunNumber: 80}, + {Action: "opened", Body: legacyBody, HeadSHA: "ffffffffffffffffffffffffffffffffffffffff", PRNumber: 9, RunID: 900, RunNumber: 90}, + {Action: "opened", Body: historicalBody, HeadSHA: requiredWorkflowTestHeadSHA, PRNumber: 10, RunID: 1000, RunNumber: 100}, + {Action: "synchronize", Body: incompleteLegacyBody, HeadSHA: "ffffffffffffffffffffffffffffffffffffffff", PRNumber: 11, RunID: 1100, RunNumber: 110}, + {Action: "synchronize", Body: generatedPipelineBody(t), HeadSHA: "ffffffffffffffffffffffffffffffffffffffff", PRNumber: 12, RunID: 1200, RunNumber: 120}, + {Action: "synchronize", Body: historicalBody, HeadSHA: "ffffffffffffffffffffffffffffffffffffffff", PRNumber: 13, RunID: 1300, RunNumber: 130}, + {Action: "synchronize", Body: malformedHeadLegacyBody, HeadSHA: "ffffffffffffffffffffffffffffffffffffffff", PRNumber: 14, RunID: 1400, RunNumber: 140}, + }) + want := []requiredWorkflowResult{ + {RunID: 500, RunNumber: 50, Action: "opened", Executed: true, Conclusion: "success"}, + {RunID: 600, RunNumber: 60, Action: "synchronize", Executed: true, Conclusion: "success"}, + {RunID: 700, RunNumber: 70, Action: "synchronize", Executed: true, Conclusion: "success"}, + {RunID: 800, RunNumber: 80, Action: "edited", Executed: true, Conclusion: "failure"}, + {RunID: 900, RunNumber: 90, Action: "opened", Executed: true, Conclusion: "failure"}, + {RunID: 1000, RunNumber: 100, Action: "opened", Executed: true, Conclusion: "failure"}, + {RunID: 1100, RunNumber: 110, Action: "synchronize", Executed: true, Conclusion: "failure"}, + {RunID: 1200, RunNumber: 120, Action: "synchronize", Executed: true, Conclusion: "failure"}, + {RunID: 1300, RunNumber: 130, Action: "synchronize", Executed: true, Conclusion: "failure"}, + {RunID: 1400, RunNumber: 140, Action: "synchronize", Executed: true, Conclusion: "failure"}, + } + if !slices.Equal(got, want) { + t.Fatalf("legacy v1 publication results =\n %v\nwant\n %v", got, want) + } +} + // TestNoSlopRequiredWorkflowReadsPRBodyViaEnv pins the shell-injection-safe // pattern: the PR body must be piped through an env var, not interpolated // directly into the shell script body. @@ -73,12 +201,18 @@ func TestNoSlopRequiredWorkflowReadsPRBodyViaEnv(t *testing.T) { if got := step.Env["PR_BODY"]; got != "${{ github.event.pull_request.body }}" { t.Fatalf("PR_BODY env expression = %q, want pull request body expression", got) } + if got := step.Env["PR_HEAD_SHA"]; got != "${{ github.event.pull_request.head.sha }}" { + t.Fatalf("PR_HEAD_SHA env expression = %q, want pull request head expression", got) + } + if got := step.Env["PR_ACTION"]; got != "${{ github.event.action }}" { + t.Fatalf("PR_ACTION env expression = %q, want pull request action expression", got) + } if strings.Contains(step.Run, "github.event.pull_request.body") { t.Fatalf("workflow must not interpolate the PR body expression directly into run script") } got := executeRequiredWorkflowFixture(t, workflow, []requiredWorkflowEvent{ - {Action: "opened", Body: generatedPipelineBody(t) + "\n$(exit 42)\n`exit 42`", HeadSHA: "head", PRNumber: 1, RunID: 10, RunNumber: 10}, + {Action: "opened", Body: generatedPipelineBody(t) + "\n$(exit 42)\n`exit 42`", HeadSHA: requiredWorkflowTestHeadSHA, PRNumber: 1, RunID: 10, RunNumber: 10}, }) if got[0].Conclusion != "success" { t.Fatalf("env-carried PR body with shell metacharacters concluded %q, want success", got[0].Conclusion) @@ -113,9 +247,9 @@ func TestNoSlopRequiredWorkflowExecutesEveryBodyEvent(t *testing.T) { workflow := loadRequiredWorkflow(t) pipelineBody := generatedPipelineBody(t) events := []requiredWorkflowEvent{ - {Action: "opened", Body: pipelineBody, HeadSHA: "same-head", PRNumber: 549, RunID: 29962844999, RunNumber: 586}, - {Action: "edited", Body: "signature removed", HeadSHA: "same-head", PRNumber: 549, RunID: 29962943078, RunNumber: 587}, - {Action: "edited", Body: pipelineBody, HeadSHA: "same-head", PRNumber: 549, RunID: 29965243268, RunNumber: 588}, + {Action: "opened", Body: pipelineBody, HeadSHA: requiredWorkflowTestHeadSHA, PRNumber: 549, RunID: 29962844999, RunNumber: 586}, + {Action: "edited", Body: "signature removed", HeadSHA: requiredWorkflowTestHeadSHA, PRNumber: 549, RunID: 29962943078, RunNumber: 587}, + {Action: "edited", Body: pipelineBody, HeadSHA: requiredWorkflowTestHeadSHA, PRNumber: 549, RunID: 29965243268, RunNumber: 588}, } got := executeRequiredWorkflowFixture(t, workflow, events) @@ -161,18 +295,28 @@ func TestNoSlopRequiredWorkflowPublishesStableEventIdentity(t *testing.T) { t.Fatalf("required check name changed to %q", workflow.Jobs["check"].Name) } - first := requiredWorkflowEvent{Action: "edited", PRNumber: 549, RunID: 29962943078, RunNumber: 587} - latest := requiredWorkflowEvent{Action: "edited", PRNumber: 549, RunID: 29965243268, RunNumber: 588} + firstNonce := "00112233445566778899aabbccddeeff" + latestNonce := "ffeeddccbbaa99887766554433221100" + first := requiredWorkflowEvent{Action: "edited", Body: "\n\nfirst body quoting ", PRNumber: 549, RunID: 29962943078, RunNumber: 587} + latest := requiredWorkflowEvent{Action: "edited", Body: "\n\nlatest body", PRNumber: 549, RunID: 29965243268, RunNumber: 588} firstName := renderRequiredWorkflowTemplate(t, workflow.RunName, first) latestName := renderRequiredWorkflowTemplate(t, workflow.RunName, latest) - for _, want := range []string{"#549", "edited", "587", "29962943078"} { - if !strings.Contains(firstName, want) { - t.Errorf("first event run name %q does not expose %q", firstName, want) - } - } - for _, want := range []string{"#549", "edited", "588", "29965243268"} { - if !strings.Contains(latestName, want) { - t.Errorf("latest event run name %q does not expose %q", latestName, want) + if !strings.HasPrefix(firstName, "no-slop-required|edited|PR #549 event 587 (run 29962943078)|") { + t.Fatalf("first event run name = %q, want publication identity prefix", firstName) + } + if !strings.HasPrefix(latestName, "no-slop-required|edited|PR #549 event 588 (run 29965243268)|") { + t.Fatalf("latest event run name = %q, want publication identity prefix", latestName) + } + for label, rendered := range map[string]string{"first": firstName, "latest": latestName} { + for field, want := range map[string]string{ + "PR number": "PR #549", + "event action": "edited", + "run number": "event " + strconv.FormatInt(map[string]int64{"first": first.RunNumber, "latest": latest.RunNumber}[label], 10), + "run ID": "run " + strconv.FormatInt(map[string]int64{"first": first.RunID, "latest": latest.RunID}[label], 10), + } { + if !strings.Contains(rendered, want) { + t.Fatalf("%s event run name = %q, want %s %q", label, rendered, field, want) + } } } if firstName == latestName { @@ -243,6 +387,7 @@ type requiredWorkflowEvent struct { Action string Body string HeadSHA string + UpdatedAt string PRNumber int64 RunID int64 RunNumber int64 @@ -270,16 +415,172 @@ func loadRequiredWorkflow(t *testing.T) requiredWorkflow { } func generatedPipelineBody(t *testing.T) string { + return generatedPipelineBodyWithStatuses(t, types.StepStatusCompleted, types.StepStatusCompleted, types.StepStatusCompleted) +} + +func generatedPipelineBodyWithStatuses(t *testing.T, review, testStep, document types.StepStatus) string { t.Helper() - body, _ := pipelinesteps.BuildPipelineSummary([]*db.StepResult{ - {ID: "review", StepName: types.StepReview, Status: types.StepStatusCompleted}, - }, map[string][]*db.StepRound{ - "review": []*db.StepRound{{Round: 1, Trigger: "initial"}}, - }, "0123456789abcdef0123456789abcdef01234567") + results := []*db.StepResult{ + {ID: "review", StepName: types.StepReview, Status: review, CertifiedHeadSHA: testCertifiedWorkflowHead(review)}, + {ID: "test", StepName: types.StepTest, Status: testStep, CertifiedHeadSHA: testCertifiedWorkflowHead(testStep)}, + {ID: "document", StepName: types.StepDocument, Status: document, CertifiedHeadSHA: testCertifiedWorkflowHead(document)}, + {ID: "pr", StepName: types.StepPR, Status: types.StepStatusRunning}, + {ID: "ci", StepName: types.StepCI, Status: types.StepStatusPending}, + } + if review == "" { + results = results[1:] + } + rounds := make(map[string][]*db.StepRound, len(results)) + for _, result := range results { + rounds[result.ID] = []*db.StepRound{{Round: 1, Trigger: "initial"}} + } + body, _ := pipelinesteps.BuildPipelineSummary(results, rounds, requiredWorkflowTestHeadSHA) if strings.TrimSpace(body) == "" { t.Fatal("pipeline summary builder returned an empty PR body") } - return body + return publicationMarkerForPipelineBody(t, body) + "\n\n" + body +} + +func publicationMarkerForPipelineBody(t *testing.T, body string) string { + t.Helper() + const prefix = "" + start := strings.Index(body, prefix) + if start < 0 { + t.Fatal("generated body has no pipeline attestation") + } + start += len(prefix) + end := strings.Index(body[start:], closing) + if end < 0 { + t.Fatal("generated body has malformed pipeline attestation") + } + var attestation struct { + PublicationNonce string `json:"publication_nonce"` + } + if err := json.Unmarshal([]byte(body[start:start+end]), &attestation); err != nil { + t.Fatal(err) + } + if attestation.PublicationNonce == "" { + t.Fatal("generated body has no publication nonce") + } + return "" +} + +func generatedPipelineBodyWithQuotedInvalidAttestation(t *testing.T) string { + t.Helper() + body := generatedPipelineBody(t) + const prefix = "" + start := strings.Index(body, prefix) + if start < 0 { + t.Fatal("generated body has no pipeline attestation") + } + start += len(prefix) + end := strings.Index(body[start:], closing) + if end < 0 { + t.Fatal("generated body has malformed pipeline attestation") + } + var attestation map[string]any + if err := json.Unmarshal([]byte(body[start:start+end]), &attestation); err != nil { + t.Fatal(err) + } + attestation["steps"] = []any{} + invalid, err := json.Marshal(attestation) + if err != nil { + t.Fatal(err) + } + quoted := "## Intent\n\nQuoted historical data:\n\n## Pipeline\n\nUpdates from [git push no-slop](https://github.com/Blakeolson21/no-slop)\n\n" + prefix + string(invalid) + closing + return insertAfterPublicationMarker(t, body, quoted) +} + +func legacyV1PipelineBody(t *testing.T, body string) string { + t.Helper() + markerEnd := strings.Index(body, "\n\n") + if markerEnd < 0 { + t.Fatal("generated body has no publication marker separator") + } + body = body[markerEnd+2:] + const prefix = "" + start := strings.Index(body, prefix) + if start < 0 { + t.Fatal("generated body has no pipeline attestation") + } + start += len(prefix) + end := strings.Index(body[start:], closing) + if end < 0 { + t.Fatal("generated body has malformed pipeline attestation") + } + var attestation struct { + HeadSHA string `json:"head_sha"` + Steps []struct { + Step types.StepName `json:"step"` + Status types.StepStatus `json:"status"` + } `json:"steps"` + } + if err := json.Unmarshal([]byte(body[start:start+end]), &attestation); err != nil { + t.Fatal(err) + } + payload, err := json.Marshal(attestation) + if err != nil { + t.Fatal(err) + } + return body[:start] + string(payload) + body[start+end:] +} + +func insertAfterPublicationMarker(t *testing.T, body, text string) string { + t.Helper() + markerEnd := strings.Index(body, "\n\n") + if markerEnd < 0 { + t.Fatal("generated body has no publication marker separator") + } + return body[:markerEnd+2] + text + "\n\n" + body[markerEnd+2:] +} + +func testCertifiedWorkflowHead(status types.StepStatus) *string { + if status != types.StepStatusCompleted { + return nil + } + head := requiredWorkflowTestHeadSHA + return &head +} + +func generatedPipelineBodyWithStaleReviewCertification(t *testing.T) string { + t.Helper() + body := generatedPipelineBody(t) + const prefix = "" + start := strings.Index(body, prefix) + if start < 0 { + t.Fatal("generated body has no pipeline attestation") + } + start += len(prefix) + end := strings.Index(body[start:], closing) + if end < 0 { + t.Fatal("generated body has malformed pipeline attestation") + } + var attestation struct { + HeadSHA string `json:"head_sha"` + PublicationNonce string `json:"publication_nonce"` + Steps []struct { + Step types.StepName `json:"step"` + Status types.StepStatus `json:"status"` + HeadSHA string `json:"head_sha"` + } `json:"steps"` + } + if err := json.Unmarshal([]byte(body[start:start+end]), &attestation); err != nil { + t.Fatal(err) + } + for i := range attestation.Steps { + if attestation.Steps[i].Step == types.StepReview { + attestation.Steps[i].HeadSHA = "ffffffffffffffffffffffffffffffffffffffff" + } + } + payload, err := json.Marshal(attestation) + if err != nil { + t.Fatal(err) + } + return body[:start] + string(payload) + body[start+end:] } func requiredWorkflowCheckStep(t *testing.T, workflow requiredWorkflow) requiredWorkflowStep { @@ -403,6 +704,8 @@ func executeRequiredWorkflowFixture(t *testing.T, workflow requiredWorkflow, eve shellenv.ConfigureShellCommand(cmd) cmd.Env = append(os.Environ(), "PR_BODY="+event.Body, + "PR_HEAD_SHA="+event.HeadSHA, + "PR_ACTION="+event.Action, "PR_AUTHOR=first-time-fork-contributor", "PR_NUMBER="+strconv.FormatInt(event.PRNumber, 10), ) @@ -441,8 +744,10 @@ func renderRequiredWorkflowTemplate(t *testing.T, template string, event require value string }{ {expression: "github.event.action", value: event.Action}, + {expression: "github.event.pull_request.body", value: event.Body}, {expression: "github.event.pull_request.number", value: strconv.FormatInt(event.PRNumber, 10)}, {expression: "github.event.pull_request.head.sha", value: event.HeadSHA}, + {expression: "github.event.pull_request.updated_at", value: event.UpdatedAt}, {expression: "github.run_id", value: strconv.FormatInt(event.RunID, 10)}, {expression: "github.run_number", value: strconv.FormatInt(event.RunNumber, 10)}, }