diff --git a/internal/cli/review_next_transition_test.go b/internal/cli/review_next_transition_test.go index 43b227ca8..cadf251ce 100644 --- a/internal/cli/review_next_transition_test.go +++ b/internal/cli/review_next_transition_test.go @@ -74,6 +74,94 @@ func TestValidatingEvidenceCollectionUnblocksFinalizeAndPreCommit(t *testing.T) } } +func TestNegotiatedPreCommitStatusRetainsApprovedStagedIntendedReceipt(t *testing.T) { + repo := initReviewCLIRepo(t) + for _, path := range []string{"first.txt", "second.txt"} { + if err := os.WriteFile(filepath.Join(repo, path), []byte("reviewed "+path+"\n"), 0o644); err != nil { + t.Fatal(err) + } + } + lineage := "next-transition-staged-intended" + var startedOutput bytes.Buffer + if err := RunReviewFacadeStart([]string{"--cwd", repo, "--lineage", lineage}, &startedOutput); err != nil { + t.Fatal(err) + } + var started ReviewFacadeStartResult + decodeStrictReviewJSON(t, startedOutput.Bytes(), &started) + store, err := reviewtransaction.CompactAuthoritativeStore(context.Background(), repo, lineage) + if err != nil { + t.Fatal(err) + } + record, err := store.Load() + if err != nil { + t.Fatal(err) + } + for order, lens := range record.State.SelectedLenses { + input := filepath.Join(t.TempDir(), fmt.Sprintf("result-%d.json", order)) + if err := os.WriteFile(input, admittedReviewerPayloadForTest(t, repo, record, lens, order), 0o600); err != nil { + t.Fatal(err) + } + if err := RunReviewCaptureResult([]string{ + "--cwd", repo, "--lineage", started.LineageID, "--target", record.State.InitialSnapshot.Identity, + "--lens", lens, "--order", fmt.Sprint(order), "--input", input, + }, &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + } + if err := RunReviewFacadeFinalize([]string{"--cwd", repo, "--lineage", lineage, "--captured-results"}, &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + record, err = store.Load() + if err != nil { + t.Fatal(err) + } + evidence := filepath.Join(t.TempDir(), "evidence.txt") + if err := os.WriteFile(evidence, []byte("verification passed\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := RunReview([]string{ + "capture-evidence", "--cwd", repo, "--lineage", lineage, "--target", record.State.CurrentSnapshot.Identity, + "--expected-revision", record.Revision, "--outcome", string(reviewtransaction.VerificationOutcomePassed), "--input", evidence, + }, &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + if err := RunReviewFacadeFinalize([]string{"--cwd", repo, "--lineage", lineage, "--captured-evidence"}, &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + runReviewCLIGit(t, repo, "add", "--", "first.txt", "second.txt") + + status := func(selector ...string) ReviewTargetStatusResult { + t.Helper() + args := []string{ + "status", "--contract", ReviewIntegrationContractV1, "--next-transition", "--cwd", repo, + "--projection", string(reviewtransaction.ProjectionStaged), "--gate", string(reviewtransaction.GatePreCommit), + } + args = append(args, selector...) + var output bytes.Buffer + if err := RunReview(args, &output); err != nil { + t.Fatalf("negotiated staged status: %v\n%s", err, output.String()) + } + var result ReviewTargetStatusResult + decodeStrictReviewJSON(t, output.Bytes(), &result) + return result + } + + explicit := status("--lineage", lineage) + unqualified := status() + for _, result := range []ReviewTargetStatusResult{explicit, unqualified} { + if result.Applicability != reviewtransaction.TargetApplicabilityCurrent || + result.Action != reviewtransaction.TargetStatusActionValidate || result.Receipt.Status != ReviewReceiptPresent || + result.Authority == nil || result.Authority.LineageID != lineage || result.NextTransition == nil || + result.NextTransition.Execute == nil || result.NextTransition.Execute.Operation != "review.validate" { + t.Fatalf("approved staged receipt status = %#v", result) + } + } + if !reflect.DeepEqual(explicit.Authority, unqualified.Authority) || explicit.Receipt != unqualified.Receipt || + explicit.NextTransition.Execute.Operation != unqualified.NextTransition.Execute.Operation { + t.Fatalf("explicit and unqualified staged status diverged: explicit %#v, unqualified %#v", explicit, unqualified) + } +} + func TestFinalizeNextTransitionBindsCorrectedCurrentSnapshot(t *testing.T) { initialTarget := strings.Repeat("a", 64) currentTarget := strings.Repeat("b", 64) diff --git a/internal/reviewtransaction/target_status.go b/internal/reviewtransaction/target_status.go index aec72ab41..77470371b 100644 --- a/internal/reviewtransaction/target_status.go +++ b/internal/reviewtransaction/target_status.go @@ -239,6 +239,21 @@ func assessTargetStatusSnapshot(ctx context.Context, repo string, request Target } else if compactLiveTargetMatchesValidatedSnapshot(state, live, true) { candidates = append(candidates, candidate) continue + } else if state.State == StateApproved && candidate.receiptPublished && candidate.receiptCanonical && + (state.CurrentSnapshot.Projection == "" || state.CurrentSnapshot.Projection == ProjectionWorkspace) && live.Projection == ProjectionStaged { + // PRE-COMMIT represents reviewed intended-untracked files in the index. + // Reuse its exact target assessment instead of weakening STATUS matching: + // it accepts only the identical candidate, base, and complete path digest. + assessment, assessmentErr := AssessCompactGateTarget(ctx, repo, state, NativeGateRequestInput{ + Gate: GatePreCommit, LineageID: state.LineageID, + }) + if assessmentErr != nil { + return targetStatusFailure(base, assessmentErr) + } + if assessment.Applicability == CompactGateTargetExact { + candidates = append(candidates, candidate) + continue + } } if request.LineageID == "" && candidate.receiptPublished && (state.State == StateApproved || state.State == StateEscalated) { if projectCompactTerminalHistory(state, live) == compactTerminalHistoryScopeChanged { diff --git a/internal/reviewtransaction/target_status_test.go b/internal/reviewtransaction/target_status_test.go index 91e6ea2af..14e5a4edf 100644 --- a/internal/reviewtransaction/target_status_test.go +++ b/internal/reviewtransaction/target_status_test.go @@ -129,6 +129,101 @@ func TestAssessTargetStatusDerivesReceiptTruthWithoutMutation(t *testing.T) { } } +func TestAssessTargetStatusRetainsApprovedReceiptForExactStagedIntendedTransition(t *testing.T) { + requireSnapshotGit(t) + tests := []struct { + name string + stage func(t *testing.T, repo string) + wantCurrent bool + }{ + { + name: "all reviewed intended paths staged exactly", + stage: func(t *testing.T, repo string) { + gitSnapshot(t, repo, "add", "--", "tracked.txt", "first.txt", "second.txt") + }, + wantCurrent: true, + }, + { + name: "only a subset of intended paths staged", + stage: func(t *testing.T, repo string) { + gitSnapshot(t, repo, "add", "--", "tracked.txt", "first.txt") + }, + }, + { + name: "additional unreviewed staged path", + stage: func(t *testing.T, repo string) { + writeSnapshotFile(t, repo, "extra.txt", "not reviewed\n") + gitSnapshot(t, repo, "add", "--", "tracked.txt", "first.txt", "second.txt", "extra.txt") + }, + }, + { + name: "reviewed staged content changed", + stage: func(t *testing.T, repo string) { + writeSnapshotFile(t, repo, "first.txt", "changed after review\n") + gitSnapshot(t, repo, "add", "--", "tracked.txt", "first.txt", "second.txt") + }, + }, + { + name: "reviewed staged mode changed", + stage: func(t *testing.T, repo string) { + gitSnapshot(t, repo, "add", "--", "tracked.txt", "first.txt", "second.txt") + gitSnapshot(t, repo, "update-index", "--chmod=+x", "--", "first.txt") + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := initSnapshotRepo(t) + writeSnapshotFile(t, repo, "tracked.txt", "reviewed tracked change\n") + for _, path := range []string{"first.txt", "second.txt"} { + writeSnapshotFile(t, repo, path, "reviewed "+path+"\n") + } + lineage := "status-staged-intended-" + strings.ReplaceAll(tt.name, " ", "-") + state, _, _ := approvedCompactCurrentChangesFixture(t, repo, lineage, []string{"first.txt", "second.txt"}) + tt.stage(t, repo) + assessment, err := AssessCompactGateTarget(context.Background(), repo, state, NativeGateRequestInput{ + Gate: GatePreCommit, LineageID: lineage, + }) + if err != nil { + t.Fatal(err) + } + + request := TargetStatusRequest{Target: Target{ + Kind: TargetCurrentChanges, Projection: ProjectionStaged, IntendedUntracked: []string{}, + }, LineageID: lineage} + explicit, err := AssessTargetStatus(context.Background(), repo, request) + if err != nil { + t.Fatal(err) + } + request.LineageID = "" + unqualified, err := AssessTargetStatus(context.Background(), repo, request) + if err != nil { + t.Fatal(err) + } + + if tt.wantCurrent { + if assessment.Applicability != CompactGateTargetExact { + t.Fatalf("exact staged pre-commit assessment = %#v, expected %#v", assessment, state.CurrentSnapshot) + } + if explicit.Applicability != TargetApplicabilityCurrent || explicit.Action != TargetStatusActionValidate || + explicit.State != StateApproved || explicit.ReceiptIdentity == "" || explicit.LineageID != lineage { + t.Fatalf("explicit staged status = %#v", explicit) + } + if unqualified.Applicability != TargetApplicabilityCurrent || unqualified.Action != TargetStatusActionValidate || + unqualified.LineageID != explicit.LineageID || unqualified.ReceiptIdentity != explicit.ReceiptIdentity || + unqualified.AuthorityTargetIdentity != state.CurrentSnapshot.Identity { + t.Fatalf("unqualified staged status = %#v, explicit %#v", unqualified, explicit) + } + return + } + if explicit.Applicability == TargetApplicabilityCurrent || explicit.Action == TargetStatusActionValidate || + unqualified.Applicability == TargetApplicabilityCurrent || unqualified.Action == TargetStatusActionValidate { + t.Fatalf("inexact staged status became current: explicit %#v, unqualified %#v", explicit, unqualified) + } + }) + } +} + func TestAssessTargetStatusClassifiesAllApplicabilityStates(t *testing.T) { requireSnapshotGit(t) request := TargetStatusRequest{Target: Target{Kind: TargetCurrentChanges, IntendedUntracked: []string{}}}