From 47c385e11c757ef13bb20cae1ce690a0c3351879 Mon Sep 17 00:00:00 2001 From: Ahmet Taspinar Date: Fri, 2 Oct 2026 20:10:16 +0200 Subject: [PATCH] Tie reviews to the exact reviewed version with a fingerprint scripts/lib/fingerprint.sh computes a content fingerprint of the working tree or of an explicit set of paths, including uncommitted and untracked changes and excluding review and triage artifacts. Committing unchanged content keeps it; changing a covered file changes it. review-feature.sh records it as reviewed_tree (and reviewed_paths for a file-set review). triage-review.sh and apply-triage.sh refuse a stale review before starting an agent, and check-review.sh reports whether a review is current. Agent runs also detect changes to the artifacts that fingerprints leave out. Closes #38 Co-Authored-By: Claude Opus 5.5 --- docs/development.md | 22 +++++++ scripts/apply-triage.sh | 19 ++++++ scripts/check-review.sh | 54 +++++++++++++++ scripts/lib/fingerprint.sh | 113 ++++++++++++++++++++++++++++++++ scripts/lib/review-data.sh | 5 +- scripts/review-feature.sh | 29 +++------ scripts/triage-review.sh | 14 +++- scripts/verify.conf | 1 + tests/apply-triage-test.sh | 8 +++ tests/fingerprint-test.sh | 123 +++++++++++++++++++++++++++++++++++ tests/lib-fakes.sh | 23 +++++++ tests/review-feature-test.sh | 14 ++++ tests/triage-review-test.sh | 8 +++ 13 files changed, 412 insertions(+), 21 deletions(-) create mode 100755 scripts/check-review.sh create mode 100644 scripts/lib/fingerprint.sh create mode 100755 tests/fingerprint-test.sh diff --git a/docs/development.md b/docs/development.md index 5c4b55c..a74a206 100644 --- a/docs/development.md +++ b/docs/development.md @@ -336,6 +336,28 @@ fields the scripts own, so editing a stored artifact cannot weaken a decision. Documents that people maintain, such as the roadmap, requirements, and architecture, keep Markdown as their source. +### When a review becomes stale + +Every review records a fingerprint of what it reviewed (`reviewed_tree`): the +Git tree hash of the file contents at review time, including uncommitted and +untracked changes. Review and triage artifacts and ignored files are not part +of it, except files Git already tracks. A review may instead cover an explicit +list of files (`reviewed_paths`); then only those files count. + +The fingerprint depends on content, not on commits. Committing the reviewed +content keeps the review current; changing, adding, or deleting a covered file +makes it stale. `triage-review.sh` and `apply-triage.sh` refuse a stale review +before they start an agent. After `apply-triage.sh` changes the code, the +review is stale by design: run a new review when the fixes need confirmation. + +Check a review yourself with: + +```bash +./scripts/check-review.sh .agents/reviews/feature-12-player-movement-review-01.json +``` + +It exits 0 when the review is current, 1 when it is stale, and 2 on an error. + Verify again after review fixes, then commit and push: ```bash diff --git a/scripts/apply-triage.sh b/scripts/apply-triage.sh index c0a4b61..f63e29f 100755 --- a/scripts/apply-triage.sh +++ b/scripts/apply-triage.sh @@ -5,6 +5,7 @@ set -euo pipefail script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" source "$script_dir/lib/agent.sh" source "$script_dir/lib/review-data.sh" +source "$script_dir/lib/fingerprint.sh" usage() { echo "Usage: $0 [--agent ] [--model ]" @@ -82,6 +83,24 @@ review_errors="$(review_artifact_errors "$source_review_path")" triage_errors="$(triage_artifact_errors "$triage_path" "$source_review_path")" [[ -z "$triage_errors" ]] || report_errors "invalid or unapproved triage artifact:" "$triage_errors" +tmp_work="$(mktemp -d "${TMPDIR:-/tmp}/apply-triage.XXXXXX")" +trap 'rm -rf "$tmp_work"' EXIT +review_path="$source_review_path" +review_relative="$source_review_relative" +fail() { + echo "Error: $*" >&2 + exit 1 +} + +# Triage and fixes apply only to the content that was reviewed. +stale_status=0 +review_is_current "$root" "$tmp_work" "$review_path" || stale_status=$? +case "$stale_status" in + 0) ;; + 1) fail "the review is stale: the reviewed content changed after $review_relative was written. Run a new review." ;; + *) fail "could not compute the current fingerprint of the working tree." ;; +esac + source_issue="$(jq -r '.issue' "$triage_path")" branch="$(git branch --show-current)" diff --git a/scripts/check-review.sh b/scripts/check-review.sh new file mode 100755 index 0000000..5a9bbdc --- /dev/null +++ b/scripts/check-review.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +source "$script_dir/lib/review-data.sh" +source "$script_dir/lib/fingerprint.sh" + +if [[ $# -ne 1 ]]; then + echo "Usage: $0 " + echo + echo "Reports whether the content a review covers is unchanged since the review." + echo "Exits 0 when the review is current, 1 when it is stale, and 2 on an error." + exit 2 +fi + +fail() { + echo "Error: $*" >&2 + exit 2 +} + +root="$(git rev-parse --show-toplevel)" +[[ -f "$1" ]] || fail "review artifact not found: $1" +review_path="$(cd "$(dirname "$1")" && pwd -P)/$(basename "$1")" +[[ "$review_path" == "$root/.agents/reviews/"*.json ]] || + fail "review artifact must match .agents/reviews/*.json. Received: $review_path" +review_relative="${review_path#"$root"/}" + +review_data_require_jq +review_errors="$(review_artifact_errors "$review_path")" +if [[ -n "$review_errors" ]]; then + echo "Error: invalid review artifact:" >&2 + printf '%s\n' "$review_errors" | sed 's/^/ - /' >&2 + exit 2 +fi + +tmp_work="$(mktemp -d "${TMPDIR:-/tmp}/check-review.XXXXXX")" +trap 'rm -rf "$tmp_work"' EXIT + +status=0 +review_is_current "$root" "$tmp_work" "$review_path" || status=$? +case "$status" in + 0) + echo "Current: $review_relative still matches the reviewed content." + ;; + 1) + echo "Stale: the reviewed content changed after $review_relative was written." + echo "Run a new review before triage, fixes, or approval." + exit 1 + ;; + *) + fail "could not compute the current fingerprint." + ;; +esac diff --git a/scripts/lib/fingerprint.sh b/scripts/lib/fingerprint.sh new file mode 100644 index 0000000..e8179bf --- /dev/null +++ b/scripts/lib/fingerprint.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash + +# Content fingerprints of a working tree or of a set of files. +# Source this file; do not execute it. +# +# A fingerprint is the Git tree hash of the current file contents, including +# uncommitted and untracked changes. It depends on content only, so committing +# unchanged content keeps it valid, and any change to a covered file changes +# it. Review and triage artifacts are never part of a fingerprint. The real +# Git index is not touched; objects may be written to the object database. + +FINGERPRINT_EXCLUDES=(".agents/reviews" ".agents/triage") + +# fingerprint_worktree +# Prints the fingerprint of the whole working tree. Ignored files are left +# out unless Git already tracks them. +fingerprint_worktree() { + local root="$1" + local index="$2/fingerprint.index" + local real_index + local exclude_specs=() + local path + + for path in "${FINGERPRINT_EXCLUDES[@]}"; do + exclude_specs+=(":(exclude)$path") + done + + # Start from the real index so tracked files that match an ignore rule stay + # covered. The path may be relative to the repository root. + rm -f "$index" + real_index="$(cd "$root" && git rev-parse --git-path index)" + if (cd "$root" && [[ -f "$real_index" ]]); then + (cd "$root" && cp "$real_index" "$index") || return 1 + fi + + GIT_INDEX_FILE="$index" git -C "$root" rm -r -q --cached --ignore-unmatch -- "${FINGERPRINT_EXCLUDES[@]}" >/dev/null && + GIT_INDEX_FILE="$index" git -C "$root" add -A -- . "${exclude_specs[@]}" >/dev/null && + GIT_INDEX_FILE="$index" git -C "$root" write-tree +} + +# fingerprint_files ... +# Prints the fingerprint of exactly the given repository-relative paths, which +# may be files, symlinks, or directories. A missing path is part of the +# fingerprint as absent. Review and triage artifacts are excluded here too. +fingerprint_files() { + local root="$1" + local index="$2/fingerprint-files.index" + local specs=() + local path + + shift 2 + for path in "$@"; do + if [[ -e "$root/$path" || -L "$root/$path" ]]; then + specs+=("$path") + fi + done + + rm -f "$index" + if [[ "${#specs[@]}" -gt 0 ]]; then + for path in "${FINGERPRINT_EXCLUDES[@]}"; do + specs+=(":(exclude)$path") + done + GIT_INDEX_FILE="$index" git -C "$root" add -f -- "${specs[@]}" >/dev/null || return 1 + fi + GIT_INDEX_FILE="$index" git -C "$root" write-tree +} + +# fingerprint_artifacts +# Prints a hash of the review and triage artifacts, which every other +# fingerprint leaves out, so a run can detect that an agent changed them. +fingerprint_artifacts() { + ( + cd "$1" || exit 1 + # A missing artifact directory is an empty set, not an error. + { find "${FINGERPRINT_EXCLUDES[@]}" \( -type f -o -type l \) 2>/dev/null || true; } | LC_ALL=C sort | + while IFS= read -r path; do + if [[ -L "$path" ]]; then + printf '%s symlink %s\n' "$path" "$(readlink "$path")" + else + printf '%s %s\n' "$path" "$(git hash-object -- "$path")" + fi + done + ) | git hash-object --stdin +} + +# fingerprint_review +# Prints the current fingerprint of what the review covers: the paths in +# reviewed_paths when the review lists them, otherwise the whole working tree. +fingerprint_review() { + local root="$1" + local scratch="$2" + local review="$3" + local paths=() + local path + + if jq -e '(.reviewed_paths | type) == "array"' "$review" >/dev/null; then + while IFS= read -r path; do + paths+=("$path") + done < <(jq -r '.reviewed_paths[]' "$review") + fingerprint_files "$root" "$scratch" ${paths[@]+"${paths[@]}"} + else + fingerprint_worktree "$root" "$scratch" + fi +} + +# review_is_current +# Succeeds when the reviewed content is unchanged since the review. +review_is_current() { + local current + + current="$(fingerprint_review "$1" "$2" "$3")" || return 2 + [[ "$current" == "$(jq -r '.reviewed_tree' "$3")" ]] +} diff --git a/scripts/lib/review-data.sh b/scripts/lib/review-data.sh index 48c662b..6ebf54c 100644 --- a/scripts/lib/review-data.sh +++ b/scripts/lib/review-data.sh @@ -169,7 +169,10 @@ review_artifact_errors() { "the file is not a review/v1 artifact" else unexpected(["base", "branch", "created_at", "findings", "head", "issue", "limitations", - "merge_base", "reviewed_tree", "reviewer", "round", "schema", "verdict"]; "the review"), + "merge_base", "reviewed_paths", "reviewed_tree", "reviewer", "round", "schema", "verdict"]; "the review"), + (if .reviewed_paths == null + or ((.reviewed_paths | type) == "array" and (.reviewed_paths | length) > 0 and (.reviewed_paths | all(nonempty))) then empty + else "reviewed_paths must be null or a non-empty list of paths" end), (if (.issue | positive_integer) then empty else "issue must be a positive integer" end), (if (.round | positive_integer) then empty else "round must be a positive integer" end), (("branch", "base", "merge_base", "head", "reviewed_tree", "created_at") as $field | diff --git a/scripts/review-feature.sh b/scripts/review-feature.sh index a5a178b..c2c6997 100755 --- a/scripts/review-feature.sh +++ b/scripts/review-feature.sh @@ -5,6 +5,7 @@ set -euo pipefail script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" source "$script_dir/lib/agent.sh" source "$script_dir/lib/review-data.sh" +source "$script_dir/lib/fingerprint.sh" fail() { echo "Error: $*" >&2 @@ -76,25 +77,13 @@ issue_context="$(gh issue view "$issue" \ --template 'Title: {{.title}}{{"\n\n"}}{{.body}}')" || fail "could not read GitHub Issue #$issue." -# Tree of the complete working tree, including uncommitted and untracked -# files, built in a temporary index so the real index is not touched. -snapshot_tree() { - local index="$tmp_work/index.$1" - local real_index - - # Start from the real index so tracked files that match an ignore rule stay - # in the snapshot. The path may be relative to the repository root. - real_index="$(cd "$root" && git rev-parse --git-path index)" - if (cd "$root" && [[ -f "$real_index" ]]); then - (cd "$root" && cp "$real_index" "$index") || - fail "could not copy the Git index for the review snapshot." - fi - GIT_INDEX_FILE="$index" git -C "$root" add -A >/dev/null - GIT_INDEX_FILE="$index" git -C "$root" write-tree -} - head_before="$(git -C "$root" rev-parse HEAD)" -tree_before="$(snapshot_tree before)" +# The fingerprint of the reviewed content, including uncommitted and untracked +# files; the diff below is built from the same snapshot. +tree_before="$(fingerprint_worktree "$root" "$tmp_work")" || + fail "could not compute the fingerprint of the working tree." +cp "$tmp_work/fingerprint.index" "$tmp_work/index.before" +artifacts_before="$(fingerprint_artifacts "$root")" review_paths=(. ":(exclude).agents/reviews" ":(exclude).agents/triage") context_file="$tmp_work/context.md" @@ -190,7 +179,8 @@ for attempt in 1 2; do agent_status=$? set -e - if [[ "$(git -C "$root" rev-parse HEAD)" != "$head_before" || "$(snapshot_tree "after-$attempt")" != "$tree_before" ]]; then + if [[ "$(git -C "$root" rev-parse HEAD)" != "$head_before" || "$(fingerprint_worktree "$root" "$tmp_work")" != "$tree_before" || + "$(fingerprint_artifacts "$root")" != "$artifacts_before" ]]; then echo "Error: the reviewer modified the working tree or created a commit. No review was stored." >&2 git -C "$root" status --short >&2 exit 1 @@ -235,6 +225,7 @@ review_result_with_ids "$report_file" | jq \ merge_base: $merge_base, head: $head, reviewed_tree: $tree, + reviewed_paths: null, reviewer: {agent: $agent, model: $model}, created_at: $created_at, verdict: .verdict, diff --git a/scripts/triage-review.sh b/scripts/triage-review.sh index ead03c9..109f988 100755 --- a/scripts/triage-review.sh +++ b/scripts/triage-review.sh @@ -5,6 +5,7 @@ set -euo pipefail script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" source "$script_dir/lib/agent.sh" source "$script_dir/lib/review-data.sh" +source "$script_dir/lib/fingerprint.sh" usage() { echo "Usage: $0 [--agent ] [--model ]" @@ -85,6 +86,15 @@ provenance+="[$review_ref]" tmp_work="$(mktemp -d "${TMPDIR:-/tmp}/triage-review.XXXXXX")" trap 'rm -rf "$tmp_work"' EXIT +# Triage and fixes apply only to the content that was reviewed. +stale_status=0 +review_is_current "$root" "$tmp_work" "$review_path" || stale_status=$? +case "$stale_status" in + 0) ;; + 1) fail "the review is stale: the reviewed content changed after $review_relative was written. Run a new review." ;; + *) fail "could not compute the current fingerprint of the working tree." ;; +esac + decisions_file="$tmp_work/decisions.json" context_file="$tmp_work/context.md" @@ -120,6 +130,7 @@ every finding exactly once, using its 'id' as 'finding_id', and return JSON that matches the supplied schema." review_hash_before="$(git hash-object "$review_path")" + artifacts_before="$(fingerprint_artifacts "$root")" status_before="$(git -C "$root" status --porcelain=v1 --untracked-files=all)" # Invalid output is retried once; a failed agent or a modified tree is not. @@ -134,7 +145,8 @@ that matches the supplied schema." if [[ "$(git hash-object "$review_path")" != "$review_hash_before" ]]; then fail "triage agent modified the source review artifact." fi - if [[ "$(git -C "$root" status --porcelain=v1 --untracked-files=all)" != "$status_before" ]]; then + if [[ "$(git -C "$root" status --porcelain=v1 --untracked-files=all)" != "$status_before" || + "$(fingerprint_artifacts "$root")" != "$artifacts_before" ]]; then echo "Error: triage agent modified the working tree." >&2 git -C "$root" status --short >&2 exit 1 diff --git a/scripts/verify.conf b/scripts/verify.conf index b2ed585..72c810f 100644 --- a/scripts/verify.conf +++ b/scripts/verify.conf @@ -27,6 +27,7 @@ shell-syntax: for file in scripts/*.sh scripts/lib/*.sh tests/*.sh; do bash -n " verify: ./tests/verify-test.sh doctor: ./tests/doctor-test.sh agent: ./tests/agent-test.sh +fingerprint: ./tests/fingerprint-test.sh review-feature: ./tests/review-feature-test.sh triage-review: ./tests/triage-review-test.sh apply-triage: ./tests/apply-triage-test.sh diff --git a/tests/apply-triage-test.sh b/tests/apply-triage-test.sh index 48f91a9..41c57f1 100755 --- a/tests/apply-triage-test.sh +++ b/tests/apply-triage-test.sh @@ -74,6 +74,7 @@ setup_repo() { git -C "$repo" add . git -C "$repo" commit -qm "Seed project" git -C "$repo" switch -q -c feature/13-apply-test + record_reviewed_tree "$repo" "$repo/$review" "$repo/$triage" printf '%s\n' "$repo" } @@ -211,6 +212,12 @@ expect_rejected "$repo" "the input is the generated report" ".agents/triage/repo expect_rejected "$repo" "the triage does not exist" ".agents/triage/missing.json" expect_rejected "$repo" "the agent is unsupported" "$triage" --agent copilot --model model-x +# Fixes apply only to the reviewed content; a stale review is refused. +repo="$(setup_repo stale)" +printf 'changed after the review\n' >>"$repo/AGENTS.md" +expect_rejected "$repo" "the review is stale" "$triage" +grep -Fq "stale" "$repo.out" || fail "a stale review was not reported as stale" + # A failing agent is reported after verification still ran. repo="$(setup_repo agent-fails)" if MOCK_AGENT_EXIT=7 run_apply "$repo" y "$triage"; then @@ -235,6 +242,7 @@ fi repo="$(setup_repo verification-fails)" printf 'broken: false\n' >"$repo/scripts/verify.conf" git -C "$repo" commit -qam "Break verification" +record_reviewed_tree "$repo" "$repo/$review" "$repo/$triage" if run_apply "$repo" y "$triage"; then fail "a failed verification returned success" fi diff --git a/tests/fingerprint-test.sh b/tests/fingerprint-test.sh new file mode 100755 index 0000000..85fb1b7 --- /dev/null +++ b/tests/fingerprint-test.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +tmp="$(mktemp -d "${TMPDIR:-/tmp}/fingerprint-test.XXXXXX")" + +cleanup() { + rm -rf "$tmp" +} +trap cleanup EXIT + +fail() { + echo "fingerprint test failed: $*" >&2 + exit 1 +} + +source "$source_root/scripts/lib/fingerprint.sh" +source "$source_root/tests/lib-fakes.sh" + +repo="$tmp/repo" +mkdir -p "$repo/docs" "$repo/.agents/reviews" "$repo/.agents/triage" "$repo/scripts/lib" +cp "$source_root/scripts/check-review.sh" "$repo/scripts/" +cp "$source_root/scripts/lib/"*.sh "$repo/scripts/lib/" +printf 'one\n' >"$repo/a.txt" +printf 'plan\n' >"$repo/docs/plan.md" +printf '*.log\n' >"$repo/.gitignore" +printf 'tracked although ignored\n' >"$repo/kept.log" +git -C "$repo" init -q -b main +git -C "$repo" config user.name "Fingerprint Test" +git -C "$repo" config user.email "fingerprint-test@example.com" +git -C "$repo" add . +git -C "$repo" add -f kept.log +git -C "$repo" commit -qm "Seed" + +whole() { + fingerprint_worktree "$repo" "$tmp" +} +files() { + fingerprint_files "$repo" "$tmp" docs/plan.md +} + +base="$(whole)" +[[ "$(whole)" == "$base" ]] || fail "the fingerprint of unchanged content is not stable" + +# Review and triage artifacts and ignored files do not count. +printf '{}\n' >"$repo/.agents/reviews/x-review-01.json" +printf '{}\n' >"$repo/.agents/triage/x-review-01-triage.json" +printf 'build output\n' >"$repo/build.log" +[[ "$(whole)" == "$base" ]] || fail "review artifacts or ignored files changed the fingerprint" + +# A changed, an untracked, and a deleted file each change it. +printf 'two\n' >>"$repo/a.txt" +changed="$(whole)" +[[ "$changed" != "$base" ]] || fail "a modified file did not change the fingerprint" +printf 'new\n' >"$repo/b.txt" +[[ "$(whole)" != "$changed" ]] || fail "an untracked file did not change the fingerprint" +rm "$repo/b.txt" +[[ "$(whole)" == "$changed" ]] || fail "removing the untracked file did not restore the fingerprint" + +# Committing unchanged content keeps the fingerprint. +git -C "$repo" add a.txt +git -C "$repo" commit -qm "Commit the change" +[[ "$(whole)" == "$changed" ]] || fail "committing unchanged content changed the fingerprint" + +# A tracked file that matches an ignore rule stays covered, also from a subdirectory. +printf 'changed\n' >>"$repo/kept.log" +from_subdirectory="$(cd "$repo/docs" && whole)" +[[ "$from_subdirectory" != "$changed" ]] || fail "a tracked ignored file was not covered" +git -C "$repo" checkout -q -- kept.log + +# A file-set fingerprint covers exactly its files. +set_base="$(files)" +printf 'three\n' >>"$repo/a.txt" +[[ "$(files)" == "$set_base" ]] || fail "a file outside the set changed its fingerprint" +printf 'revised\n' >>"$repo/docs/plan.md" +[[ "$(files)" != "$set_base" ]] || fail "a file in the set did not change its fingerprint" +git -C "$repo" checkout -q -- a.txt docs/plan.md + +# Artifacts are excluded from a file set too, also when it names a directory. +dir_base="$(fingerprint_files "$repo" "$tmp" . docs)" +printf '{"changed": true}\n' >"$repo/.agents/reviews/x-review-01.json" +[[ "$(fingerprint_files "$repo" "$tmp" . docs)" == "$dir_base" ]] || + fail "a review artifact changed a file-set fingerprint" + +# A dangling symlink in a file set counts: its target and its removal matter. +ln -s missing-target "$repo/docs/link" +link_base="$(fingerprint_files "$repo" "$tmp" docs/link)" +[[ "$link_base" != "$(fingerprint_files "$repo" "$tmp" docs/absent)" ]] || + fail "a dangling symlink was treated as absent" +rm "$repo/docs/link" +ln -s other-target "$repo/docs/link" +[[ "$(fingerprint_files "$repo" "$tmp" docs/link)" != "$link_base" ]] || + fail "a changed symlink target did not change the fingerprint" +rm "$repo/docs/link" + +# check-review.sh reports current and stale reviews for both kinds of review. +review="$repo/.agents/reviews/feature-1-x-review-01.json" +jq -n '{ + schema: "review/v1", issue: 1, round: 1, branch: "feature/1-x", base: "main", + merge_base: "aaaa", head: "bbbb", reviewed_tree: "", reviewed_paths: null, + reviewer: {agent: "codex", model: "m"}, created_at: "2026-01-01T00:00:00Z", + verdict: "PASS", limitations: "", findings: [] +}' >"$review" +record_reviewed_tree "$repo" "$review" +(cd "$repo" && ./scripts/check-review.sh "$review" >/dev/null) || fail "a current review was reported as stale" +printf 'four\n' >>"$repo/a.txt" +status=0 +(cd "$repo" && ./scripts/check-review.sh "$review" >/dev/null) || status=$? +[[ "$status" -eq 1 ]] || fail "a stale review was not reported as stale (status $status)" +git -C "$repo" checkout -q -- a.txt + +jq --arg tree "$(files)" '.reviewed_paths = ["docs/plan.md"] | .reviewed_tree = $tree' "$review" >"$review.tmp" +mv "$review.tmp" "$review" +printf 'five\n' >>"$repo/a.txt" +(cd "$repo" && ./scripts/check-review.sh "$review" >/dev/null) || + fail "a change outside the reviewed paths made the review stale" +printf 'revised\n' >>"$repo/docs/plan.md" +status=0 +(cd "$repo" && ./scripts/check-review.sh "$review" >/dev/null) || status=$? +[[ "$status" -eq 1 ]] || fail "a change to a reviewed path did not make the review stale" + +echo "fingerprint tests passed" diff --git a/tests/lib-fakes.sh b/tests/lib-fakes.sh index 538df95..d68808e 100644 --- a/tests/lib-fakes.sh +++ b/tests/lib-fakes.sh @@ -88,3 +88,26 @@ copy_workflow() { cp "$source_root"/.agents/prompts/*.md "$repo/.agents/prompts/" cp "$source_root"/.agents/schemas/*.json "$repo/.agents/schemas/" } + +# record_reviewed_tree [triage-json] +# Stores the current fingerprint of the repository as the reviewed tree of a +# fixture review, and copies it into a triage fixture, so the review is current. +record_reviewed_tree() { + local repo="$1" + local review="$2" + local triage="${3:-}" + local scratch + local tree + + scratch="$(mktemp -d "${TMPDIR:-/tmp}/fingerprint.XXXXXX")" + tree="$( + source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)/scripts/lib/fingerprint.sh" + fingerprint_worktree "$repo" "$scratch" + )" + rm -rf "$scratch" + + jq --arg tree "$tree" '.reviewed_tree = $tree' "$review" >"$review.tmp" && mv "$review.tmp" "$review" + if [[ -n "$triage" ]]; then + jq --arg tree "$tree" '.reviewed_tree = $tree' "$triage" >"$triage.tmp" && mv "$triage.tmp" "$triage" + fi +} diff --git a/tests/review-feature-test.sh b/tests/review-feature-test.sh index c2b7bdb..558531b 100755 --- a/tests/review-feature-test.sh +++ b/tests/review-feature-test.sh @@ -152,6 +152,20 @@ repo="$(setup_repo create)" MOCK_AGENT_ACTION="printf 'created by the reviewer\n' >'$repo/reviewer-note.txt'" \ expect_no_review "$repo" "the reviewer created a file" 7 +# A reviewer that changes an earlier review artifact is detected, although +# artifacts are not part of the reviewed content. +repo="$(setup_repo modifies-artifact)" +run_review "$repo" 7 || { + cat "$repo.out" >&2 + fail "the first review round failed" +} +earlier="$repo/.agents/reviews/feature-7-marker-review-01.json" +if MOCK_AGENT_ACTION="printf ' ' >>'$earlier'" run_review "$repo" 7; then + fail "a reviewer that changed an earlier review artifact was accepted" +fi +[[ ! -e "$repo/.agents/reviews/feature-7-marker-review-02.json" ]] || + fail "a review was stored although the reviewer changed an earlier review artifact" + # A failed reviewer stores nothing. repo="$(setup_repo agent-fails)" MOCK_AGENT_EXIT=43 expect_no_review "$repo" "the reviewer failed" 7 diff --git a/tests/triage-review-test.sh b/tests/triage-review-test.sh index e55503d..27358a6 100755 --- a/tests/triage-review-test.sh +++ b/tests/triage-review-test.sh @@ -100,6 +100,7 @@ setup_repo() { git -C "$repo" config user.email "triage-test@example.com" git -C "$repo" add . git -C "$repo" commit -qm "Seed project" + record_reviewed_tree "$repo" "$repo/.agents/reviews/feature-5-test-review-$(printf '%02d' "$round").json" printf '%s\n' "$repo" } @@ -262,6 +263,13 @@ MOCK_AGENT_EXIT=9 expect_no_triage "$repo" "the triage agent failed" "$review" repo="$(setup_repo modifies)" MOCK_AGENT_ACTION="printf 'x\n' >'$repo/note.txt'" expect_no_triage "$repo" "the triage agent changed the working tree" "$review" +# A review of content that changed since is stale and is not triaged. +repo="$(setup_repo stale)" +printf 'changed after the review\n' >>"$repo/AGENTS.md" +expect_no_triage "$repo" "the review is stale" "$review" +grep -Fq "stale" "$repo.out" || fail "a stale review was not reported as stale" +[[ ! -e "$repo.log" ]] || fail "a triage agent was started for a stale review" + # A review without findings needs no agent and still records an approved triage. repo="$(setup_repo none 1 none)" run_triage "$repo" y "$review" || {