diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 67f78f00..a56ecddf 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -130,6 +130,24 @@ dependencies { implementation("org.jetbrains.pty4j:pty4j:0.13.10") runtimeOnly("org.slf4j:slf4j-jdk14:2.0.13") + // Structural parsing for the Review board's change graph (docs/superpowers/ + // specs/2026-08-22-review-navigation-design.md §10). The core artifact + // bundles aarch64/x86_64 macOS, x86_64 Windows and both Linux natives -- + // exactly the platforms this app supports -- and extracts the matching one + // to ~/.tree-sitter/tree-sitter-lib/ on first use. A grammar missing from + // the classpath is the lexical path (GrammarRegistry), not an error, so + // this list is a packaging decision and may differ per artifact. + implementation("io.github.bonede:tree-sitter:0.25.3") + implementation("io.github.bonede:tree-sitter-java:0.23.4") + implementation("io.github.bonede:tree-sitter-kotlin:0.3.8.1") + implementation("io.github.bonede:tree-sitter-python:0.23.4") + implementation("io.github.bonede:tree-sitter-javascript:0.23.1") + implementation("io.github.bonede:tree-sitter-typescript:0.23.2") + implementation("io.github.bonede:tree-sitter-go:0.23.3") + implementation("io.github.bonede:tree-sitter-rust:0.23.1") + implementation("io.github.bonede:tree-sitter-c:0.23.2") + implementation("io.github.bonede:tree-sitter-cpp:0.23.4") + testImplementation(platform("org.junit:junit-bom:5.11.4")) testImplementation("org.junit.jupiter:junit-jupiter") testRuntimeOnly("org.junit.platform:junit-platform-launcher") diff --git a/app/src/main/java/app/drydock/DrydockApplication.java b/app/src/main/java/app/drydock/DrydockApplication.java index a7144ab9..4739d435 100644 --- a/app/src/main/java/app/drydock/DrydockApplication.java +++ b/app/src/main/java/app/drydock/DrydockApplication.java @@ -599,6 +599,14 @@ public CompletableFuture saveOpenChangedFilesInSkim(boolean value) { // over an hour. case "quit" -> diagQuit(primaryStage); case "shot" -> diagSnapshot(primaryStage, Path.of(arg)); + // Documented in this hook's comment since Task 18 + // and never implemented until now; it silently hit + // the default branch below, which prints "mark". + case "reviewkey" -> mainWorkspace.diagReviewKey(arg.strip()); + // Documented alongside reviewkey and unwired for + // just as long; ReviewDiffColumn.diagOpenComposer + // was already written and had no caller. + case "comment" -> mainWorkspace.diagComment(); // forcebanner:/, or // forcebanner:none for a session that never wrote // a brief, or forcebanner://dead for one @@ -688,7 +696,19 @@ public CompletableFuture saveOpenChangedFilesInSkim(boolean value) { } case "unwind" -> System.out.println("[diag] explorer unwind -> " + mainWorkspace.unwindExplorerOverlay()); - default -> System.out.println("[diag] mark " + arg); + // mark is a real verb, not a fallthrough. It used to + // BE the default, which is how an unwired verb -- + // reviewkey and comment were both documented from + // Task 18 and never wired -- printed a plausible + // beacon and did nothing. A driver could not tell a + // synchronisation marker from a verb that does not + // exist, so a run that did nothing looked like one + // that worked. + case "mark" -> System.out.println("[diag] mark " + arg); + default -> System.out.println( + "[diag] UNKNOWN explorerScript verb '" + verb + "'" + + " -- nothing was done. Add a case in" + + " DrydockApplication or fix the script."); } }); } @@ -1455,6 +1475,28 @@ private void diagTabStep(Stage stage, RepositorySidebar sidebar, String verb, St // See the explorerScript driver for why every script has this. case "quit" -> diagQuit(stage); case "shot" -> diagSnapshot(stage, Path.of(arg)); + // The Review board's out-of-diff fan-in popover, and a + // snapshot OF it: a Popup is its own window, so `shot` above + // photographs the board behind it rather than the popover. + case "fanin" -> System.out.println("[diag] fanin -> " + + mainWorkspace.diagOpenFanIn()); + case "popupshot" -> diagPopupSnapshot(Path.of(arg)); + // Opening AND photographing in one FX block, not two script + // steps: the popover sets autoHide, and a diag run's window + // is not the focused one, so it closes itself the moment the + // pulse that opened it ends. "no popup window is showing" is + // what a two-step script actually captures. + case "faninshot" -> { + // Focused FIRST. A Popup with autoHide closes itself the + // instant its owner window loses (or never had) focus, + // and a diag run's window is not the one the user is + // looking at -- so without this the popover is gone + // before the snapshot in the very same pulse. + stage.toFront(); + stage.requestFocus(); + System.out.println("[diag] fanin -> " + mainWorkspace.diagOpenFanIn()); + diagPopupSnapshot(Path.of(arg)); + } // DIAG-ONLY, added for the sidebar row-layout visual pass: the // row-overlay's hover fade and pickOnBounds=false passthrough // have no other observable hook (Node.hoverProperty is driven @@ -1504,7 +1546,13 @@ private void diagTabStep(Stage stage, RepositorySidebar sidebar, String verb, St // the code. Two rounds were lost to a plausible-but-wrong // theory that the picture had already contradicted. case "fadeinfo" -> diagFadeInfo(sidebar, arg); - default -> System.out.println("[diag] mark " + arg); + // See the explorerScript dispatcher: mark is a verb, and an + // unrecognised one has to say so rather than impersonate it. + case "mark" -> System.out.println("[diag] mark " + arg); + default -> System.out.println( + "[diag] UNKNOWN tabScript verb '" + verb + "'" + + " -- nothing was done. Add a case in" + + " DrydockApplication or fix the script."); } } catch (RuntimeException e) { System.out.println("[diag] tab step '" + verb + "' failed: " + e); @@ -1660,7 +1708,34 @@ private static void diagOpenNewWorktree(MainWorkspace mainWorkspace, AppShell ap } private static void diagSnapshot(Stage stage, Path target) { - WritableImage image = stage.getScene().snapshot(null); + diagSnapshotScene(stage.getScene(), target); + } + + /** + * Snapshots the topmost showing {@code Popup} instead of the primary + * stage. A popover is its own window: {@code Stage.getScene().snapshot} + * cannot see one at all, so without this a visual pass over the symbol + * lens or the out-of-diff fan-in popover would photograph the board + * BEHIND them and read as a clean result. + */ + private static void diagPopupSnapshot(Path target) { + javafx.stage.Window popup = javafx.stage.Window.getWindows().stream() + .filter(window -> window instanceof javafx.stage.PopupWindow && window.isShowing()) + .reduce((first, second) -> second) + .orElse(null); + if (popup == null || popup.getScene() == null) { + System.out.println("[diag] popupshot: no popup window is showing; windows=" + + javafx.stage.Window.getWindows().stream() + .map(window -> window.getClass().getSimpleName() + "(showing=" + + window.isShowing() + ",focused=" + window.isFocused() + ")") + .toList()); + return; + } + diagSnapshotScene(popup.getScene(), target); + } + + private static void diagSnapshotScene(javafx.scene.Scene scene, Path target) { + WritableImage image = scene.snapshot(null); int width = (int) image.getWidth(); int height = (int) image.getHeight(); // The snapshot is a fresh, detached copy that nothing else references diff --git a/app/src/main/java/app/drydock/git/GitStatusService.java b/app/src/main/java/app/drydock/git/GitStatusService.java index 432c99c0..383bc6cb 100644 --- a/app/src/main/java/app/drydock/git/GitStatusService.java +++ b/app/src/main/java/app/drydock/git/GitStatusService.java @@ -728,6 +728,44 @@ public Optional headCommitBlocking(Path workingDirectory) { return sha.isEmpty() ? Optional.empty() : Optional.of(sha); } + /** + * The commit {@code ref} names in {@code workingDirectory}, or empty when + * it names none -- a branch that does not exist here, a tag that was + * never fetched, or a directory that is not a repository. + * + *

Empty rather than throwing, for {@link #headCommitBlocking}'s + * reason: the caller is stamping or comparing metadata, and a base branch + * that cannot be resolved right now is an ordinary state of a fresh + * worktree, not a failure worth costing the caller its operation. What + * the caller must NOT do is fall back to the ref name -- a verdict + * recorded against {@code "main"} and compared against {@code "main"} + * would never read as stale, which is the inert no-op this method + * exists to end.

+ * + *

{@code --end-of-options} precedes the ref because a ref may begin + * with {@code -} and would otherwise be read as a flag. Blocking; never + * call on the FX thread.

+ */ + public Optional commitForRefBlocking(Path workingDirectory, String ref) { + Optional git = locator.locate(); + if (git.isEmpty() || ref == null || ref.isBlank()) { + return Optional.empty(); + } + ProcessResult result = run(List.of(git.get().toString(), "-C", workingDirectory.toString(), + "rev-parse", "--verify", "--end-of-options", ref + "^{commit}")); + if (result.exitCode() != 0) { + // Logged rather than folded silently into the empty result: an + // unresolvable base is what makes every verdict on the scope read + // as stale, and a reader asking why must be able to find out. + LOG.log(Level.WARNING, "git rev-parse --verify " + ref + " failed (exit " + + result.exitCode() + ") in " + workingDirectory + ": " + + ProcessRunner.excerpt(result.stderr())); + return Optional.empty(); + } + String sha = result.stdout().strip(); + return sha.isEmpty() ? Optional.empty() : Optional.of(sha); + } + /** Async form of {@link #headCommitBlocking}, on this service's background executor. */ public CompletableFuture> headCommit(Path workingDirectory) { return CompletableFuture.supplyAsync(() -> headCommitBlocking(workingDirectory), executor); diff --git a/app/src/main/java/app/drydock/mcp/McpSessionContext.java b/app/src/main/java/app/drydock/mcp/McpSessionContext.java index c53847e6..b9cdb1fd 100644 --- a/app/src/main/java/app/drydock/mcp/McpSessionContext.java +++ b/app/src/main/java/app/drydock/mcp/McpSessionContext.java @@ -3,6 +3,7 @@ import app.drydock.domain.HandoffBrief; import app.drydock.domain.ManagedSessionId; import app.drydock.git.UnifiedDiff; +import app.drydock.review.RecheckAssessment; import app.drydock.review.ReviewAnnotation; import app.drydock.review.ReviewIntent; import app.drydock.review.ReviewScope; @@ -93,15 +94,51 @@ Optional mutateAnnotation(ReviewAnnotation.Key key, /** Replaces a scope's intent grouping ({@code review_intents}). */ void putIntents(String scopeId, List intents); + /** + * {@code scopeId}'s intents over {@code diff}: the reviewer's grouping + * when {@link #putIntents} supplied one, otherwise the by-file fallback + * -- the same choice {@code SessionReviewView.Host#intents} makes for the + * UI. {@code review_state} joins this against {@link #verdictsOf} to + * report a verdict under the id an agent actually sent to {@code + * review_intents}, rather than whatever internal key a verdict happens + * to be stored under. + */ + List intentsOf(String scopeId, UnifiedDiff diff); + /** Upserts findings on {@code finding.id}, so a re-run keeps existing threads. */ void upsertFindings(List findings); /** Every finding of one scope, whatever its state. */ List findingsOf(String scopeId); - /** The verdicts recorded on one scope's intents. */ + /** The verdicts recorded on one scope's hunks (spec §9.2). */ List verdictsOf(String scopeId); + /** + * The commit {@code scope}'s base REF resolves to right now, empty when + * git cannot say. + * + *

A commit, never the ref name, for {@link ReviewVerdict#staleAgainst}'s + * reason: a verdict recorded against {@code "main"} and compared against + * {@code "main"} could never be stale. This is the {@code toBase} half of + * a {@link RecheckAssessment}'s key, so it has to be the very same string + * the board will later ask {@code assessedAffected} with, or the recheck + * is stored under a key nobody reads.

+ * + *

Empty rather than {@code SessionReviewView.UNRESOLVED_BASE}: the + * board needs a sentinel that reads as stale on a path it cannot fail, + * whereas {@code review_recheck} can simply refuse -- there is no base + * move to assess when the current base is not a commit.

+ */ + Optional currentReviewBase(ReviewScope scope); + + /** + * Records agent rechecks (spec §9.7). Decoded in full before anything is + * stored, like {@link #upsertFindings}: a batch with one bad entry writes + * nothing rather than half a recheck. + */ + void putAssessments(List assessments); + /** Whether the human has submitted this scope's review. */ boolean reviewSubmitted(String scopeId); diff --git a/app/src/main/java/app/drydock/mcp/McpToolRouter.java b/app/src/main/java/app/drydock/mcp/McpToolRouter.java index 3adcccff..6665dbe6 100644 --- a/app/src/main/java/app/drydock/mcp/McpToolRouter.java +++ b/app/src/main/java/app/drydock/mcp/McpToolRouter.java @@ -7,10 +7,19 @@ import app.drydock.mcp.McpSessionContext.RenameOutcome; import app.drydock.git.UnifiedDiff; import app.drydock.review.AnnotationStatus; +import app.drydock.review.ChangeGraph; +import app.drydock.review.IntentHunks; +import app.drydock.review.OutOfDiffFanIn; +import app.drydock.review.ReadingPath; +import app.drydock.review.RecheckAssessment; import app.drydock.review.ReviewAnnotation; import app.drydock.review.ReviewIntent; import app.drydock.review.ReviewScope; +import app.drydock.review.ReviewVerdict; +import app.drydock.review.Sections; import app.drydock.review.Severity; +import app.drydock.review.SymbolScan; +import app.drydock.review.VerdictMerge; import app.drydock.state.json.JsonValue; import app.drydock.state.json.JsonValue.JsonArray; import app.drydock.state.json.JsonValue.JsonBoolean; @@ -24,8 +33,12 @@ import java.nio.file.Path; import java.time.Instant; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import java.util.Optional; +import java.util.function.Function; import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Stream; @@ -58,10 +71,53 @@ public final class McpToolRouter { private final McpSessionContext context; private final McpSessionRegistry registry; + private final Function graphBuilder; + + /** + * One scope's computed grouping, keyed by the diff it was computed from. + * + *

Without it, every {@code review_scope} call that asks for {@code + * sections} rebuilds the whole {@link ChangeGraph} AND spawns a fresh + * full-worktree {@code git grep} -- so an agent polling during a review + * runs one 30s-bounded grep per poll, concurrently with the board's own. + * One duplicate scan across the UI/MCP boundary is the price of these + * two surfaces having no common owner; one per poll is not.

+ * + *

Keyed on the diff INSTANCE, the same identity test the board's + * graph cache uses: a re-read that produced a genuinely new diff gets a + * genuinely new grouping, and a repeated read of the same one does not + * pay twice. Concurrent because MCP calls arrive on the server's threads, + * not on one.

+ * + *

Unbounded, and blind to the worktree changing under a diff it has + * already grouped -- deliberately, because both are true of the board's + * own graph and fan-in caches too, and one entry per live scope with a + * new diff instance on every re-read is not a leak worth a second + * eviction policy. If that ever stops holding it stops holding in both + * places at once, which is the point of matching them.

+ */ + private final Map sectionsByScope = new ConcurrentHashMap<>(); + + /** One completed {@link #computeSections} result, keyed by what it was computed from. */ + private record SectionsCacheEntry(UnifiedDiff diff, List sections) { + } public McpToolRouter(McpSessionContext context, McpSessionRegistry registry) { + this(context, registry, ChangeGraph::of); + } + + /** + * Test seam: swaps how a scope's {@link ChangeGraph} is built (mirrors + * {@code GitStatusService}'s ssh-executable constructor). Package-private + * -- its only reason to exist is letting a test count builds, or fail + * them, without a mocking library; production callers always get the + * real, blocking {@link ChangeGraph#of}. + */ + McpToolRouter(McpSessionContext context, McpSessionRegistry registry, + Function graphBuilder) { this.context = context; this.registry = registry; + this.graphBuilder = graphBuilder; } public List toolDescriptors() { @@ -95,7 +151,10 @@ public List toolDescriptors() { .put("scopeId", schemaString("Review scope handle to read.")) .put("cursor", schemaString("Resume token from a previous page. Omit to start.")) .put("maxBytes", schemaString("Byte budget for this page; default " - + DEFAULT_SCOPE_BYTES + ".")), + + DEFAULT_SCOPE_BYTES + ".")) + .put("include", schemaString("Optional extras, comma-separated. " + + "\"sections\" returns drydock's computed grouping: " + + "accept and name it, or regroup deliberately.")), "scopeId"), descriptor("review_intents", "Replaces a scope's intent grouping: what the change is trying to do, at what risk, " @@ -103,8 +162,10 @@ public List toolDescriptors() { + "groups by file.", JsonObject.empty() .put("scopeId", schemaString("Review scope handle.")) - .put("intents", schemaString("Array of {id, title, kind, risk, rationale, " - + "hunkIds, collapse?, autoApprove?}.")), + .put("intents", schemaString("Array of {id, title, kind, risk, " + + "rationale, hunkIds, reads?, collapse?, autoApprove?}. " + + "reads names the intents this one is built on; drydock " + + "orders the rail by it and does not verify it.")), "scopeId", "intents"), descriptor("review_finding", "Records findings against a scope. Idempotent on finding id: a re-run upserts, so " @@ -133,6 +194,21 @@ public List toolDescriptors() { + "this before a re-run so settled findings are not re-flagged.", JsonObject.empty().put("scopeId", schemaString("Review scope handle.")), "scopeId"), + descriptor("review_recheck", + "Assesses whether a base move still leaves already-settled hunks valid. " + + "affected=true marks them stale; affected=false is ADVICE and " + + "never clears a human's verdict. Drydock derives which base " + + "move each hunk is being asked about -- the base its own verdict " + + "was recorded against, against the scope's base now -- so a hunk " + + "with no verdict has nothing to recheck and is refused.", + JsonObject.empty() + .put("scopeId", schemaString("Review scope handle.")) + .put("assessments", schemaString("Array of {hunkId, affected, why}. " + + "hunkId is a hunk id from review_scope; affected is a " + + "real boolean, not \"true\"; why is REQUIRED whenever " + + "affected is true -- it is the reason a human is shown " + + "for re-reading the hunk.")), + "scopeId", "assessments"), descriptor("worktree_create", "Creates a worktree in the caller's repository: a new branch by default, or a " + "checkout of a branch that already exists when 'existing' is true. An existing " @@ -197,6 +273,7 @@ public JsonValue call(ManagedSessionId caller, String tool, JsonValue arguments) case "review_finding" -> reviewFinding(caller, arguments); case "review_answer" -> reviewAnswer(caller, arguments); case "review_state" -> reviewState(caller, arguments); + case "review_recheck" -> reviewRecheck(caller, arguments); case "worktree_create" -> worktreeCreate(caller, arguments); case "session_start" -> sessionStart(caller, arguments); case "session_rename" -> sessionRename(caller, arguments); @@ -257,9 +334,26 @@ private JsonValue reviewScope(ManagedSessionId caller, JsonValue arguments) thro int maxBytes = Math.clamp(optionalIntArg(args, "maxBytes", DEFAULT_SCOPE_BYTES), 1_000, MAX_SCOPE_BYTES); + Optional cursor = optionalStringArg(args, "cursor"); UnifiedDiff diff = context.reviewDiff(scope); - ReviewToolCodec.ScopePage page = ReviewToolCodec.pageHunks(diff, - optionalStringArg(args, "cursor"), maxBytes); + + // Computed only on the FIRST page of a read (cursor absent), and only + // when asked: ChangeGraph.of parses every changed file and can trigger + // a first-time native grammar load, so it must never be a cost a plain + // review_scope call pays, and a multi-page read must not pay it again + // on every page for a payload that would not have changed anyway. + Optional sectionsJson = cursor.isEmpty() && includesSections(args) + ? computeSections(scope, diff) + : Optional.empty(); + // Charged against the SAME budget as hunks, not on top of it: sections + // overlap by design (a shared foundation file appears in every section + // that needs it), so their payload scales as sections x shared files, + // not by file count the way scope/files/priorThreads do -- an + // unaccounted addition here could dwarf a small maxBytes with no + // signal at all. + int sectionsBytes = sectionsJson.map(ReviewToolCodec::approximateBytes).orElse(0); + int hunkBudget = Math.max(0, maxBytes - sectionsBytes); + ReviewToolCodec.ScopePage page = ReviewToolCodec.pageHunks(diff, cursor, hunkBudget); JsonObject result = JsonObject.empty() .put("scope", ReviewToolCodec.scopeToJson(scope)) @@ -276,9 +370,82 @@ private JsonValue reviewScope(ManagedSessionId caller, JsonValue arguments) thro result.put("priorThreads", new JsonArray(context.findingsOf(scope.id()).stream() .map(ReviewToolCodec::findingStateToJson) .toList())); + + if (sectionsJson.isPresent()) { + result.put("sections", sectionsJson.get()); + // The grouping is never truncated mid-array -- that would hand an + // agent a lie it could act on -- so when it alone is bigger than + // the whole budget, the overage is reported rather than hidden: + // a caller that asked for this explicitly gets all of it, plus a + // signal that maxBytes was not honoured, instead of a silently + // blown budget. + if (sectionsBytes > maxBytes) { + result.put("sectionsOverBudget", new JsonBoolean(true)); + } + } return result; } + /** + * {@code sections}, or empty if none was requested or the graph could not + * be built. {@link ChangeGraph#of} (via {@link SymbolScan}) can throw + * unchecked on a parse edge case; that must cost this ONE optional extra, + * never the whole call -- a caller who merely opted into {@code sections} + * must still get {@code hunks}, {@code scope} and {@code files}. + * + *

A live surface (the Review board's rail) numbers its cards off the + * reading path's order, not {@link Sections#of}'s own grouping order -- + * see {@link ReadingPath.Path#sections()}. An agent reading {@code + * sections} off the plain grouping would disagree with the human looking + * at the same review over which card is ①, so this reorders the SAME + * sections through {@link ReadingPath#of} before handing them out, + * exactly as the rail does -- fan-in scan included, so the two agree on + * the rank's first term as well as on the ordering.

+ * + *

Scanned synchronously, unlike the board's own background scan: + * an MCP tool call already runs off the FX thread, {@link + * OutOfDiffFanIn#scan} bounds itself with a 30s timeout, and handing an + * agent a first-call-always-unavailable answer it will then act on is + * worse than making it wait.

+ */ + private Optional computeSections(ReviewScope scope, UnifiedDiff diff) { + SectionsCacheEntry cached = sectionsByScope.get(scope.id()); + if (cached != null && cached.diff() == diff) { + return Optional.of(ReviewToolCodec.sectionsToJson(cached.sections())); + } + try { + ChangeGraph graph = graphBuilder.apply(diff); + List sections = Sections.of(diff, graph); + ReadingPath.Path path = ReadingPath.of(diff, graph, sections, + OutOfDiffFanIn.forScope(scope, graph, diff)); + // Cached as the ordered sections rather than as the rendered + // JSON: the response is assembled per call (a later page adds + // its own keys to it), and handing every caller the same mutable + // object is a defect waiting for the first one that edits it. + // Only a SUCCESSFUL build is cached -- a parse edge case must + // stay retryable rather than being pinned as this scope's answer. + sectionsByScope.put(scope.id(), new SectionsCacheEntry(diff, path.sections())); + return Optional.of(ReviewToolCodec.sectionsToJson(path.sections())); + } catch (RuntimeException e) { + LOG.log(Level.WARNING, "review_scope: could not compute sections for scope " + + scope.id() + "; omitting: " + e.getMessage(), e); + return Optional.empty(); + } + } + + /** + * Whether the comma-separated {@code include} argument names {@code + * sections}. An unknown token, or a missing/blank argument, is silently + * false -- this is an optional read, and a typo must not fail the call. + */ + private static boolean includesSections(JsonObject args) throws McpToolException { + return optionalStringArg(args, "include") + .map(value -> Stream.of(value.split(",")) + .map(String::strip) + .anyMatch("sections"::equals)) + .orElse(false); + } + private JsonValue reviewIntents(ManagedSessionId caller, JsonValue arguments) throws McpToolException { requireLiveSession(caller); JsonObject args = asObject(arguments); @@ -319,6 +486,57 @@ private JsonValue reviewFinding(ManagedSessionId caller, JsonValue arguments) th .put("findings", JsonNumber.of(decoded.size())); } + /** + * {@code review_recheck}: the agent's answer to the one question neither + * a hunk digest nor {@link app.drydock.review.BaseMove}'s intersection + * can reach (spec §9.7). + * + *

The relevance filter is file-level and lexical and names its own + * blind spot: a base change that alters behaviour without touching a file + * this scope references is invisible to it. An agent has no such + * boundary, so it can close that gap -- but only in one direction. + * {@code affected == true} adds staleness, which costs at worst a wasted + * re-read. {@code affected == false} is advice and clears nothing, + * because an agent wrong THAT way would leave a human's approval standing + * over code nobody re-read. Nothing in this method or below it touches a + * verdict, which is what makes that true by construction rather than by + * every reader remembering it.

+ * + *

Which base move is being assessed is drydock's to say, not the + * agent's: {@code fromBase} comes from each hunk's own verdict and {@code + * toBase} from the scope's current base, so the key written here is the + * key the board reads with. See {@link + * ReviewToolCodec#assessmentsFromJson}, which also owns the hunkId -> + * digest translation and the three refusals.

+ */ + private JsonValue reviewRecheck(ManagedSessionId caller, JsonValue arguments) throws McpToolException { + requireLiveSession(caller); + JsonObject args = asObject(arguments); + ReviewScope scope = requireScope(caller, args); + + String toBase = context.currentReviewBase(scope).orElseThrow(() -> new McpToolException( + "The base of scope '" + scope.id() + "' does not resolve to a commit right now, so " + + "there is no base move to assess. Nothing was recorded.")); + Map verdictsByDigest = new LinkedHashMap<>(); + for (ReviewVerdict verdict : context.verdictsOf(scope.id())) { + verdictsByDigest.put(verdict.hunkDigest(), verdict); + } + List decoded = ReviewToolCodec.assessmentsFromJson(scope.id(), + args.get("assessments"), context.reviewDiff(scope), verdictsByDigest, toBase, + Instant.now()); + // Decoded in full before anything is stored, like review_finding: a + // batch with one bad entry writes nothing rather than half a recheck. + context.putAssessments(decoded); + return JsonObject.empty() + .put("scopeId", new JsonString(scope.id())) + .put("assessments", JsonNumber.of(decoded.size())) + // Echoed because it is the only half with an effect: an agent + // that sent ten and marked none has changed nothing, and + // saying so is cheaper than letting it believe otherwise. + .put("markedStale", JsonNumber.of( + (int) decoded.stream().filter(RecheckAssessment::affected).count())); + } + /** * {@code review_answer}: appends the agent's reply to a thread. The * {@code propose*} fields are suggestions -- they are recorded in the @@ -355,24 +573,76 @@ private JsonValue reviewAnswer(ManagedSessionId caller, JsonValue arguments) thr .put("messages", JsonNumber.of(updated.thread().size())); } + /** + * The wire {@code id} here is intent-keyed, not hunk-keyed: an agent + * correlates it against the ids it sent to {@code review_intents}, so + * this joins the scope's intents against their verdicts rather than + * reporting {@link ReviewVerdict#hunkDigest()} straight through -- a + * verdict's own storage key must not leak onto this wire, or the join + * silently breaks the moment that key stops being intent-shaped. + * + *

The join needs a diff (to know the scope's current intents), but + * findings and submission status do not -- so a scope whose diff cannot + * be produced (a PR with no local checkout, or a git failure) still + * reports those two. The {@code intents} key is omitted entirely rather + * than emitted empty in that case: an empty array reads as "nothing is + * settled", a false claim, whereas an absent key correctly says "cannot + * be known right now" (the same absent-vs-zero rule the sidebar's + * {@code ◨n} badge follows).

+ * + *

The join is many-to-one: a verdict is keyed by a hunk's content + * digest, and an intent covers several hunks, so what is reported is what + * {@link VerdictMerge} makes of them -- never a single stored verdict.

+ */ private JsonValue reviewState(ManagedSessionId caller, JsonValue arguments) throws McpToolException { requireLiveSession(caller); JsonObject args = asObject(arguments); ReviewScope scope = requireScope(caller, args); - List intents = context.verdictsOf(scope.id()).stream() - .map(verdict -> (JsonValue) JsonObject.empty() - .put("id", new JsonString(verdict.intentId())) - .put("verdict", new JsonString(verdict.decision().wireName())) - .put("note", verdict.note() - .map(JsonString::new).orElse(JsonNull.INSTANCE))) - .toList(); - return JsonObject.empty() - .put("intents", new JsonArray(intents)) - .put("findings", new JsonArray(context.findingsOf(scope.id()).stream() + JsonObject result = JsonObject.empty(); + try { + result.put("intents", new JsonArray(intentsStateToJson(scope))); + } catch (McpToolException e) { + LOG.log(Level.WARNING, "review_state: could not compute a diff for scope " + + scope.id() + "; omitting intents: " + e.getMessage()); + } + result.put("findings", new JsonArray(context.findingsOf(scope.id()).stream() .map(ReviewToolCodec::findingStateToJson) .toList())) .put("submitted", new JsonBoolean(context.reviewSubmitted(scope.id()))); + return result; + } + + /** The scope's intents joined against their verdicts, as {@code review_state} reports them. */ + private List intentsStateToJson(ReviewScope scope) throws McpToolException { + Map verdictsByDigest = new LinkedHashMap<>(); + for (ReviewVerdict verdict : context.verdictsOf(scope.id())) { + verdictsByDigest.put(verdict.hunkDigest(), verdict); + } + UnifiedDiff diff = context.reviewDiff(scope); + List intents = new ArrayList<>(); + for (ReviewIntent intent : context.intentsOf(scope.id(), diff)) { + List> perHunk = IntentHunks.digestsOf(intent, diff).stream() + .map(digest -> Optional.ofNullable(verdictsByDigest.get(digest))) + .toList(); + Optional decision = VerdictMerge.derive(perHunk); + if (decision.isEmpty()) { + continue; + } + // The first note any of the section's hunks carries. A section has + // no note of its own -- notes are written against hunks -- and + // concatenating several would report text nobody wrote. + Optional note = perHunk.stream() + .flatMap(Optional::stream) + .map(ReviewVerdict::note) + .flatMap(Optional::stream) + .findFirst(); + intents.add(JsonObject.empty() + .put("id", new JsonString(intent.id())) + .put("verdict", new JsonString(decision.get().wireName())) + .put("note", note.map(JsonString::new).orElse(JsonNull.INSTANCE))); + } + return intents; } // ---- review_comments ----------------------------------------------- diff --git a/app/src/main/java/app/drydock/mcp/ReviewToolCodec.java b/app/src/main/java/app/drydock/mcp/ReviewToolCodec.java index 19e7edf3..88401ca1 100644 --- a/app/src/main/java/app/drydock/mcp/ReviewToolCodec.java +++ b/app/src/main/java/app/drydock/mcp/ReviewToolCodec.java @@ -3,9 +3,13 @@ import app.drydock.git.UnifiedDiff; import app.drydock.review.AnnotationStatus; import app.drydock.review.Confidence; +import app.drydock.review.HunkDigest; +import app.drydock.review.RecheckAssessment; import app.drydock.review.ReviewAnnotation; import app.drydock.review.ReviewIntent; import app.drydock.review.ReviewScope; +import app.drydock.review.ReviewVerdict; +import app.drydock.review.Sections; import app.drydock.review.Severity; import app.drydock.state.json.JsonValue; import app.drydock.state.json.JsonValue.JsonArray; @@ -16,8 +20,11 @@ import java.time.Instant; import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.Set; /** * Encodes and decodes the Review MCP payloads (schema §§1-4), keeping the @@ -217,14 +224,37 @@ private static int firstNew(List lines) { } /** - * Byte cost of an encoded hunk, measured on its own serialization rather + * Byte cost of an encoded value, measured on its own serialization rather * than estimated: the budget exists to keep a response under a hard limit, * and an estimate that drifts would either waste the budget or blow it. + * Package-private so {@link McpToolRouter} can charge the {@code + * sections} include against the same budget {@code hunks} pays from. */ - private static int approximateBytes(JsonValue value) { + static int approximateBytes(JsonValue value) { return app.drydock.state.json.JsonWriter.write(value).getBytes(java.nio.charset.StandardCharsets.UTF_8).length; } + /** + * drydock's computed grouping ({@code review_scope}'s {@code sections} + * include), offered so an agent can accept-and-name it rather than + * regroup from scratch and lose the header conventions and the + * dependency order {@link Sections#of} already worked out. + */ + static JsonValue sectionsToJson(List sections) { + List entries = new ArrayList<>(); + for (Sections.Section section : sections) { + JsonObject obj = JsonObject.empty(); + obj.put("title", new JsonString(section.title())); + obj.put("files", new JsonArray(section.files().stream() + .map(file -> (JsonValue) new JsonString(file)).toList())); + obj.put("hunkIds", new JsonArray(section.hunkIds().stream() + .map(id -> (JsonValue) new JsonString(id)).toList())); + section.hubSymbol().ifPresent(hub -> obj.put("hubSymbol", new JsonString(hub))); + entries.add(obj); + } + return new JsonArray(entries); + } + // ---- review_intents (agent -> drydock) ---------------------------------- static List intentsFromJson(JsonValue value) throws McpToolException { @@ -232,6 +262,10 @@ static List intentsFromJson(JsonValue value) throws McpToolExcepti throw new McpToolException("intents must be an array"); } List intents = new ArrayList<>(); + // Provisional only: IntentGrouping.set re-assigns a dense 1..N over + // the order `reads` produces and discards whatever arrives here, so + // this numbering never reaches a rail. It is kept because a + // ReviewIntent has to carry SOME number to be constructed at all. int number = 1; for (JsonValue element : array.elements()) { if (!(element instanceof JsonObject obj)) { @@ -248,11 +282,86 @@ static List intentsFromJson(JsonValue value) throws McpToolExcepti "intent.rationale"), stringList(obj, "hunkIds"), collapseFromJson(obj), - obj.get("autoApprove") instanceof JsonBoolean auto && auto.value())); + obj.get("autoApprove") instanceof JsonBoolean auto && auto.value(), + readsFromJson(obj, id))); } + checkReadsResolve(intents); return List.copyOf(intents); } + /** + * One intent's {@code reads}, rejecting a malformed one rather than + * quietly reading it as an empty list. + * + *

Decoded here and not through {@link #stringList}, which answers + * {@code List.of()} for any non-array and drops any non-string element. + * That lenience predates this task and is shared with {@code hunkIds}, + * where a dropped entry costs at worst one hunk's membership in a group + * a human can see and fix. It costs far more here: {@code + * "reads":"the-guard"} -- one dependency written without the brackets, + * which is the likeliest way to get this wrong -- would decode as + * "declared nothing", and the rail would then render the exact REVERSE of + * the order the agent asserted. With no diagnostic on any surface, and + * {@code reads} echoed on no outbound wire, the agent could not discover + * it had happened. Absent and broken must not look the same -- the same + * rule {@link app.drydock.review.Graphs#topologicalOrder} keeps for an + * edge pointing outside its nodes, and the reason {@link + * #checkReadsResolve} exists at all.

+ * + *

An explicit {@code null} is absent, not broken: it is how several + * clients spell an omitted optional field.

+ */ + private static List readsFromJson(JsonObject obj, String id) throws McpToolException { + JsonValue raw = obj.get("reads"); + if (raw == null || raw instanceof JsonValue.JsonNull) { + return List.of(); + } + if (!(raw instanceof JsonArray array)) { + throw new McpToolException("intent '" + id + "' has a reads that is not an array; " + + "one dependency is [\"other-id\"], not \"other-id\""); + } + List reads = new ArrayList<>(); + for (JsonValue element : array.elements()) { + if (!(element instanceof JsonString read)) { + throw new McpToolException("intent '" + id + "' has a reads entry that is not a " + + "string; every entry names an intent id in this call"); + } + reads.add(read.value()); + } + return List.copyOf(reads); + } + + /** + * Rejects the whole batch when a {@code reads} names an id no intent in + * the same call carries. + * + *

Checked HERE, at decode, and not where the order is actually built: + * {@link app.drydock.review.Graphs#topologicalOrder} does refuse an edge + * pointing outside its nodes -- deliberately, so absent and broken cannot + * look the same -- but it refuses with an {@link IllegalArgumentException} + * on whatever thread {@code IntentGrouping.set} was called from, where + * the agent that sent the payload never hears about it. An MCP error + * naming the id and the intent that declared it is the report the agent + * can act on.

+ * + *

All-or-nothing, like the rest of the batch: half a grouping, with + * some intents' declared order silently dropped, is worse than none.

+ */ + private static void checkReadsResolve(List intents) throws McpToolException { + Set ids = new LinkedHashSet<>(); + for (ReviewIntent intent : intents) { + ids.add(intent.id()); + } + for (ReviewIntent intent : intents) { + for (String read : intent.reads()) { + if (!ids.contains(read)) { + throw new McpToolException("intent '" + intent.id() + "' reads '" + read + + "', which is not an intent in this call"); + } + } + } + } + private static Optional collapseFromJson(JsonObject obj) throws McpToolException { if (!(obj.get("collapse") instanceof JsonObject collapse)) { @@ -266,6 +375,142 @@ private static Optional collapseFromJson(JsonObject obj) collapse.get("fileCount") instanceof JsonNumber count ? count.asInt() : 0)); } + // ---- review_recheck (agent -> drydock) ---------------------------------- + + /** + * Decodes {@code review_recheck}'s {@code assessments} array, translating + * each wire {@code hunkId} into the content digest a verdict is actually + * keyed by (spec §9.7). + * + *

The two ids are different things. {@link + * ReviewIntent#hunkId} is POSITIONAL -- a file and an index into that + * file's hunks -- and is what an agent reads off {@code review_scope}. + * {@link HunkDigest#of} is CONTENT-ADDRESSED and deliberately excludes + * line numbers, so a hunk that merely moved keeps its digest. Storing the + * positional id would strand every assessment the moment the diff + * re-hunked; this walks the diff the same way {@code IntentHunks.digestsOf} + * does and stores the digest.

+ * + *

The base PAIR is derived, never taken from the wire. {@code fromBase} + * is the base the hunk's own verdict was recorded against and {@code + * toBase} is the scope's current base commit, so the key this writes is + * by construction the key the board later reads with. An agent-supplied + * pair could name commits no verdict was ever judged against, and the + * recheck would then sit in the store answering a question nobody asks -- + * absent and broken looking the same again.

+ * + *

Five things reject the whole batch, each naming the offending id: a + * {@code hunkId} that resolves to nothing in the current diff; a hunk that + * carries no verdict at all, which has no {@code fromBase} and therefore + * nothing to recheck; a mark with no {@code why}, which is the reflexive + * signal this tool is asymmetric to avoid; a {@code why} that fails {@link + * PromptSafety}; and an {@code affected} or {@code why} of the wrong JSON + * type, which must not decode as "said nothing" (see {@link + * #affectedFromJson}). All-or-nothing like the rest of this surface: a + * silently skipped entry is an agent's recheck that the human believes + * happened and did not.

+ */ + static List assessmentsFromJson(String scopeId, JsonValue value, UnifiedDiff diff, + Map verdictsByDigest, + String toBase, Instant at) + throws McpToolException { + if (!(value instanceof JsonArray array)) { + throw new McpToolException("assessments must be an array"); + } + List assessments = new ArrayList<>(); + for (JsonValue element : array.elements()) { + if (!(element instanceof JsonObject obj)) { + throw new McpToolException("each assessment must be an object"); + } + String hunkId = requireString(obj, "hunkId"); + String digest = digestOfHunkId(diff, hunkId).orElseThrow(() -> new McpToolException( + "assessment names hunkId '" + hunkId + "', which is not a hunk of this scope's " + + "current diff; hunk ids are the ones review_scope reports and are " + + "positional, so a re-diff can strand them")); + ReviewVerdict verdict = verdictsByDigest.get(digest); + if (verdict == null) { + throw new McpToolException("assessment names hunkId '" + hunkId + "', which carries " + + "no verdict; a recheck says whether a base move undermines a decision, " + + "and there is no decision on that hunk to undermine"); + } + boolean affected = affectedFromJson(obj, hunkId); + String why = PromptSafety.checkInboundText(whyFromJson(obj, hunkId), "assessment.why"); + if (affected && why.isBlank()) { + throw new McpToolException("assessment marks hunkId '" + hunkId + "' affected with " + + "no why; a staleness signal asserted with no reason is the reflexive " + + "click this recheck is asymmetric to avoid, and a human will be shown " + + "the reason as the whole justification for re-reading the hunk"); + } + assessments.add(new RecheckAssessment(scopeId, digest, verdict.baseCommit(), toBase, + affected, why, at)); + } + return List.copyOf(assessments); + } + + /** + * One assessment's {@code affected}, refusing anything that is not a + * boolean rather than quietly reading it as {@code false}. + * + *

The same rule {@link #readsFromJson} keeps, and for the same reason: + * absent and broken must not look the same. {@code "affected":"true"} from + * a stringifying client -- not hypothetical, {@code + * McpToolRouter.optionalIntArg} exists to accommodate one -- would + * otherwise decode as "the agent looked and found nothing", which is the + * one answer this tool must never manufacture. The direction is inert, so + * nothing unsafe follows; what follows is a recheck the human believes + * happened and did not, which is the failure this whole surface is drawn + * around.

+ * + *

Absent, and an explicit {@code null}, stay ABSENT and decode as + * {@code false}: an assessment that says nothing about a hunk is a legal + * thing to send, and {@code null} is how several clients spell an omitted + * optional field.

+ */ + private static boolean affectedFromJson(JsonObject obj, String hunkId) throws McpToolException { + JsonValue raw = obj.get("affected"); + if (raw == null || raw instanceof JsonValue.JsonNull) { + return false; + } + if (!(raw instanceof JsonBoolean flag)) { + throw new McpToolException("assessment for hunkId '" + hunkId + "' has an affected that " + + "is not a boolean; it is true or false, not \"true\" or 1"); + } + return flag.value(); + } + + /** One assessment's {@code why}, refusing a non-string for {@link #affectedFromJson}'s reason. */ + private static String whyFromJson(JsonObject obj, String hunkId) throws McpToolException { + JsonValue raw = obj.get("why"); + if (raw == null || raw instanceof JsonValue.JsonNull) { + return ""; + } + if (!(raw instanceof JsonString why)) { + throw new McpToolException("assessment for hunkId '" + hunkId + "' has a why that is " + + "not a string; it is the sentence a human reads as the reason"); + } + return why.value(); + } + + /** + * The content digest of the hunk {@code hunkId} names in {@code diff}, or + * empty when it names no hunk there -- an unknown file, or an index past + * that file's hunk count. + */ + private static Optional digestOfHunkId(UnifiedDiff diff, String hunkId) { + return ReviewIntent.parseHunkId(hunkId).flatMap(anchor -> { + for (UnifiedDiff.FileDiff file : diff.files()) { + if (!file.path().equals(anchor.file())) { + continue; + } + List hunks = file.hunks(); + return anchor.hunkIndex() < hunks.size() + ? Optional.of(HunkDigest.of(file.path(), hunks.get(anchor.hunkIndex()))) + : Optional.empty(); + } + return Optional.empty(); + }); + } + // ---- review_finding (agent -> drydock) ---------------------------------- /** diff --git a/app/src/main/java/app/drydock/mcp/WorkspaceMcpSessionContext.java b/app/src/main/java/app/drydock/mcp/WorkspaceMcpSessionContext.java index a951f474..034d3acd 100644 --- a/app/src/main/java/app/drydock/mcp/WorkspaceMcpSessionContext.java +++ b/app/src/main/java/app/drydock/mcp/WorkspaceMcpSessionContext.java @@ -12,6 +12,7 @@ import app.drydock.git.BranchRef; import app.drydock.git.GitBranchState; import app.drydock.git.GitCommandFailedException; +import app.drydock.git.GitException; import app.drydock.git.GitExecutableNotFoundException; import app.drydock.git.GitStatus; import app.drydock.git.GitStatusService; @@ -22,6 +23,7 @@ import app.drydock.git.WorktreeService; import app.drydock.git.WorktreeService.Worktree; import app.drydock.review.AnnotationStore; +import app.drydock.review.RecheckAssessment; import app.drydock.git.DiffScope; import app.drydock.git.DiffService; import app.drydock.git.UnifiedDiff; @@ -53,6 +55,8 @@ import java.util.function.BiFunction; import java.util.function.Supplier; import java.util.function.UnaryOperator; +import java.util.logging.Level; +import java.util.logging.Logger; /** * The production {@link McpSessionContext}: the running workspace's answer to @@ -72,6 +76,8 @@ */ public final class WorkspaceMcpSessionContext implements McpSessionContext { + private static final Logger LOG = Logger.getLogger(WorkspaceMcpSessionContext.class.getName()); + /** * Bound on every wait. Generous enough for a cold {@code git} spawn on a * large repository, short enough that a hung app answers the agent with a @@ -324,6 +330,11 @@ public void putIntents(String scopeId, List intents) { intentGrouping.set(scopeId, intents); } + @Override + public List intentsOf(String scopeId, UnifiedDiff diff) { + return intentGrouping.intentsFor(scopeId, diff); + } + @Override public void upsertFindings(List findings) { findings.forEach(annotationStore::upsert); @@ -340,6 +351,29 @@ public List verdictsOf(String scopeId) { return annotationStore.verdictsFor(scopeId); } + /** + * Resolved inline on the request thread, unlike the board's cached + * baseline: this runs off the FX thread already, and handing {@code + * review_recheck} a first-call-always-unresolved answer -- which it would + * refuse on -- is worse than one {@code git rev-parse}. + */ + @Override + public Optional currentReviewBase(ReviewScope scope) { + try { + return gitStatusService.commitForRefBlocking(scope.diffRoot(), scope.base()); + } catch (GitException e) { + LOG.log(Level.WARNING, "Could not resolve the base of review scope " + scope.id() + + ": " + e.getMessage()); + return Optional.empty(); + } + } + + @Override + public void putAssessments(List assessments) { + assessments.forEach(annotationStore::putAssessment); + annotationStore.flushPendingSaves(); + } + @Override public boolean reviewSubmitted(String scopeId) { return annotationStore.isSubmitted(scopeId); diff --git a/app/src/main/java/app/drydock/review/AnnotationStore.java b/app/src/main/java/app/drydock/review/AnnotationStore.java index 7c83de03..0c9196b4 100644 --- a/app/src/main/java/app/drydock/review/AnnotationStore.java +++ b/app/src/main/java/app/drydock/review/AnnotationStore.java @@ -69,10 +69,17 @@ public final class AnnotationStore implements AutoCloseable { /** * 1 keyed findings by {@code (sessionId, DiffScope)}; 2 keys them by * scope handle; 3 adds the secret used to derive restart-stable scope - * handles. A v1 file is migrated on read rather than dropped -- see - * {@link #legacyScopeId}. + * handles; 4 re-keys verdicts from {@code intentId} onto a hunk content + * digest, carrying {@code base}/{@code head}; 5 adds the agent recheck + * assessments of spec §9.7. A v1 file is migrated on read rather than + * dropped -- see {@link #legacyScopeId}. A v3 verdict entry has no digest + * to migrate to (none were recorded in the wild) and is skipped by the + * existing lenient decode. A v4 file needs no migration at all: {@link + * #loadFromDisk} reads each named array independently, so one simply has + * no {@code assessments} key and loads with none -- which {@code + * AnnotationStoreTest} pins rather than assumes. */ - private static final int SCHEMA_VERSION = 3; + private static final int SCHEMA_VERSION = 5; private static final SecureRandom RANDOM = new SecureRandom(); /** @@ -94,9 +101,19 @@ static String legacyScopeId(ManagedSessionId sessionId, DiffScope scope) { /** Findings by their composite key, in insertion order (the margin renders in this order). */ private final Map findings = new LinkedHashMap<>(); - /** Verdicts by {@code (scopeId, intentId)}. */ + /** Verdicts by {@code (scopeId, hunkDigest)}. */ private final Map verdicts = new LinkedHashMap<>(); + /** + * Agent rechecks by {@code (scopeId, hunkDigest, fromBase, toBase)} + * (spec §9.7), in insertion order. + * + *

Keyed by the base PAIR, not by the hunk: a later base move is a new + * question, and an old answer carried forward would be the agent + * answering something it was never asked.

+ */ + private final Map assessments = new LinkedHashMap<>(); + /** Scopes whose review has been submitted. */ private final List submitted = new ArrayList<>(); @@ -118,7 +135,8 @@ static String legacyScopeId(ManagedSessionId sessionId, DiffScope scope) { private final List> changeListeners = new CopyOnWriteArrayList<>(); private record Snapshot(List findings, List verdicts, - List submitted, String scopeIdSecret) { + List assessments, List submitted, + String scopeIdSecret) { } public AnnotationStore(Path file) { @@ -175,14 +193,50 @@ public synchronized boolean hasOpenBlockingFinding(String scopeId, String intent .anyMatch(ReviewAnnotation::blocksApproval); } - public synchronized Optional verdict(String scopeId, String intentId) { - return Optional.ofNullable(verdicts.get(new ReviewVerdict.Key(scopeId, intentId))); + public synchronized Optional verdict(String scopeId, String hunkDigest) { + return Optional.ofNullable(verdicts.get(new ReviewVerdict.Key(scopeId, hunkDigest))); } public synchronized List verdictsFor(String scopeId) { return verdicts.values().stream().filter(v -> v.scopeId().equals(scopeId)).toList(); } + /** + * Whether the agent said this base move affects this hunk (spec §9.7). + * + *

False for an assessment that said "unaffected" AND for no + * assessment at all, deliberately: the two are the same to every reader, + * because neither may clear anything. Only {@code true} is actionable, + * and it can only ever ADD staleness.

+ */ + public synchronized boolean assessedAffected(String scopeId, String hunkDigest, + String fromBase, String toBase) { + RecheckAssessment found = assessments.get( + new RecheckAssessment.Key(scopeId, hunkDigest, fromBase, toBase)); + return found != null && found.affected(); + } + + /** + * Whether any assessment at all was recorded for this base pair. + * + *

Distinct from {@link #assessedAffected}, which cannot tell "the + * agent said unaffected" from "the agent was never asked" -- that + * conflation is deliberate there, because only {@code true} may add + * staleness. Dispatch needs the other question, and only this method + * answers it.

+ */ + public synchronized boolean assessedMove(String scopeId, String fromBase, String toBase) { + return assessments.values().stream() + .anyMatch(a -> a.scopeId().equals(scopeId) + && a.fromBase().equals(fromBase) + && a.toBase().equals(toBase)); + } + + /** Every recheck recorded against one scope, in the order they arrived. */ + public synchronized List assessmentsFor(String scopeId) { + return assessments.values().stream().filter(a -> a.scopeId().equals(scopeId)).toList(); + } + public synchronized boolean isSubmitted(String scopeId) { return submitted.contains(scopeId); } @@ -291,6 +345,7 @@ public void removeScope(String scopeId) { private synchronized boolean removeScopeInternal(String scopeId) { boolean changed = findings.keySet().removeIf(key -> key.scopeId().equals(scopeId)); changed |= verdicts.keySet().removeIf(key -> key.scopeId().equals(scopeId)); + changed |= assessments.keySet().removeIf(key -> key.scopeId().equals(scopeId)); changed |= submitted.remove(scopeId); if (changed) { persistAsync(); @@ -298,7 +353,7 @@ private synchronized boolean removeScopeInternal(String scopeId) { return changed; } - /** Records a per-intent verdict, replacing any previous one. */ + /** Records a per-hunk verdict, replacing any previous one. */ public void putVerdict(ReviewVerdict verdict) { putVerdictInternal(verdict); fireChanged(null); @@ -309,135 +364,39 @@ private synchronized void putVerdictInternal(ReviewVerdict verdict) { persistAsync(); } - /** - * The id scheme the by-file fallback grouping used before files were - * clustered by directory. Verdicts recorded under it are migrated onto - * the intent that now contains those files; see - * {@link #migrateLegacyVerdicts}. - */ - private static final String LEGACY_FILE_INTENT_PREFIX = "file:"; - - /** - * Carries verdicts recorded under the old {@code file:} intent ids - * onto {@code intents}, and returns how many were carried. - * - *

Verdicts are persisted by intent id, and the fallback grouping's ids - * changed when it stopped emitting one intent per file. Without this, - * every approval given before that change would read as unsettled and a - * finished review would ask to be done again.

- * - *

The merge is deliberately asymmetric, because the two directions - * carry different risk:

- *
    - *
  • Any {@code CHANGES} among the group's files makes the group - * {@code CHANGES}. "Something in here needs work" stays true of a - * group however it is drawn.
  • - *
  • {@code APPROVED} needs EVERY file of the group to have been - * settled. Approving a group is a claim that the human read all of - * it, so a partially-approved group carries nothing forward and is - * re-settled by hand. Silently approving code nobody looked at is - * the one outcome this must never produce.
  • - *
- * - *

A partial group's legacy verdicts are left in place rather than - * deleted -- they record something the human really did decide, and the - * grouping may change again. Idempotent, and safe to call on every diff - * that lands: once a group is migrated its legacy keys are gone, and a - * verdict already recorded under a new id is never overwritten (it is - * necessarily the more recent decision).

- */ - public int migrateLegacyVerdicts(String scopeId, List intents) { - // Deliberately does NOT fire a change. Every other mutator here does, - // but this one is called from the render path -- the UI asks for a - // scope's intents, which is the only moment the grouping is known -- - // and the caller reads the migrated verdicts immediately afterwards. - // Firing would re-enter that same render through the store's change - // listener while it was still running. The write is still persisted, - // so nothing is lost if the app closes before the next refresh. - return migrateLegacyVerdictsInternal(scopeId, intents); - } - - private synchronized int migrateLegacyVerdictsInternal(String scopeId, List intents) { - if (intents.isEmpty()) { - // No grouping means no diff has resolved for this scope yet. - // Rewriting verdicts against an empty grouping would delete them. - return 0; - } - Map legacy = new LinkedHashMap<>(); - for (ReviewVerdict verdict : verdicts.values()) { - if (verdict.scopeId().equals(scopeId) - && verdict.intentId().startsWith(LEGACY_FILE_INTENT_PREFIX)) { - legacy.put(verdict.intentId().substring(LEGACY_FILE_INTENT_PREFIX.length()), verdict); - } - } - if (legacy.isEmpty()) { - return 0; - } - int migrated = 0; - for (ReviewIntent intent : intents) { - if (verdicts.containsKey(new ReviewVerdict.Key(scopeId, intent.id()))) { - continue; // decided under the new grouping; that decision is newer - } - List files = intent.files(); - if (files.isEmpty()) { - continue; - } - List covering = files.stream().map(legacy::get).toList(); - Optional merged = merge(covering); - if (merged.isEmpty()) { - continue; - } - Instant at = covering.stream().filter(Objects::nonNull) - .map(ReviewVerdict::at).max(Instant::compareTo).orElse(Instant.now()); - verdicts.put(new ReviewVerdict.Key(scopeId, intent.id()), - new ReviewVerdict(scopeId, intent.id(), merged.get(), - Optional.of("carried over from a per-file verdict when Review regrouped " - + "this scope's changes"), at)); - for (String file : files) { - verdicts.remove(new ReviewVerdict.Key(scopeId, LEGACY_FILE_INTENT_PREFIX + file)); - } - migrated++; + /** {@code u}: undoes the verdict on one hunk. */ + public void clearVerdict(String scopeId, String hunkDigest) { + if (clearVerdictInternal(scopeId, hunkDigest)) { + fireChanged(null); } - if (migrated > 0) { + } + + private synchronized boolean clearVerdictInternal(String scopeId, String hunkDigest) { + if (verdicts.remove(new ReviewVerdict.Key(scopeId, hunkDigest)) != null) { persistAsync(); + return true; } - return migrated; + return false; } /** - * The group's decision, or empty when its files do not support one. See - * {@link #migrateLegacyVerdicts} for why "all settled" is required for an - * approval but any one file is enough for a change request. + * Records an agent's recheck, replacing any it already made about the + * same hunk and the same base pair. + * + *

Only an affected one has any effect (spec §9.7). Nothing here + * touches {@link #verdicts}: an assessment is a second, weaker fact + * stored alongside a verdict, never an edit to it, which is what makes + * "an agent may never clear a human's approval" true by construction + * rather than by every reader remembering to.

*/ - private static Optional merge(List covering) { - if (covering.stream().anyMatch(verdict -> verdict != null - && verdict.decision() == ReviewVerdict.Decision.CHANGES)) { - return Optional.of(ReviewVerdict.Decision.CHANGES); - } - if (covering.stream().anyMatch(Objects::isNull)) { - return Optional.empty(); - } - // A human's approval outranks an agent's auto-approval: the merged - // verdict must not claim less human attention than was actually paid. - return Optional.of(covering.stream() - .anyMatch(verdict -> verdict.decision() == ReviewVerdict.Decision.APPROVED) - ? ReviewVerdict.Decision.APPROVED - : ReviewVerdict.Decision.AUTO_APPROVED); - } - - /** {@code u}: undoes the verdict on one intent. */ - public void clearVerdict(String scopeId, String intentId) { - if (clearVerdictInternal(scopeId, intentId)) { - fireChanged(null); - } + public void putAssessment(RecheckAssessment assessment) { + putAssessmentInternal(assessment); + fireChanged(null); } - private synchronized boolean clearVerdictInternal(String scopeId, String intentId) { - if (verdicts.remove(new ReviewVerdict.Key(scopeId, intentId)) != null) { - persistAsync(); - return true; - } - return false; + private synchronized void putAssessmentInternal(RecheckAssessment assessment) { + assessments.put(assessment.key(), assessment); + persistAsync(); } /** Marks a scope's review as submitted. */ @@ -528,7 +487,8 @@ public void close() { private void persistAsync() { Snapshot snapshot = new Snapshot(List.copyOf(findings.values()), - List.copyOf(verdicts.values()), List.copyOf(submitted), scopeIdSecret); + List.copyOf(verdicts.values()), List.copyOf(assessments.values()), + List.copyOf(submitted), scopeIdSecret); // Queue a writer task only when there is no snapshot already // pending; otherwise the queued task picks up this newer one. if (pendingSnapshot.getAndSet(snapshot) == null) { @@ -546,7 +506,7 @@ private void saveSnapshot(Snapshot snapshot) { Path directory = file.getParent(); Files.createDirectories(directory); String text = JsonWriter.write(toJson(snapshot.findings(), snapshot.verdicts(), - snapshot.submitted(), snapshot.scopeIdSecret())); + snapshot.assessments(), snapshot.submitted(), snapshot.scopeIdSecret())); Path tempFile = Files.createTempFile(directory, file.getFileName().toString() + ".", ".tmp"); try { Files.writeString(tempFile, text, StandardCharsets.UTF_8); @@ -573,6 +533,13 @@ private void loadFromDisk() { for (ReviewVerdict verdict : verdictsFromJson(parsed)) { verdicts.put(verdict.key(), verdict); } + // Read on its own terms, like every other named array: a file + // written before spec §9.7 simply has no "assessments" key and + // loads with none, which is why this schema bump needs no + // migration path. + for (RecheckAssessment assessment : assessmentsFromJson(parsed)) { + assessments.put(assessment.key(), assessment); + } submitted.addAll(submittedFromJson(parsed)); } catch (IOException | RuntimeException e) { LOG.log(Level.WARNING, "Annotations file " + file + " is malformed; starting empty", e); @@ -583,11 +550,12 @@ private void loadFromDisk() { static JsonValue toJson(List findings, List verdicts, List submitted) { - return toJson(findings, verdicts, submitted, newScopeIdSecret()); + return toJson(findings, verdicts, List.of(), submitted, newScopeIdSecret()); } private static JsonValue toJson(List findings, List verdicts, - List submitted, String scopeIdSecret) { + List assessments, List submitted, + String scopeIdSecret) { JsonObject root = JsonObject.empty(); root.put("schemaVersion", JsonNumber.of(SCHEMA_VERSION)); root.put("scopeIdSecret", new JsonString(scopeIdSecret)); @@ -602,14 +570,30 @@ private static JsonValue toJson(List findings, List obj.put("note", new JsonString(note))); obj.put("at", new JsonString(verdict.at().toString())); + obj.put("base", new JsonString(verdict.baseCommit())); + obj.put("head", new JsonString(verdict.headCommit())); verdictEntries.add(obj); } root.put("verdicts", new JsonArray(verdictEntries)); + List assessmentEntries = new ArrayList<>(); + for (RecheckAssessment assessment : assessments) { + JsonObject obj = JsonObject.empty(); + obj.put("scopeId", new JsonString(assessment.scopeId())); + obj.put("hunkDigest", new JsonString(assessment.hunkDigest())); + obj.put("fromBase", new JsonString(assessment.fromBase())); + obj.put("toBase", new JsonString(assessment.toBase())); + obj.put("affected", new JsonBoolean(assessment.affected())); + obj.put("why", new JsonString(assessment.why())); + obj.put("at", new JsonString(assessment.at().toString())); + assessmentEntries.add(obj); + } + root.put("assessments", new JsonArray(assessmentEntries)); + List submittedEntries = new ArrayList<>(); for (String scopeId : submitted) { submittedEntries.add(new JsonString(scopeId)); @@ -864,11 +848,13 @@ static List verdictsFromJson(JsonValue value) { try { result.add(new ReviewVerdict( requireString(obj, "scopeId"), - requireString(obj, "intentId"), + requireString(obj, "hunkDigest"), ReviewVerdict.Decision.fromWire(requireString(obj, "verdict")) .orElseThrow(() -> new IllegalArgumentException("unknown verdict")), optionalString(obj, "note"), - Instant.parse(requireString(obj, "at")))); + Instant.parse(requireString(obj, "at")), + requireString(obj, "base"), + requireString(obj, "head"))); } catch (IllegalArgumentException | DateTimeException e) { LOG.log(Level.WARNING, "Skipping malformed verdict entry: " + e.getMessage()); } @@ -876,6 +862,40 @@ static List verdictsFromJson(JsonValue value) { return result; } + /** + * The recorded rechecks, decoded as leniently as the verdicts above: one + * malformed entry is skipped, never the rest. + * + *

A missing {@code affected} decodes as {@code false}, which is the + * inert direction. Reading an unreadable entry as "affected" would let a + * corrupt file invent staleness nobody asserted; reading it as + * "unaffected" costs nothing, because unaffected clears nothing.

+ */ + static List assessmentsFromJson(JsonValue value) { + if (!(value instanceof JsonObject root) || !(root.get("assessments") instanceof JsonArray entries)) { + return List.of(); + } + List result = new ArrayList<>(); + for (JsonValue entryValue : entries.elements()) { + if (!(entryValue instanceof JsonObject obj)) { + continue; + } + try { + result.add(new RecheckAssessment( + requireString(obj, "scopeId"), + requireString(obj, "hunkDigest"), + requireString(obj, "fromBase"), + requireString(obj, "toBase"), + obj.get("affected") instanceof JsonBoolean affected && affected.value(), + optionalString(obj, "why").orElse(""), + Instant.parse(requireString(obj, "at")))); + } catch (IllegalArgumentException | DateTimeException e) { + LOG.log(Level.WARNING, "Skipping malformed assessment entry: " + e.getMessage()); + } + } + return result; + } + static List submittedFromJson(JsonValue value) { if (!(value instanceof JsonObject root) || !(root.get("submitted") instanceof JsonArray entries)) { return List.of(); diff --git a/app/src/main/java/app/drydock/review/BaseMove.java b/app/src/main/java/app/drydock/review/BaseMove.java new file mode 100644 index 00000000..954dac7a --- /dev/null +++ b/app/src/main/java/app/drydock/review/BaseMove.java @@ -0,0 +1,112 @@ +package app.drydock.review; + +import app.drydock.process.ProcessResult; +import app.drydock.process.ProcessRunner; +import app.drydock.process.ProcessTimeoutException; + +import java.io.IOException; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.SortedSet; +import java.util.TreeSet; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Whether a base move can have changed what an approval was given for + * (spec §9.2). + * + *

Marking every verdict stale on any base move spends the reviewer's + * attention on commits that provably could not matter, and a + * "confirm still good" button clicked reflexively is worth less than no + * button. So the base delta is intersected first.

+ * + *

The intersection is file-level and lexical. A base change that alters + * behaviour without touching a file the scope names or references will not + * mark anything -- drydock does not index the repository, so it cannot see + * that far. Closing that gap is the agent recheck's job, not this class's.

+ */ +public final class BaseMove { + + private static final Logger LOG = Logger.getLogger(BaseMove.class.getName()); + private static final Duration TIMEOUT = Duration.ofSeconds(20); + + private BaseMove() { + } + + /** + * What a base move touched. {@code unresolvable} means the old base could + * not be diffed -- a force-push, or a collected commit -- and is NOT the + * same as an empty delta. + */ + public record Delta(boolean unresolvable, SortedSet changedFiles) { + public Delta { + Objects.requireNonNull(changedFiles, "changedFiles"); + changedFiles = new TreeSet<>(changedFiles); + } + } + + /** The files {@code oldBase..newBase} touched. Blocking; never call on the FX thread. */ + public static Delta between(Path worktree, String oldBase, String newBase) { + List command = List.of("git", "diff", "--name-only", "-z", "--end-of-options", + oldBase + ".." + newBase); + try { + ProcessResult result = ProcessRunner.run(command, worktree, TIMEOUT); + if (result.exitCode() != 0) { + LOG.log(Level.WARNING, "git diff for base move failed: " + + ProcessRunner.excerpt(result.stderr())); + return new Delta(true, new TreeSet<>()); + } + return new Delta(false, parseNames(result.stdout())); + } catch (ProcessTimeoutException e) { + LOG.log(Level.WARNING, "git diff for base move timed out", e); + return new Delta(true, new TreeSet<>()); + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + LOG.log(Level.WARNING, "git diff for base move could not run", e); + return new Delta(true, new TreeSet<>()); + } + } + + /** + * Parses NUL-separated filenames from git diff output (with {@code -z} flag). + * Each path is a raw UTF-8 string with no C-style quoting. + */ + static SortedSet parseNames(String stdout) { + SortedSet files = new TreeSet<>(); + for (String path : stdout.split("\0", -1)) { + if (!path.isEmpty()) { + files.add(path); + } + } + return files; + } + + /** + * Whether {@code delta} could have changed the meaning of code in + * {@code scopeFiles}. + * + *

{@code scopeFiles} is a {@link Collection} rather than the scope's + * own file list so that the set can widen -- Phase 2 adds the files + * declaring symbols the scope's hunks reference -- without moving any + * caller.

+ */ + public static boolean couldMatter(Delta delta, Collection scopeFiles) { + Objects.requireNonNull(delta, "delta"); + Objects.requireNonNull(scopeFiles, "scopeFiles"); + if (delta.unresolvable()) { + return true; + } + for (String file : scopeFiles) { + if (delta.changedFiles().contains(file)) { + return true; + } + } + return false; + } +} diff --git a/app/src/main/java/app/drydock/review/ChangeGraph.java b/app/src/main/java/app/drydock/review/ChangeGraph.java new file mode 100644 index 00000000..df5607c3 --- /dev/null +++ b/app/src/main/java/app/drydock/review/ChangeGraph.java @@ -0,0 +1,272 @@ +package app.drydock.review; + +import app.drydock.git.UnifiedDiff; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.SortedSet; +import java.util.TreeMap; +import java.util.TreeSet; + +/** + * The changed symbols of one scope and the references between them + * (spec §4). + * + *

In memory, scope lifetime, rebuilt when the diff is re-read. Nothing is + * persisted -- the reference implementation keeps a SQLite graph only because + * it is a multi-process pipeline, and one process needs no file, no + * invalidation story and no collection.

+ * + *

Two granularities, one rule. The file-level view + * ({@link #filesReferencedBy}, {@link #filesReferencing}) is what grouping + * asks for: are these two files related. The hunk-level view ({@link + * #referencesIn}, {@link #hunksDeclaring}) is what a marker rendered + * BENEATH one hunk asks for, and the two are not interchangeable -- a + * footer saying "calls guards.cpp" under a hunk that calls nothing is a + * false statement about that hunk, not a loose one about the file. Both are + * built from the same symbols in the same pass and answer cross-file by the + * same test, so they cannot drift.

+ * + *

Every exposed collection is sorted. Determinism is a requirement here, + * not a property (spec §9.5), and hash iteration order is the cheapest way + * to lose it.

+ * + *

Building the graph parses every changed file through {@link + * SymbolScan}, which can trigger a first-time native grammar load. Blocking; + * never call {@link #of} on the FX thread.

+ */ +public final class ChangeGraph { + + private final SortedSet files; + private final Map> declarationsByFile; + private final Map fileByUniqueDeclaration; + private final Map> referencesOut; + private final Map> referencesIn; + private final Map> referencesInBySymbol; + private final Map> declarationsByHunk; + private final Map> referencesByHunk; + private final Map> hunksDeclaringSymbol; + private final Map> hunksReferencingSymbol; + + /** + * One hunk of one changed file, by the same index {@link + * ReviewIntent#hunkId} counts. + * + *

The file-level view answers "are these two files related". A + * reviewer is shown a marker under ONE hunk, and a marker under a hunk + * that does not reference the target is a false statement about that + * hunk, not a soft overstatement about the file -- so the graph carries + * both granularities rather than leaving a caller to spread a file's + * answer over its hunks.

+ */ + public record Hunk(String file, int index) implements Comparable { + public Hunk { + Objects.requireNonNull(file, "file"); + } + + @Override + public int compareTo(Hunk other) { + int byFile = file.compareTo(other.file); + return byFile != 0 ? byFile : Integer.compare(index, other.index); + } + } + + private ChangeGraph(SortedSet files, + Map> declarationsByFile, + Map fileByUniqueDeclaration, + Map> referencesOut, + Map> referencesIn, + Map> referencesInBySymbol, + Map> declarationsByHunk, + Map> referencesByHunk, + Map> hunksDeclaringSymbol, + Map> hunksReferencingSymbol) { + this.declarationsByHunk = declarationsByHunk; + this.referencesByHunk = referencesByHunk; + this.hunksDeclaringSymbol = hunksDeclaringSymbol; + this.hunksReferencingSymbol = hunksReferencingSymbol; + this.files = files; + this.declarationsByFile = declarationsByFile; + this.fileByUniqueDeclaration = fileByUniqueDeclaration; + this.referencesOut = referencesOut; + this.referencesIn = referencesIn; + this.referencesInBySymbol = referencesInBySymbol; + } + + /** + * Builds the graph for {@code diff}. Blocking -- scans every file with + * {@link SymbolScan}, which can load a native grammar library the first + * time a language is seen -- never call on the FX thread. + */ + public static ChangeGraph of(UnifiedDiff diff) { + Map> scans = new LinkedHashMap<>(); + for (UnifiedDiff.FileDiff file : diff.files()) { + scans.put(file.path(), SymbolScan.of(file)); + } + + // A name declared in more than one changed file cannot be resolved, + // so it is dropped rather than guessed at. + Map> declaringFiles = new TreeMap<>(); + Map> declarationsByFile = new TreeMap<>(); + Map> declarationsByHunk = new TreeMap<>(); + for (Map.Entry> entry : scans.entrySet()) { + for (SymbolScan.Symbol symbol : entry.getValue()) { + if (symbol.declaration() && symbol.onChangedLine()) { + declaringFiles.computeIfAbsent(symbol.name(), key -> new ArrayList<>()) + .add(entry.getKey()); + declarationsByFile.computeIfAbsent(entry.getKey(), key -> new TreeSet<>()) + .add(symbol.name()); + declarationsByHunk + .computeIfAbsent(new Hunk(entry.getKey(), symbol.hunk()), + key -> new TreeSet<>()) + .add(symbol.name()); + } + } + } + Map unique = new TreeMap<>(); + for (Map.Entry> entry : declaringFiles.entrySet()) { + List distinct = entry.getValue().stream().distinct().toList(); + if (distinct.size() == 1) { + unique.put(entry.getKey(), distinct.get(0)); + } + } + + Map> out = new TreeMap<>(); + Map> in = new TreeMap<>(); + Map> inBySymbol = new TreeMap<>(); + Map> referencesByHunk = new TreeMap<>(); + Map> hunksDeclaringSymbol = new TreeMap<>(); + Map> hunksReferencingSymbol = new TreeMap<>(); + for (Map.Entry> entry : scans.entrySet()) { + for (SymbolScan.Symbol symbol : entry.getValue()) { + // A use counts wherever it sits in the diff window, changed + // line or context line. The node set is already restricted + // to changed files, so this cannot drag in unrelated code -- + // it only connects files already under review together. A + // declaration changing behaviour without most of its call + // sites being touched is the single most common shape of + // the coupling this graph exists to surface; requiring the + // use itself to be edited would split that section in half + // to buy edge purity the node-set restriction already gives + // for free. + String target = unique.get(symbol.name()); + // Cross-file only: an intra-file match is noise from + // short-name matching, not a relationship worth showing. + if (target == null || target.equals(entry.getKey())) { + continue; + } + out.computeIfAbsent(entry.getKey(), key -> new TreeSet<>()).add(target); + in.computeIfAbsent(target, key -> new TreeSet<>()).add(entry.getKey()); + inBySymbol.computeIfAbsent(symbol.name(), key -> new TreeSet<>()) + .add(entry.getKey()); + // Same edge, one granularity finer. Cross-file only, by the + // same test: the file-level and hunk-level views must agree + // about what an edge IS, or a link footer and the section it + // sits in would disagree. + referencesByHunk + .computeIfAbsent(new Hunk(entry.getKey(), symbol.hunk()), + key -> new TreeSet<>()) + .add(symbol.name()); + hunksReferencingSymbol.computeIfAbsent(symbol.name(), key -> new TreeSet<>()) + .add(new Hunk(entry.getKey(), symbol.hunk())); + } + } + + // Which hunks of the declaring file actually declare each resolvable + // name. A name uniquely declared in one FILE may still be declared in + // more than one of its hunks, and a "calls" link has to point at all + // of them rather than guess one. + for (Map.Entry> entry : declarationsByHunk.entrySet()) { + for (String name : entry.getValue()) { + if (entry.getKey().file().equals(unique.get(name))) { + hunksDeclaringSymbol.computeIfAbsent(name, key -> new TreeSet<>()) + .add(entry.getKey()); + } + } + } + + SortedSet files = new TreeSet<>(scans.keySet()); + return new ChangeGraph(files, declarationsByFile, unique, out, in, inBySymbol, + declarationsByHunk, referencesByHunk, hunksDeclaringSymbol, + hunksReferencingSymbol); + } + + /** Every changed file, in this scope. */ + public SortedSet files() { + return Collections.unmodifiableSortedSet(files); + } + + /** Names {@code file} declares on a changed line. */ + public SortedSet declarationsIn(String file) { + return unmodifiable(declarationsByFile.get(file)); + } + + /** Files {@code file} references. */ + public SortedSet filesReferencedBy(String file) { + return unmodifiable(referencesOut.get(file)); + } + + /** Files that reference {@code file}. */ + public SortedSet filesReferencing(String file) { + return unmodifiable(referencesIn.get(file)); + } + + /** + * Files that reference {@code symbol} itself, which is not the same + * question as {@link #filesReferencing(String)} on its declaring file: a + * file declaring ten changed symbols has one fan-in, and its ten symbols + * do not. Anything asking which symbol a group of files is ABOUT needs + * the per-symbol count, and reading it off the file would answer with + * whichever name happened to sort first. + */ + public SortedSet filesReferencingSymbol(String symbol) { + return unmodifiable(referencesInBySymbol.get(symbol)); + } + + /** The one changed file declaring {@code symbol}, when exactly one does. */ + public Optional fileDeclaring(String symbol) { + return Optional.ofNullable(fileByUniqueDeclaration.get(symbol)); + } + + /** Every uniquely-declared changed symbol name. */ + public SortedSet changedDeclarations() { + return Collections.unmodifiableSortedSet(new TreeSet<>(fileByUniqueDeclaration.keySet())); + } + + /** Names {@code hunk} declares on a changed line. */ + public SortedSet declarationsIn(Hunk hunk) { + return unmodifiable(declarationsByHunk.get(hunk)); + } + + /** + * Names {@code hunk} uses that another changed file uniquely declares -- + * the hunk-level counterpart of {@link #filesReferencedBy(String)}, and + * cross-file by the same rule. + */ + public SortedSet referencesIn(Hunk hunk) { + return unmodifiable(referencesByHunk.get(hunk)); + } + + /** The hunks of {@code symbol}'s one declaring file that declare it. */ + public SortedSet hunksDeclaring(String symbol) { + return unmodifiableHunks(hunksDeclaringSymbol.get(symbol)); + } + + /** The hunks in other files that reference {@code symbol}. */ + public SortedSet hunksReferencingSymbol(String symbol) { + return unmodifiableHunks(hunksReferencingSymbol.get(symbol)); + } + + private static SortedSet unmodifiable(SortedSet set) { + return Collections.unmodifiableSortedSet(set == null ? new TreeSet<>() : set); + } + + private static SortedSet unmodifiableHunks(SortedSet set) { + return Collections.unmodifiableSortedSet(set == null ? new TreeSet<>() : set); + } +} diff --git a/app/src/main/java/app/drydock/review/FallbackIntents.java b/app/src/main/java/app/drydock/review/FallbackIntents.java index bbe89e49..04f9e05c 100644 --- a/app/src/main/java/app/drydock/review/FallbackIntents.java +++ b/app/src/main/java/app/drydock/review/FallbackIntents.java @@ -64,7 +64,7 @@ public static List group(UnifiedDiff diff) { * enum's own ordinal, which is a wire-format concern and would silently * reorder the rail the next time a kind is added to it. */ - private static int readingOrder(ReviewIntent.Kind kind) { + static int readingOrder(ReviewIntent.Kind kind) { return switch (kind) { case CHANGE -> 0; case REFACTOR -> 1; @@ -107,7 +107,9 @@ ReviewIntent toIntent(int number) { } } return new ReviewIntent(id(), number, title(), key.kind(), risk(churn), - rationale(churn), hunkIds, java.util.Optional.empty(), false); + // No reads: the fallback is what runs when no agent has, + // so there is no declared dependency order to carry. + rationale(churn), hunkIds, java.util.Optional.empty(), false, List.of()); } private String id() { @@ -226,6 +228,19 @@ private static boolean isGenerated(String lower, String name) { || name.endsWith(".g.dart"); } + /** + * Whether {@code path} is a test path, by the same rules {@link #kindOf} + * applies. Exposed for {@link ReadingPath}'s entry-point rank, which + * needs the question without the kind: a vendored test is {@link + * ReviewIntent.Kind#GENERATED} and still a test. A second copy of this + * vocabulary drifted the last time one existed, which is the reason + * {@link SymbolWords} is a class at all. + */ + static boolean isTestPath(String path) { + String lower = path.toLowerCase(Locale.ROOT); + return isTest(lower, fileName(lower)); + } + private static boolean isTest(String lower, String name) { return lower.contains("/test/") || lower.contains("/tests/") diff --git a/app/src/main/java/app/drydock/review/GrammarRegistry.java b/app/src/main/java/app/drydock/review/GrammarRegistry.java new file mode 100644 index 00000000..08b56f27 --- /dev/null +++ b/app/src/main/java/app/drydock/review/GrammarRegistry.java @@ -0,0 +1,119 @@ +package app.drydock.review; + +import org.treesitter.TSLanguage; + +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Extension to tree-sitter grammar (spec §10.2). + * + *

A grammar that is absent is the lexical path, not an error. + * That rule is what keeps the shipped language set a packaging decision + * rather than an architectural one: the {@code .app} and the jbang jar may + * ship different sets, and a language nobody packaged produces a coarser + * change graph rather than a broken surface.

+ * + *

Grammars are resolved reflectively and cached. Loading pulls a native + * library out of the jar and {@code System.load}s it, so the first call for + * a language is disk I/O -- never make it on the FX thread.

+ */ +public final class GrammarRegistry { + + private static final Logger LOG = Logger.getLogger(GrammarRegistry.class.getName()); + + /** Extension to the grammar class the artifact publishes, insertion-ordered for determinism. */ + private static final Map GRAMMARS = new LinkedHashMap<>(); + + static { + GRAMMARS.put("java", "org.treesitter.TreeSitterJava"); + GRAMMARS.put("kt", "org.treesitter.TreeSitterKotlin"); + GRAMMARS.put("kts", "org.treesitter.TreeSitterKotlin"); + GRAMMARS.put("py", "org.treesitter.TreeSitterPython"); + GRAMMARS.put("js", "org.treesitter.TreeSitterJavascript"); + GRAMMARS.put("mjs", "org.treesitter.TreeSitterJavascript"); + GRAMMARS.put("ts", "org.treesitter.TreeSitterTypescript"); + GRAMMARS.put("tsx", "org.treesitter.TreeSitterTypescript"); + GRAMMARS.put("go", "org.treesitter.TreeSitterGo"); + GRAMMARS.put("rs", "org.treesitter.TreeSitterRust"); + GRAMMARS.put("c", "org.treesitter.TreeSitterC"); + GRAMMARS.put("h", "org.treesitter.TreeSitterCpp"); + GRAMMARS.put("cc", "org.treesitter.TreeSitterCpp"); + GRAMMARS.put("cpp", "org.treesitter.TreeSitterCpp"); + GRAMMARS.put("hpp", "org.treesitter.TreeSitterCpp"); + } + + private static final Map> CACHE = new LinkedHashMap<>(); + /** Grammar classes already warned about for a reflective-shape problem, so each is logged once, not once per extension. */ + private static final Set classShapeFailuresLogged = new LinkedHashSet<>(); + private static volatile boolean nativeFailed; + + private GrammarRegistry() { + } + + /** Whether the native library loaded. False means every file takes the lexical path. */ + public static boolean nativeAvailable() { + return !nativeFailed; + } + + /** The grammar for {@code path}'s language, or empty when there is none. */ + public static synchronized Optional forPath(String path) { + if (path == null || path.endsWith("/")) { + return Optional.empty(); + } + int dot = path.lastIndexOf('.'); + int slash = path.lastIndexOf('/'); + if (dot < 0 || dot < slash || dot == path.length() - 1) { + return Optional.empty(); + } + String extension = path.substring(dot + 1).toLowerCase(Locale.ROOT); + String className = GRAMMARS.get(extension); + if (className == null) { + return Optional.empty(); + } + return CACHE.computeIfAbsent(extension, key -> load(className)); + } + + private static Optional load(String className) { + if (nativeFailed) { + return Optional.empty(); + } + try { + Class type = Class.forName(className); + return Optional.of((TSLanguage) type.getDeclaredConstructor().newInstance()); + } catch (ClassNotFoundException e) { + // The grammar was not packaged for this artifact. Normal, and the + // lexical path handles it -- logging it per file would be noise. + return Optional.empty(); + } catch (ReflectiveOperationException e) { + // The class exists but its reflective shape is not what we + // expect -- no no-arg constructor, a visibility change, etc. + // That is a problem with THIS grammar only; it must not take + // every other language down with it. Log once for the class + // (extensions sharing a class, e.g. cpp/h/cc/hpp, would + // otherwise each re-trigger it) and leave the native latch alone. + if (classShapeFailuresLogged.add(className)) { + LOG.log(Level.WARNING, "tree-sitter grammar " + className + + " could not be instantiated; falling back to lexical " + + "scanning for its extensions", e); + } + return Optional.empty(); + } catch (UnsatisfiedLinkError | RuntimeException e) { + // The native library itself could not load: unsupported arch, a + // failed extraction, a CRC mismatch. Say it ONCE and fall back + // for everything; per-file logging would bury it. + if (!nativeFailed) { + nativeFailed = true; + LOG.log(Level.WARNING, "tree-sitter unavailable; the change graph " + + "falls back to lexical scanning for every file", e); + } + return Optional.empty(); + } + } +} diff --git a/app/src/main/java/app/drydock/review/Graphs.java b/app/src/main/java/app/drydock/review/Graphs.java new file mode 100644 index 00000000..f7ff1010 --- /dev/null +++ b/app/src/main/java/app/drydock/review/Graphs.java @@ -0,0 +1,170 @@ +package app.drydock.review; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Deque; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.SortedSet; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.function.Function; + +/** + * Kahn and Tarjan (spec §2.3, §6.1). + * + *

Hand-rolled rather than taken from a graph library: what this design + * asks of a graph is a topological sort, strongly-connected components and + * reachability over tens of nodes, and a library costs a megabyte of + * transitives, an entry in the jlink module list that a test pins against + * jdeps, and a POM dependency the jbang jar bundles nothing of.

+ * + *

The tie-break is supplied by the caller and must be TOTAL: two runs may + * not order equal units differently (spec §9.5). A caller-supplied edge that + * points outside {@code nodes} is rejected with {@link IllegalArgumentException} + * rather than silently dropped -- absent and broken must not look the same. + * A node with no such dependency and a node whose dependency the caller + * forgot to include would otherwise produce identical output, hiding a bug + * in whatever built {@code dependsOn} behind a graph that looks merely + * incomplete.

+ */ +public final class Graphs { + + private Graphs() { + } + + /** + * {@code nodes} in reading order, foundation first. Each entry is one + * unit: a single node, or the members of a cycle collapsed together and + * ordered by {@code tieBreak}. + */ + public static List> topologicalOrder( + SortedSet nodes, Function> dependsOn, Comparator tieBreak) { + List> components = stronglyConnected(nodes, dependsOn, tieBreak); + + Map componentOf = new LinkedHashMap<>(); + for (int index = 0; index < components.size(); index++) { + for (T member : components.get(index)) { + componentOf.put(member, index); + } + } + + // Condense to a DAG over components, then Kahn it. + Map> prerequisites = new TreeMap<>(); + Map> dependents = new TreeMap<>(); + for (int index = 0; index < components.size(); index++) { + prerequisites.put(index, new TreeSet<>()); + dependents.put(index, new TreeSet<>()); + } + // stronglyConnected already walked every node's dependsOn and would + // have thrown on a target outside nodes, so every prerequisite here + // is guaranteed to resolve to a component. + for (T node : nodes) { + for (T prerequisite : dependsOn.apply(node)) { + int from = componentOf.get(prerequisite); + int to = componentOf.get(node); + if (from == to) { + continue; + } + prerequisites.get(to).add(from); + dependents.get(from).add(to); + } + } + + Comparator byFirstMember = + Comparator.comparing(index -> components.get(index).get(0), tieBreak); + TreeSet ready = new TreeSet<>(byFirstMember); + for (int index = 0; index < components.size(); index++) { + if (prerequisites.get(index).isEmpty()) { + ready.add(index); + } + } + + List> ordered = new ArrayList<>(); + while (!ready.isEmpty()) { + Integer next = ready.first(); + ready.remove(next); + ordered.add(List.copyOf(components.get(next))); + for (Integer dependent : dependents.get(next)) { + SortedSet remaining = prerequisites.get(dependent); + remaining.remove(next); + if (remaining.isEmpty()) { + ready.add(dependent); + } + } + } + return List.copyOf(ordered); + } + + /** Tarjan, iterative so a deep graph cannot overflow the stack. */ + private static List> stronglyConnected( + SortedSet nodes, Function> edges, Comparator tieBreak) { + Map index = new LinkedHashMap<>(); + Map lowLink = new LinkedHashMap<>(); + Deque stack = new ArrayDeque<>(); + Set onStack = new LinkedHashSet<>(); + List> components = new ArrayList<>(); + int[] counter = {0}; + + for (T root : nodes) { + if (index.containsKey(root)) { + continue; + } + Deque work = new ArrayDeque<>(); + Deque> pending = new ArrayDeque<>(); + work.push(root); + pending.push(edges.apply(root).iterator()); + index.put(root, counter[0]); + lowLink.put(root, counter[0]++); + stack.push(root); + onStack.add(root); + + while (!work.isEmpty()) { + T node = work.peek(); + Iterator children = pending.peek(); + if (children.hasNext()) { + T child = children.next(); + if (!nodes.contains(child)) { + throw new IllegalArgumentException( + "dependsOn(" + node + ") named " + child + + ", which is not in nodes"); + } + if (!index.containsKey(child)) { + index.put(child, counter[0]); + lowLink.put(child, counter[0]++); + stack.push(child); + onStack.add(child); + work.push(child); + pending.push(edges.apply(child).iterator()); + } else if (onStack.contains(child)) { + lowLink.put(node, Math.min(lowLink.get(node), index.get(child))); + } + } else { + work.pop(); + pending.pop(); + if (!work.isEmpty()) { + T parent = work.peek(); + lowLink.put(parent, Math.min(lowLink.get(parent), lowLink.get(node))); + } + if (lowLink.get(node).equals(index.get(node))) { + List component = new ArrayList<>(); + T member; + do { + member = stack.pop(); + onStack.remove(member); + component.add(member); + } while (!member.equals(node)); + component.sort(tieBreak); + components.add(component); + } + } + } + } + return components; + } +} diff --git a/app/src/main/java/app/drydock/review/HunkDigest.java b/app/src/main/java/app/drydock/review/HunkDigest.java new file mode 100644 index 00000000..3d699b18 --- /dev/null +++ b/app/src/main/java/app/drydock/review/HunkDigest.java @@ -0,0 +1,55 @@ +package app.drydock.review; + +import app.drydock.git.UnifiedDiff; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Objects; + +/** + * The content identity of one hunk: what an approval is valid for + * (spec §9.2). + * + *

Covers the file path, the hunk's changed lines and its context + * lines. Context is included because a hunk means what it means in place -- + * change the line above it and its changed lines are byte-identical, so a + * changed-lines-only digest would leave an approval standing over code whose + * surroundings moved. It stops at the context window rather than the whole + * file: a file-wide digest would unsettle every hunk whenever a file is + * touched again, re-reviewing code nobody changed.

+ * + *

Line NUMBERS are deliberately excluded. A hunk that only moved is the + * same code and stays approved; that is the whole reason this is not the + * positional line key findings use.

+ */ +public final class HunkDigest { + + private HunkDigest() { + } + + /** The digest {@code hunk} in {@code path} is approved under. */ + public static String of(String path, UnifiedDiff.Hunk hunk) { + Objects.requireNonNull(path, "path"); + Objects.requireNonNull(hunk, "hunk"); + StringBuilder material = new StringBuilder(path).append('\n'); + for (UnifiedDiff.Line line : hunk.lines()) { + // The kind is part of the material: an added line and a deleted + // line carrying the same text are not the same thing to approve. + material.append(line.kind().name()).append(' ').append(line.text()).append('\n'); + } + return hex(material.toString()); + } + + private static String hex(String material) { + try { + MessageDigest sha = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(sha.digest(material.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + // SHA-256 is mandated by the platform; its absence is not a + // condition this application can meaningfully continue past. + throw new IllegalStateException("SHA-256 unavailable", e); + } + } +} diff --git a/app/src/main/java/app/drydock/review/IntentGrouping.java b/app/src/main/java/app/drydock/review/IntentGrouping.java index 8cebe3c7..091a12fc 100644 --- a/app/src/main/java/app/drydock/review/IntentGrouping.java +++ b/app/src/main/java/app/drydock/review/IntentGrouping.java @@ -2,12 +2,21 @@ import app.drydock.git.UnifiedDiff; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HexFormat; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.SortedSet; +import java.util.TreeMap; +import java.util.TreeSet; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.Consumer; @@ -25,27 +34,129 @@ */ public final class IntentGrouping { + /** + * The reading path's rank has no out-of-diff fan-in scan behind it here + * (Task 18 follow-up, correction 4): {@link OutOfDiffFanIn#scan} spawns a + * blocking {@code git grep} per scope, a separate concern from reordering + * a grouping already in hand. {@code unavailable=true} is the honest + * input for a signal nothing computed -- the same choice the rail and + * {@code McpToolRouter} both make. + */ + private static final OutOfDiffFanIn.Result NO_FAN_IN_SCAN = new OutOfDiffFanIn.Result(Map.of(), true); + private final Map> byScope = new ConcurrentHashMap<>(); private final List> listeners = new CopyOnWriteArrayList<>(); + /** + * How many times each scope's grouping has changed -- bumped by {@link + * #notifyChanged}, so a caller that caches {@link #intentsFor}'s result + * across more than one call (nothing else here can go stale: {@code + * diff} and {@code graph} are plain values a caller can compare by + * identity) has a single number to compare instead of recomputing on + * every call to find out nothing changed. + */ + private final Map versionByScope = new ConcurrentHashMap<>(); + + /** + * {@code scopeId}'s current grouping version -- 0 until the first + * {@link #set}/{@link #clear}, and incremented by every one after that. + * Never decreases and never repeats for a scope, so two reads that + * differ mean the reviewer's grouping genuinely changed in between; two + * reads that agree mean it provably did not, however far apart in time. + */ + public long version(String scopeId) { + return versionByScope.getOrDefault(scopeId, 0L); + } + /** * Replaces {@code scopeId}'s grouping with what a reviewer supplied. * Numbering is assigned here rather than trusted from the caller, so the * rail's {@code 1..N} is always dense and in order. + * + *

The ORDER numbered is still the reviewer's own -- either the order + * it listed its intents in, or, when any of them declares {@link + * ReviewIntent#reads()}, that declared dependency order (see {@link + * #orderByReads}). Both are the agent's assertion about its own change; + * neither is drydock re-deciding what card is (1).

*/ public void set(String scopeId, List intents) { Objects.requireNonNull(scopeId, "scopeId"); List numbered = new ArrayList<>(); int number = 1; - for (ReviewIntent intent : intents) { + for (ReviewIntent intent : orderByReads(intents)) { numbered.add(new ReviewIntent(intent.id(), number++, intent.title(), intent.kind(), intent.risk(), intent.rationale(), intent.hunkIds(), intent.collapse(), - intent.autoApprove())); + intent.autoApprove(), intent.reads())); } byScope.put(scopeId, List.copyOf(numbered)); notifyChanged(scopeId); } + /** The position stood in for a {@code reads} naming no intent in the batch. */ + private static final int UNRESOLVED_READ = -1; + + /** + * {@code intents} in the dependency order the agent declared through + * {@link ReviewIntent#reads()} -- foundation first -- or unchanged when + * none of them declares anything, which is every grouping sent before + * the field existed. + * + *

The graph's nodes are POSITIONS in {@code intents}, not intent ids. + * Nothing stops an agent sending the same id twice, and a set of ids + * would collapse those two into one node and lose a card outright; a set + * of positions cannot, whatever the ids say. It also makes the tie-break + * the agent's own array order, which is total by construction and is the + * right answer anyway: where {@code reads} says nothing, the order the + * agent listed them in is the only other thing it told us.

+ * + *

{@link Graphs#topologicalOrder} returns a list OF units -- a cycle + * comes back as one unit with its members already in tie-break order, + * not as an error. An agent declaring {@code A reads B} and {@code B + * reads A} is describing genuinely entangled work, and refusing its whole + * batch over that would be worse than showing the two adjacent, so the + * units are simply flattened in order.

+ */ + private static List orderByReads(List intents) { + if (intents.stream().allMatch(intent -> intent.reads().isEmpty())) { + return intents; + } + Map positionOf = new LinkedHashMap<>(); + for (int position = 0; position < intents.size(); position++) { + positionOf.putIfAbsent(intents.get(position).id(), position); + } + SortedSet nodes = new TreeSet<>(); + Map> readsOf = new TreeMap<>(); + for (int position = 0; position < intents.size(); position++) { + nodes.add(position); + SortedSet targets = new TreeSet<>(); + for (String read : intents.get(position).reads()) { + // An id no intent in the batch carries is rejected at decode, + // in ReviewToolCodec.intentsFromJson, with an MCP error naming + // it -- a malformed agent payload must not first be noticed + // here, where the only report left is an exception on whatever + // thread happened to call set. UNRESOLVED_READ is outside nodes, + // so an id that reaches here anyway (from in-process code, + // which is a drydock bug and not an agent's) still makes + // Graphs refuse rather than silently drop the edge. + targets.add(positionOf.getOrDefault(read, UNRESOLVED_READ)); + } + readsOf.put(position, targets); + } + List ordered = new ArrayList<>(); + // getOrDefault, not readsOf::get: every position was populated just + // above so an unmapped key cannot happen today, but a method reference + // that answers null on one would surface as an NPE inside Graphs' + // traversal rather than as anything a reader could trace back here. + for (List unit : Graphs.topologicalOrder(nodes, + position -> readsOf.getOrDefault(position, Collections.emptySortedSet()), + Comparator.naturalOrder())) { + for (Integer position : unit) { + ordered.add(intents.get(position)); + } + } + return ordered; + } + /** Drops a scope's grouping (the scope left the queue). */ public void clear(String scopeId) { if (byScope.remove(scopeId) != null) { @@ -61,13 +172,252 @@ public boolean hasReviewerGrouping(String scopeId) { /** * {@code scopeId}'s intents: the reviewer's grouping when there is one, * otherwise {@link FallbackIntents}' clustering of {@code diff}. + * + *

Equivalent to calling {@link #intentsFor(String, UnifiedDiff, + * Optional)} with no graph -- for callers with no {@link ChangeGraph} to + * offer, which fall back to the (kind, directory) clustering exactly as + * they always have.

*/ public List intentsFor(String scopeId, UnifiedDiff diff) { + return intentsFor(scopeId, diff, Optional.empty()); + } + + /** + * {@code scopeId}'s intents: the reviewer's grouping when there is one, + * otherwise the computed sections -- and, with no graph to compute from, + * {@link FallbackIntents}' clustering of {@code diff}. + * + *

A reviewer's grouping is never re-sorted or re-drawn. It came from + * something that read the change; recomputing over it would be drydock + * overruling the reviewer -- so its {@code number}s stay exactly {@link + * #set}'s own dense 1..N over whatever order the reviewer supplied -- + * its array order, or the {@link ReviewIntent#reads()} order it declared, + * both of them the reviewer's own -- unrelated to {@link ReadingPath}'s + * reading order. Only the COMPUTED path below is renumbered against it, + * because only there is drydock itself the one deciding what card is + * (1).

+ * + *

When the graph turns out to have nothing structural to add -- + * {@link Sections#of} takes the same (kind, directory) clustering itself + * in that case -- this returns the fallback's OWN {@link ReviewIntent}s + * rather than restating them under a fresh {@code computed:} identity. A + * finding recorded against the fallback's id while the graph was still + * building must not be orphaned by a rebuild that, in the end, found + * nothing more to say: that would silently defeat {@code + * blockingFindingOpen}'s id match for no reason a reviewer caused.

+ */ + public List intentsFor(String scopeId, UnifiedDiff diff, + Optional graph) { List supplied = byScope.get(scopeId); if (supplied != null) { return supplied; } - return FallbackIntents.group(diff); + List fallback = FallbackIntents.group(diff); + if (graph.isEmpty()) { + return fallback; + } + List sections = Sections.of(diff, graph.get()); + // sameAsFallback compares against Sections.of's own (unreordered) + // list -- the ONE case that matters here is content equality with + // the fallback, which reordering cannot create or hide. + if (sameAsFallback(sections, fallback)) { + return fallback; + } + // Numbered in the SAME order the rail's PATH mode and + // McpToolRouter's review_scope both use (Task 18, correction 4): + // ReadingPath.of reorders Sections.of's own list by reading order, + // so a human looking at computed card (1) here and an agent reading + // section (1) off review_scope never disagree about which section + // that is -- and pressing p in the rail does not silently renumber + // every card underneath whichever intent a finding or verdict named. + List ordered = + ReadingPath.of(diff, graph.get(), sections, NO_FAN_IN_SCAN).sections(); + Map fallbackByHunk = new LinkedHashMap<>(); + for (ReviewIntent intent : fallback) { + for (String hunkId : intent.hunkIds()) { + fallbackByHunk.put(hunkId, intent); + } + } + List computed = new ArrayList<>(); + int number = 1; + for (Sections.Section section : ordered) { + computed.add(new ReviewIntent(computedId(section), number, + section.title(), kindOf(section, fallbackByHunk), riskOf(section, fallbackByHunk), + // No reads: this is the COMPUTED path, where drydock + // itself decided the order -- there is no agent assertion + // to carry, and ReadingPath.of above already ordered it. + rationale(section), section.hunkIds(), Optional.empty(), false, List.of())); + number++; + } + return List.copyOf(computed); + } + + /** + * What kind of change a computed section is: the most significant kind + * among the fallback intents whose hunks it covers, in the same + * (production change over its own tests, generated output or config) + * priority {@link FallbackIntents} itself orders the rail by. A section + * merging a header with its implementation, or a change with the test + * that covers it, must not flatten to a bare {@code change} tag just + * because {@code Sections} does not itself infer kind -- the fallback + * already worked that out per file, and grouping the hunks differently + * is no reason to discard it. + */ + private static ReviewIntent.Kind kindOf(Sections.Section section, Map fallbackByHunk) { + ReviewIntent.Kind best = null; + for (String hunkId : section.hunkIds()) { + ReviewIntent covering = fallbackByHunk.get(hunkId); + if (covering == null) { + continue; + } + if (best == null || kindPriority(covering.kind()) < kindPriority(best)) { + best = covering.kind(); + } + } + return best == null ? ReviewIntent.Kind.CHANGE : best; + } + + /** + * Mirrors {@link FallbackIntents}' own (private) reading-order priority: + * a production change is more significant than the tests or config that + * came with it, so ONE kind has to win when a section spans several, and + * this is the same choice the rail's own ordering already makes. + */ + private static int kindPriority(ReviewIntent.Kind kind) { + return switch (kind) { + case CHANGE -> 0; + case REFACTOR -> 1; + case MOVE -> 2; + case CONFIG -> 3; + case TESTS -> 4; + case GENERATED -> 5; + }; + } + + /** + * A computed section's risk: the worst of the fallback intents whose + * hunks it covers. A section is only as safe to wave through as its + * riskiest part, so the churn-derived HIGH/MED/LOW the fallback already + * measured per file must not vanish into a flat {@code NONE} the moment + * {@code Sections} regroups those same hunks. + */ + private static ReviewIntent.Risk riskOf(Sections.Section section, Map fallbackByHunk) { + ReviewIntent.Risk worst = ReviewIntent.Risk.NONE; + for (String hunkId : section.hunkIds()) { + ReviewIntent covering = fallbackByHunk.get(hunkId); + if (covering != null && covering.risk().ordinal() < worst.ordinal()) { + worst = covering.risk(); + } + } + return worst; + } + + /** + * The id one computed section is addressed by: derived from WHICH hunks + * it covers, never from where it happens to sit in the rail. + * + *

{@link Sections#of} orders sections topologically, so an edit + * elsewhere in the diff can shift a section's position in that order + * without changing what it is about. A positional {@code computed:N} + * id would then quietly re-point any verdict or finding recorded + * against {@code N} at a DIFFERENT section covering different hunks -- + * worse than losing track of it, because nothing about the result looks + * wrong. Hashed over the section's own hunk ids instead, sorted so the + * identity is the SET of hunks, not the order {@link Sections} happened + * to read them in.

+ * + *

The file set is hashed in too, not just the hunks: a binary file or + * a pure rename has no hunks at all ({@code UnifiedDiff} carries neither + * for those), so a section built from one alone hashes an EMPTY hunk + * list -- and every such section would collide on the identical id + * without the files to still tell them apart. Positional ids could + * never collide this way; content-derived ones must not either.

+ */ + private static String computedId(Sections.Section section) { + List sortedFiles = new ArrayList<>(section.files()); + Collections.sort(sortedFiles); + List sortedHunks = new ArrayList<>(section.hunkIds()); + Collections.sort(sortedHunks); + // Files and hunks are hashed SEPARATELY, then 8 hex characters are + // taken from EACH digest, rather than truncating one concatenated + // string -- that would keep only the leading digest's bytes and + // silently drop the other, which is exactly the bug this id exists + // to avoid: the id must depend on both the file set and the hunk + // set, since the file set alone is what tells two hunkless sections + // apart, and the hunk set alone is what makes the id survive a + // reordering that touches neither section's own hunks. + String files = sha256Hex(String.join("\n", sortedFiles)); + String hunks = sha256Hex(String.join("\n", sortedHunks)); + return "computed:" + files.substring(0, 8) + hunks.substring(0, 8); + } + + private static String sha256Hex(String material) { + try { + MessageDigest sha = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(sha.digest(material.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + // SHA-256 is mandated by the platform; its absence is not a + // condition this application can meaningfully continue past. + throw new IllegalStateException("SHA-256 unavailable", e); + } + } + + /** + * Whether {@code sections} is exactly {@link FallbackIntents}' own + * clustering, restated: {@link Sections#of} takes that path itself + * whenever it finds no dependency or convention edge at all. Compared by + * title and hunk ids, in order -- the two things a card actually shows + * and settles by -- rather than by re-deriving {@link Sections}' + * internal edge computation here. + */ + private static boolean sameAsFallback(List sections, List fallback) { + if (sections.size() != fallback.size()) { + return false; + } + for (int i = 0; i < sections.size(); i++) { + Sections.Section section = sections.get(i); + ReviewIntent intent = fallback.get(i); + if (!section.title().equals(intent.title()) || !section.hunkIds().equals(intent.hunkIds())) { + return false; + } + } + return true; + } + + /** At most this many cycle members are named before "and N more" takes over. */ + private static final int CYCLE_NAMES_SHOWN = 3; + + /** + * What a computed section says for itself with no agent to name it: the + * structural facts, and the cycle when it is in one. + */ + private static String rationale(Sections.Section section) { + String base = section.files().size() + " files · " + + section.hunkIds().size() + " hunks · grouped by drydock, no reviewer has run"; + if (section.cycleWith().isEmpty()) { + return base; + } + // cycleWith() names members of THIS section's own unit that + // reference each other -- the section IS the cycle, not something + // pointing outward at one -- so "in a dependency cycle with" reads + // as though these files belonged elsewhere, which they do not. + return base + " · its files reference each other in a cycle: " + + summarizeCycle(section.cycleWith()); + } + + /** + * At most {@link #CYCLE_NAMES_SHOWN} names, "and N more" beyond that. A + * unit's cycle can be its entire membership -- this branch's own + * ChangeGraph section names 24 files, all mutually referencing -- and + * spelling every one of them inline turns a two-line rationale into a + * card taller than the rail's own viewport. + */ + private static String summarizeCycle(List names) { + if (names.size() <= CYCLE_NAMES_SHOWN) { + return String.join(", ", names); + } + return String.join(", ", names.subList(0, CYCLE_NAMES_SHOWN)) + + " and " + (names.size() - CYCLE_NAMES_SHOWN) + " more"; } /** @@ -90,6 +440,7 @@ public Runnable addChangeListener(Consumer listener) { } private void notifyChanged(String scopeId) { + versionByScope.merge(scopeId, 1L, Long::sum); for (Consumer listener : listeners) { listener.accept(scopeId); } diff --git a/app/src/main/java/app/drydock/review/IntentHunks.java b/app/src/main/java/app/drydock/review/IntentHunks.java new file mode 100644 index 00000000..4d31bdef --- /dev/null +++ b/app/src/main/java/app/drydock/review/IntentHunks.java @@ -0,0 +1,53 @@ +package app.drydock.review; + +import app.drydock.git.UnifiedDiff; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * The hunks a section covers, as the digests its verdicts are keyed by + * (spec §9.1). + * + *

Sections overlap: the same hunk may sit in two of them, and the same + * digest may therefore be produced by both. That is the whole point -- a + * verdict is keyed by the hunk, not by whichever section the human happened + * to be looking at -- so the join from a section to its verdicts is + * many-to-one and every reader of it has to walk the diff the same way. It + * is walked here, once, rather than in the view, the workspace and the MCP + * router separately.

+ */ +public final class IntentHunks { + + private IntentHunks() { + } + + /** + * The content digests of the hunks {@code intent} covers in {@code diff}, + * in diff order, each listed once. + * + *

Membership is asked of {@link ReviewIntent#containsHunk}, so an + * intent that names no hunks at all covers the whole diff -- the same + * rule the diff column filters by. Two byte-identical hunks in one file + * collapse to a single digest, which is not a loss: they are the same + * code, and one verdict is what settles both.

+ */ + public static List digestsOf(ReviewIntent intent, UnifiedDiff diff) { + Objects.requireNonNull(intent, "intent"); + Objects.requireNonNull(diff, "diff"); + // Insertion-ordered: the rail, the bar and review_state all read this + // list, and a set with no order would give them three different ones. + Set digests = new LinkedHashSet<>(); + for (UnifiedDiff.FileDiff file : diff.files()) { + List hunks = file.hunks(); + for (int index = 0; index < hunks.size(); index++) { + if (intent.containsHunk(file.path(), index)) { + digests.add(HunkDigest.of(file.path(), hunks.get(index))); + } + } + } + return List.copyOf(digests); + } +} diff --git a/app/src/main/java/app/drydock/review/OutOfDiffFanIn.java b/app/src/main/java/app/drydock/review/OutOfDiffFanIn.java new file mode 100644 index 00000000..f68583f6 --- /dev/null +++ b/app/src/main/java/app/drydock/review/OutOfDiffFanIn.java @@ -0,0 +1,263 @@ +package app.drydock.review; + +import app.drydock.git.UnifiedDiff; +import app.drydock.process.ProcessResult; +import app.drydock.process.ProcessRunner; +import app.drydock.process.ProcessTimeoutException; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.SortedSet; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Where a changed symbol is used outside the change (spec §4.3). + * + *

The change graph is diff-scoped by design: it parses only the files the + * diff touches, so a caller sitting in an unchanged file is invisible to it. + * That caller is exactly the strongest "read this first" signal a review has + * -- a public-API change whose contract other code depends on -- and this + * class recovers it with one bounded {@code git grep} rather than by + * building the repository-wide index this codebase has twice declined to + * carry.

+ * + *

One spawn for the whole scope, not one per symbol: every uniquely-named + * changed declaration goes into a patterns file and {@code git grep -f} + * reads them all in a single pass.

+ * + *

The locations are kept, not just counted: a fan-in with nowhere to + * click is a statistic rather than comprehension, and it lands exactly when + * a reviewer wants to look. A later task feeds these into the existing + * occurrence popover.

+ * + *

A grep match is a lexical count, not a call count: it cannot tell a + * real reference from an unrelated identifier spelled the same way, the + * same trade this codebase already makes for an ungrammared file (see + * {@link SymbolScan}). What it will NOT do is count a line that does not + * contain the symbol at all. Attribution is therefore word-bounded ({@link + * #mentions}), with {@code git grep -w} narrowing what comes back in the + * first place, because a plain substring match reports + * {@code ZetaSymHelper} as two uses of {@code ZetaSym}, and this number is + * the reading path's FIRST rank term (see {@link ReadingPath}): an inflated + * count does not merely read wrong, it reorders what a human reads next. A + * repository-wide semantic index would still resolve more than this does -- + * a same-named symbol from another package is counted here -- and that is + * exactly the cost this class is built to avoid paying. The popover says + * "occurrences, not resolved references" for that residue; it was never a + * licence to list lines the symbol is absent from.

+ * + *

One line that genuinely mentions two changed declarations is counted + * once for each. That is not double counting: it is a use of both, and the + * popover lists it under both names.

+ * + *

Path quoting. Plain {@code git grep -n -F} C-quotes any path + * with a non-ASCII byte or a special character -- {@code café.txt} comes + * back as the literal {@code "caf\303\251.txt"}, quotes and octal escapes + * included -- which would silently fail to match against {@code + * changedFiles} and under-report the scan as clean. {@code -z} avoids the + * quoting entirely, but it also changes the framing: each match becomes + * {@code filelinetext} terminated by {@code \n} (verified against + * a real git binary), not the colon-joined text plain {@code git grep -n} + * prints. {@link #parse} is written against that NUL framing so a path + * containing a colon, or a non-ASCII byte, or both, round-trips intact.

+ * + *

Blocking; never call {@link #scan} on the FX thread.

+ */ +public final class OutOfDiffFanIn { + + private static final Logger LOG = Logger.getLogger(OutOfDiffFanIn.class.getName()); + private static final Duration TIMEOUT = Duration.ofSeconds(30); + private static final char FIELD_SEPARATOR = '\0'; + + /** + * One place {@code symbol} is used, outside the change. {@code text} is + * kept exactly as {@code git grep} reports it, leading whitespace + * included -- deliberately, not a missed {@code .strip()}: the popover + * this feeds is showing a source line, and its original indentation is + * part of reading it, not noise to trim. + */ + public record Occurrence(String file, int line, String text) { + } + + /** {@code unavailable} means the scan could not run: absent, not zero. */ + public record Result(Map> bySymbol, boolean unavailable) { + } + + private OutOfDiffFanIn() { + } + + /** + * The scan for one scope's diff: the same {@link #scan} with the two + * inputs every caller would otherwise have to derive for itself -- the + * worktree to grep, and the diff's own files as the "inside the change" + * set. + * + *

A scope with no worktree is {@code unavailable}, not empty: there + * is no checkout to grep, so nothing was measured. That is the same + * distinction {@link Result#unavailable} draws everywhere else, and the + * one thing a surface built on this may not blur.

+ * + *

Blocking, like {@link #scan}; never call on the FX thread.

+ */ + public static Result forScope(ReviewScope scope, ChangeGraph graph, UnifiedDiff diff) { + Optional worktree = scope.worktree(); + if (worktree.isEmpty()) { + return new Result(Map.of(), true); + } + SortedSet changedFiles = new TreeSet<>(); + for (UnifiedDiff.FileDiff file : diff.files()) { + changedFiles.add(file.path()); + } + return scan(worktree.get(), graph, changedFiles); + } + + /** + * Where each of {@code graph}'s changed declarations is used outside + * {@code changedFiles}. Spawns one {@code git grep} over every + * uniquely-named changed declaration at once. Blocking; never call on + * the FX thread. + */ + public static Result scan(Path worktree, ChangeGraph graph, Set changedFiles) { + SortedSet symbols = graph.changedDeclarations(); + if (symbols.isEmpty()) { + return new Result(Map.of(), false); + } + Path patterns = null; + try { + patterns = Files.createTempFile("drydock-fanin-", ".patterns"); + Files.writeString(patterns, String.join("\n", symbols), StandardCharsets.UTF_8); + // -w is a PRE-FILTER, not the correctness mechanism: mentions() + // below is, and it subsumes this. Not merely observed -- a + // mutation dropping -w changed no result, including against a + // repository of adversarial near-misses -- but provable: git's + // word characters are ASCII [A-Za-z0-9_], mentions() requires + // non-(isLetterOrDigit || '_') on both sides, and Java's set is a + // strict SUPERSET of git's, so a boundary mentions() accepts is + // one git also accepts. -w can therefore never drop a line + // mentions() would have kept: it can only spare work. + // + // The work it spares is real: without it git streams back -- and + // this class allocates an Occurrence for -- every line that + // merely CONTAINS a changed name, which for a short declaration + // like `id` is most of a repository. Do not read it as the reason + // the count is right. + List command = List.of("git", "grep", "-z", "-n", "-F", "-w", "-f", + patterns.toString(), "--end-of-options"); + ProcessResult result = ProcessRunner.run(command, worktree, TIMEOUT); + // git grep exits 1 for "no matches", a valid empty answer, not a + // failure. Anything above 1 is. + if (result.exitCode() > 1) { + LOG.log(Level.WARNING, "git grep for out-of-diff fan-in failed: " + + ProcessRunner.excerpt(result.stderr())); + return new Result(Map.of(), true); + } + List occurrences = parse(result.stdout(), changedFiles); + Map> bySymbol = new TreeMap<>(); + for (String symbol : symbols) { + List hits = occurrences.stream() + .filter(occurrence -> mentions(occurrence.text(), symbol)) + .toList(); + if (!hits.isEmpty()) { + bySymbol.put(symbol, hits); + } + } + return new Result(Collections.unmodifiableMap(bySymbol), false); + } catch (ProcessTimeoutException e) { + LOG.log(Level.WARNING, "git grep for out-of-diff fan-in timed out", e); + return new Result(Map.of(), true); + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + LOG.log(Level.WARNING, "git grep for out-of-diff fan-in could not run", e); + return new Result(Map.of(), true); + } finally { + if (patterns != null) { + try { + Files.deleteIfExists(patterns); + } catch (IOException e) { + LOG.log(Level.FINE, "could not remove fan-in patterns file", e); + } + } + } + } + + /** + * Whether {@code text} uses {@code symbol} as a whole word. + * + *

{@code git grep -w} decides which LINES come back; this decides + * which of the scanned symbols each line is attributed to, and the two + * have to agree or a line matched as a whole word for one symbol gets + * attributed by substring to another ({@code Foo} collecting every use + * of {@code FooBar}). Word characters are letters, digits and + * underscore -- git's own definition, and the one {@link SymbolWords}' + * identifiers are built from.

+ */ + static boolean mentions(String text, String symbol) { + if (symbol.isEmpty()) { + return false; + } + int from = 0; + while (true) { + int at = text.indexOf(symbol, from); + if (at < 0) { + return false; + } + boolean leftClear = at == 0 || !isWordCharacter(text.charAt(at - 1)); + int after = at + symbol.length(); + boolean rightClear = after == text.length() || !isWordCharacter(text.charAt(after)); + if (leftClear && rightClear) { + return true; + } + from = at + 1; + } + } + + private static boolean isWordCharacter(char c) { + return Character.isLetterOrDigit(c) || c == '_'; + } + + /** + * Parses {@code git grep -z -n -F} output: one match per record, + * records separated by {@code \n}, and within a record {@code + * filelinetext}. Occurrences inside {@code changedFiles} are + * dropped -- they are not "outside" the change. A record that does not + * split into exactly the three NUL-separated fields, or whose middle + * field is not a line number, is skipped rather than treated as fatal. + */ + static List parse(String stdout, Set changedFiles) { + List occurrences = new ArrayList<>(); + for (String record : stdout.split("\n", -1)) { + if (record.isEmpty()) { + continue; + } + String[] fields = record.split(String.valueOf(FIELD_SEPARATOR), -1); + if (fields.length != 3) { + LOG.log(Level.FINE, "skipping malformed git grep row (expected file\\0line\\0text)"); + continue; + } + String file = fields[0]; + if (changedFiles.contains(file)) { + continue; + } + try { + occurrences.add(new Occurrence(file, Integer.parseInt(fields[1]), fields[2])); + } catch (NumberFormatException e) { + LOG.log(Level.FINE, "skipping git grep row with a non-numeric line number"); + } + } + return List.copyOf(occurrences); + } +} diff --git a/app/src/main/java/app/drydock/review/Provenance.java b/app/src/main/java/app/drydock/review/Provenance.java new file mode 100644 index 00000000..6f89cf16 --- /dev/null +++ b/app/src/main/java/app/drydock/review/Provenance.java @@ -0,0 +1,38 @@ +package app.drydock.review; + +/** + * Where an ordering or a link came from (spec §6.5). + * + *

The two fail in ways a reviewer has to tell apart. A {@link #MEASURED} + * edge fails as a false unique-name match -- two unrelated things sharing a + * name -- and is checkable on the spot by looking; a {@link #CLAIMED} one + * fails as a plausible fabrication and is checkable only against the code the + * agent says it read.

+ * + *

One rendering path, two visibly different warrants -- the treatment + * {@code ReviewIntent.Collapse} already gets, applied consistently.

+ */ +public enum Provenance { + + /** Computed here from the diff, by the rules in §4.2 and §4.3. */ + MEASURED("measured"), + + /** Asserted by the reviewing agent, through {@code review_intents} and its {@code reads}. */ + CLAIMED("claimed"); + + private final String label; + + Provenance(String label) { + this.label = label; + } + + /** What the surface shows beside a marker carrying this warrant. */ + public String label() { + return label; + } + + /** The {@code app.css} modifier class, or none for the ordinary case. */ + public String styleClass() { + return this == CLAIMED ? "provenance-claimed" : ""; + } +} diff --git a/app/src/main/java/app/drydock/review/ReadingPath.java b/app/src/main/java/app/drydock/review/ReadingPath.java new file mode 100644 index 00000000..8ed121b0 --- /dev/null +++ b/app/src/main/java/app/drydock/review/ReadingPath.java @@ -0,0 +1,524 @@ +package app.drydock.review; + +import app.drydock.git.UnifiedDiff; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.SortedSet; +import java.util.TreeMap; +import java.util.TreeSet; + +/** + * The order the change is read in, where to start, and what each hunk has to + * do with the one before it (spec §6). + * + *

Rank inside the sort, not after it. The entry-point + * rank (§6.2) is handed to {@link Graphs#topologicalOrder} as its tie-break, + * so it decides which of the units Kahn could emit next actually goes next. + * Ordering first and marking second would let "the first card" and the + * {@code START HERE} card disagree, and a {@code START HERE} badge sitting + * on card 4 reads as a bug rather than as a design. Marking is therefore not + * a pass at all: the first step emitted is the entry point, by + * construction.

+ * + *

The rank, in full. Out-of-diff fan-in, then in-degree + * within the changed set, then not-a-test, then {@link FallbackIntents}' + * kind order, then the path. That is the whole chain: §6.2's not-a-leaf is + * absent from it, for the reason two paragraphs down. The path is what + * makes it TOTAL, and total is not a nicety here: {@code Graphs} keeps its ready set + * in a {@code TreeSet} ordered by this comparator, so two distinct units + * comparing equal would collapse into one and a unit would silently fall out + * of the path (spec §9.5).

+ * + *

Not-a-test is a tie-break for when the graph is silent rather than an + * override of it: where a test references changed code the edge has already + * placed it and this signal never runs, which leaves it deciding exactly the + * case it should -- a test-only file with nothing pointing into it. It is + * ranked ahead of the kind order deliberately (§6.1): the kind order is what + * the rank degrades to, not one of its signals.

+ * + *

§6.2's fourth signal, not-a-leaf, is NOT in the chain. At file + * granularity a leaf is exactly in-degree zero, so the term ahead of it has + * already decided every case it could decide -- proved analytically and then + * empirically, by 300 generated diffs coming out byte-identical with it + * removed. A comparator step that cannot discriminate asserts a distinction + * that does not exist. It would become real only if "leaf" were redefined at + * unit level, where a cycle's members each have in-degree from inside the + * cycle while the unit as a whole is an endpoint.

+ * + *

One reading order, not two. {@link Sections} orders + * its units by path, having no entry-point rank to consult. This class + * orders by rank. A rail listing sections in the first order while badging + * the entry point computed by the second puts START HERE on card 2 -- the + * same failure the rank-inside-the-sort rule exists to prevent, one level + * up. So {@link #of} returns the section order its own path implies + * together with the numbering that indexes into it, and there is nothing + * left for a consumer to reconcile.

+ * + *

Links are per hunk, on both ends. A link renders as a + * footer row beneath one hunk (§7.2), so a file-level answer spread over a + * file's hunks would put "calls guards.cpp" under hunks that call nothing -- + * a false statement about a specific hunk, which is the one thing a surface + * built on true markers may not ship. {@link ChangeGraph.Hunk} carries the + * finer view and {@link SymbolScan.Symbol} the hunk index it is built + * from.

+ * + *

{@link #of} is string work over an already-built graph, but {@link + * ChangeGraph#of} is blocking -- it parses every changed file and can + * trigger a first-time native grammar load -- so the pair belongs off the FX + * thread.

+ */ +public final class ReadingPath { + + /** A changed symbol this hunk's symbols reference. */ + public static final String CALLS = "calls"; + + /** A changed symbol that references this hunk's symbols. */ + public static final String CALLED_BY = "called by"; + + /** A hunk sharing a changed symbol with this one, neither calling the other. */ + public static final String SAME_CONCEPT = "same concept"; + + /** Above this the circled glyphs run out and the number is spelled. */ + private static final int LAST_CIRCLED = 20; + + private static final char FIRST_CIRCLED = '①'; + + private ReadingPath() { + } + + /** + * One relationship between two hunks in different files. {@code label} + * names files and symbols ({@code ③ SessionReviewScopes.java}) and never + * a raw hunk id -- the id is what the surface acts on, not what it shows. + */ + public record Link(String kind, String targetHunkId, String label, Provenance provenance) { + public Link { + Objects.requireNonNull(kind, "kind"); + Objects.requireNonNull(targetHunkId, "targetHunkId"); + Objects.requireNonNull(label, "label"); + Objects.requireNonNull(provenance, "provenance"); + } + + /** + * A link with no warrant named is MEASURED: everything this class + * builds is (spec §6.4 -- links are facts about the diff, computed + * whoever grouped it). Callers naming it explicitly are the ones that + * could ever differ. + */ + public Link(String kind, String targetHunkId, String label) { + this(kind, targetHunkId, label, Provenance.MEASURED); + } + } + + /** + * One hunk, in reading order. {@code sectionNumber} is the 1-based place + * in {@link Path#sections()} of the first section carrying this hunk -- + * sections overlap by design (§5.6), and a step names the one a reviewer + * meets first. {@code entryPoint} is true for the first step and no + * other. + */ + public record Step(String hunkId, String file, int sectionNumber, String reason, + List links, boolean entryPoint, Provenance provenance) { + public Step { + Objects.requireNonNull(hunkId, "hunkId"); + Objects.requireNonNull(file, "file"); + Objects.requireNonNull(reason, "reason"); + Objects.requireNonNull(provenance, "provenance"); + links = List.copyOf(links); + } + + /** See {@link Link#Link(String, String, String)} -- a step is measured too. */ + public Step(String hunkId, String file, int sectionNumber, String reason, + List links, boolean entryPoint) { + this(hunkId, file, sectionNumber, reason, links, entryPoint, Provenance.MEASURED); + } + } + + /** + * The path: its hunks in reading order, and the sections in the order the + * path reaches them. + * + *

Both, from one call, because there is no such thing as two reading + * orders. {@link Sections} orders its units by path -- it has no + * entry-point rank to consult -- and this class orders by rank, so a rail + * listing sections in {@code Sections} order while badging the entry + * point would put START HERE on card 2. That is the exact failure the + * rank-inside-the-sort rule exists to prevent, one level up. Returning + * the ordering together with the numbering that indexes into it leaves a + * consumer nothing to reconcile: render {@link #sections()} down the + * rail, and {@code step.sectionNumber()} is its 1-based place there, + * which is also the number every reason and label mints.

+ */ + public record Path(List steps, List sections) { + public Path { + steps = List.copyOf(steps); + sections = List.copyOf(sections); + } + } + + /** + * {@code diff}'s hunks in reading order, and {@code sections} in the + * order that path reaches them. Blocking only in the sense its inputs + * are; never call the {@link ChangeGraph#of} that feeds it on the FX + * thread. + * + *

{@code fanIn.unavailable()} is honoured rather than read as zero: a + * scan that could not run contributes no rank, and the reason it writes + * says the outside callers are unknown instead of implying there are + * none.

+ */ + public static Path of(UnifiedDiff diff, ChangeGraph graph, + List sections, OutOfDiffFanIn.Result fanIn) { + Map byPath = new TreeMap<>(); + for (UnifiedDiff.FileDiff file : diff.files()) { + byPath.put(file.path(), file); + } + SortedSet nodes = new TreeSet<>(byPath.keySet()); + if (nodes.isEmpty()) { + return new Path(List.of(), List.copyOf(sections)); + } + + Map fanInByFile = fanInByFile(graph, fanIn); + Comparator rank = rank(graph, fanInByFile); + List> units = + Graphs.topologicalOrder(nodes, file -> dependencies(graph, nodes, file), rank); + + // Hunk order first, because the section order is read off it, and the + // numbering off that. + List hunkIds = new ArrayList<>(); + List files = new ArrayList<>(); + for (List unit : units) { + for (String file : unit) { + UnifiedDiff.FileDiff fileDiff = byPath.get(file); + if (fileDiff == null) { + continue; + } + files.add(file); + for (int index = 0; index < fileDiff.hunks().size(); index++) { + hunkIds.add(ReviewIntent.hunkId(file, index)); + } + } + } + List ordered = sectionOrder(sections, hunkIds); + Map sectionByHunk = sectionNumbers(ordered); + + List steps = new ArrayList<>(); + for (String file : files) { + UnifiedDiff.FileDiff fileDiff = byPath.get(file); + String reason = reasonFor(file, graph, byPath, sectionByHunk, + fanInByFile.getOrDefault(file, 0), fanIn.unavailable()); + for (int index = 0; index < fileDiff.hunks().size(); index++) { + String hunkId = ReviewIntent.hunkId(file, index); + List links = linksFrom(new ChangeGraph.Hunk(file, index), graph, + byPath, sectionByHunk); + steps.add(new Step(hunkId, file, sectionByHunk.getOrDefault(hunkId, 0), + reason, links, steps.isEmpty(), Provenance.MEASURED)); + } + } + return new Path(steps, ordered); + } + + // ---- order -------------------------------------------------------------- + + /** + * The entry-point rank (§6.2), as a TOTAL comparator over changed files: + * fan-in from outside the change, then in-degree within it, then + * not-a-test, then the kind order, then the path. §6.2's not-a-leaf is + * absent on purpose: see the class javadoc. + * + *

Counts are negated rather than reversed so the whole chain reads in + * one direction: smaller is earlier.

+ */ + private static Comparator rank(ChangeGraph graph, Map fanInByFile) { + return Comparator + .comparingInt((String file) -> -fanInByFile.getOrDefault(file, 0)) + .thenComparingInt(file -> -graph.filesReferencing(file).size()) + .thenComparingInt(file -> FallbackIntents.isTestPath(file) ? 1 : 0) + .thenComparingInt( + file -> FallbackIntents.readingOrder(FallbackIntents.kindOf(file))) + .thenComparing(Comparator.naturalOrder()); + } + + /** + * What {@code file} has to be read after. Intersected with {@code nodes} + * on the way out: {@link Graphs#topologicalOrder} rejects an edge that + * leaves the node set, and a graph built from a different diff than the + * one being walked would otherwise take the whole path down. + */ + private static SortedSet dependencies(ChangeGraph graph, SortedSet nodes, + String file) { + SortedSet targets = new TreeSet<>(graph.filesReferencedBy(file)); + targets.retainAll(nodes); + return targets; + } + + /** + * How many places outside the change use each file's changed + * declarations. Iterated over the graph's sorted declarations rather than + * over {@code fanIn.bySymbol()}, whose iteration order is the caller's to + * choose and therefore not something determinism may rest on. + */ + private static Map fanInByFile(ChangeGraph graph, + OutOfDiffFanIn.Result fanIn) { + Map counts = new TreeMap<>(); + for (String symbol : graph.changedDeclarations()) { + List occurrences = fanIn.bySymbol().get(symbol); + if (occurrences == null || occurrences.isEmpty()) { + continue; + } + graph.fileDeclaring(symbol) + .ifPresent(file -> counts.merge(file, occurrences.size(), Integer::sum)); + } + return counts; + } + + // ---- reasons ------------------------------------------------------------ + + /** + * Why this file sits where it does, in the words §7.1 puts on the row. + * Ordered as the rank is, so the reason names the signal that actually + * placed it. + */ + private static String reasonFor(String file, ChangeGraph graph, + Map byPath, + Map sectionByHunk, + int fanIn, boolean fanInUnavailable) { + if (fanIn > 0) { + return "called from " + fanIn + (fanIn == 1 ? " place" : " places") + + " outside the change"; + } + int own = sectionOfFile(file, byPath, sectionByHunk); + SortedSet dependents = graph.filesReferencing(file); + if (!dependents.isEmpty()) { + return "referenced by " + markers(dependents, own, byPath, sectionByHunk); + } + SortedSet dependencies = graph.filesReferencedBy(file); + if (!dependencies.isEmpty()) { + return "builds on " + markers(dependencies, own, byPath, sectionByHunk); + } + String silence = FallbackIntents.isTestPath(file) + ? "test, referenced by nothing in the change" + : "nothing in the change references it"; + // An unavailable scan is not a scan that found nothing (§4.3): this + // is the one reason that would otherwise read as "and nothing outside + // it either", which was never measured. + return fanInUnavailable ? silence + ", outside callers unknown" : silence; + } + + /** + * {@code ③, ⑤} for a set of files: where they sit in the rail. + * + *

A file in {@code own} -- the section the reason is being written for + * -- is named instead of numbered. "referenced by ①" on a row that is + * itself in ① tells a reviewer nothing, and sections carry the files + * their unit depends on (§5.2), so an edge inside one section is the + * common case rather than the corner. Naming is also the fallback when a + * section number cannot be had at all, so a reason is never a bare + * count.

+ */ + private static String markers(SortedSet files, int own, + Map byPath, + Map sectionByHunk) { + Set rendered = new LinkedHashSet<>(); + for (String file : files) { + int number = sectionOfFile(file, byPath, sectionByHunk); + rendered.add(number > 0 && number != own + ? marker(number) + : FallbackIntents.fileName(file)); + } + return String.join(", ", rendered); + } + + // ---- links -------------------------------------------------------------- + + /** + * {@code hunk}'s links, cross-file and deduplicated by target hunk. + * + *

Per hunk, not per file. A link renders as a footer row beneath one + * hunk (§7.2), so "calls guards.cpp" under a hunk that references + * nothing is a false statement about that hunk -- not a loose one about + * the file -- and this surface is worth having only while its markers + * state true things.

+ * + *

Kinds are emitted in a fixed order -- calls, called by, same + * concept -- and the first one to claim a target hunk keeps it. That is + * what "deduplicated by target hunk" has to mean for a pair that is + * both: the call is the more specific thing to say. It is also why + * same-concept ends up meaning what §2.2 wants -- two hunks that use the + * same thing, neither declaring it -- rather than restating every edge. + * Two hunks that genuinely reference each other therefore show one link + * rather than two; the cycle that makes is named by its section (§6.1), + * which is where a mutual dependency belongs on this surface.

+ */ + private static List linksFrom(ChangeGraph.Hunk hunk, ChangeGraph graph, + Map byPath, + Map sectionByHunk) { + SortedSet declared = graph.declarationsIn(hunk); + SortedSet referenced = graph.referencesIn(hunk); + + Map> calls = new TreeMap<>(); + for (String symbol : referenced) { + for (ChangeGraph.Hunk target : graph.hunksDeclaring(symbol)) { + calls.computeIfAbsent(target, key -> new TreeSet<>()).add(symbol); + } + } + Map> calledBy = new TreeMap<>(); + for (String symbol : declared) { + for (ChangeGraph.Hunk source : graph.hunksReferencingSymbol(symbol)) { + calledBy.computeIfAbsent(source, key -> new TreeSet<>()).add(symbol); + } + } + // A hunk touches a symbol by declaring it or by referencing it; an + // unresolvable name touches nothing, because neither lookup below + // knows it -- the same test an edge passes (§4.2). + Map> shared = new TreeMap<>(); + SortedSet touched = new TreeSet<>(declared); + touched.addAll(referenced); + for (String symbol : touched) { + SortedSet touching = new TreeSet<>(graph.hunksDeclaring(symbol)); + touching.addAll(graph.hunksReferencingSymbol(symbol)); + for (ChangeGraph.Hunk other : touching) { + if (!other.file().equals(hunk.file())) { + shared.computeIfAbsent(other, key -> new TreeSet<>()).add(symbol); + } + } + } + + List links = new ArrayList<>(); + Set claimed = new LinkedHashSet<>(); + // The symbol is declared in the target, so the label may point at it. + emit(links, claimed, byPath, sectionByHunk, CALLS, calls, graph, ":"); + // The symbols live HERE, not in the target, so the label says what + // the target does with them rather than pointing into it. + emit(links, claimed, byPath, sectionByHunk, CALLED_BY, calledBy, graph, " · uses "); + emit(links, claimed, byPath, sectionByHunk, SAME_CONCEPT, shared, graph, + " · both touch "); + return List.copyOf(links); + } + + private static void emit(List links, Set claimed, + Map byPath, + Map sectionByHunk, String kind, + Map> targets, + ChangeGraph graph, String relation) { + for (Map.Entry> target : targets.entrySet()) { + ChangeGraph.Hunk to = target.getKey(); + UnifiedDiff.FileDiff targetDiff = byPath.get(to.file()); + if (targetDiff == null || to.index() >= targetDiff.hunks().size()) { + // Nothing to click through to; a link to no hunk is a dead row. + continue; + } + String hunkId = ReviewIntent.hunkId(to.file(), to.index()); + if (!claimed.add(hunkId)) { + continue; + } + String marker = marker(sectionByHunk.getOrDefault(hunkId, 0)); + String label = (marker.isEmpty() ? "" : marker + " ") + + FallbackIntents.fileName(to.file()) + + relation + best(target.getValue(), graph); + links.add(new Link(kind, hunkId, label, Provenance.MEASURED)); + } + } + + /** + * Which of several shared names to put on one label: the one the most + * changed files reference, then the name itself. A label has room for one + * symbol, and the one the relationship is most about is the useful one -- + * picking alphabetically would name whichever happened to sort first. + */ + private static String best(SortedSet symbols, ChangeGraph graph) { + String chosen = null; + int fanIn = -1; + for (String symbol : symbols) { + int uses = graph.filesReferencingSymbol(symbol).size(); + if (uses > fanIn) { + chosen = symbol; + fanIn = uses; + } + } + return chosen; + } + + // ---- sections ----------------------------------------------------------- + + /** + * {@code sections} in the order the path first reaches them. + * + *

A section's place is decided by its earliest hunk in the path, so + * the entry point's section is card 1 and START HERE sits on it in both + * dimensions -- the same construction that makes the first STEP the entry + * point. Ties go to the order {@link Sections} produced, so two sections + * first reached by the same hunk keep their relative order. A section the + * path never reaches -- one carrying no hunk of this diff -- is appended + * rather than dropped: a card falling out of the rail is worse than one + * sitting at the end of it.

+ */ + private static List sectionOrder(List sections, + List hunkIds) { + Map> carrying = new TreeMap<>(); + for (int index = 0; index < sections.size(); index++) { + for (String hunkId : sections.get(index).hunkIds()) { + carrying.computeIfAbsent(hunkId, key -> new TreeSet<>()).add(index); + } + } + Set placed = new LinkedHashSet<>(); + for (String hunkId : hunkIds) { + SortedSet here = carrying.get(hunkId); + if (here != null) { + placed.addAll(here); + } + } + for (int index = 0; index < sections.size(); index++) { + placed.add(index); + } + List ordered = new ArrayList<>(); + for (Integer index : placed) { + ordered.add(sections.get(index)); + } + return ordered; + } + + /** + * Each hunk's section number, 1-based over the READING order. Sections + * overlap (§5.6), so a hunk can be in several; the one the reviewer meets + * first wins. + */ + private static Map sectionNumbers(List sections) { + Map numbers = new TreeMap<>(); + for (int index = 0; index < sections.size(); index++) { + for (String hunkId : sections.get(index).hunkIds()) { + numbers.putIfAbsent(hunkId, index + 1); + } + } + return numbers; + } + + private static int sectionOfFile(String file, Map byPath, + Map sectionByHunk) { + UnifiedDiff.FileDiff fileDiff = byPath.get(file); + if (fileDiff == null || fileDiff.hunks().isEmpty()) { + return 0; + } + return sectionByHunk.getOrDefault(ReviewIntent.hunkId(file, 0), 0); + } + + /** + * {@code ③} for 3. Past the twenty glyphs Unicode circles, {@code #21} -- + * a rail that long is not the case this notation is for, and inventing a + * fallback glyph would be worse than saying the number. + */ + private static String marker(int number) { + if (number <= 0) { + return ""; + } + return number <= LAST_CIRCLED + ? String.valueOf((char) (FIRST_CIRCLED + number - 1)) + : "#" + number; + } +} diff --git a/app/src/main/java/app/drydock/review/RecheckAssessment.java b/app/src/main/java/app/drydock/review/RecheckAssessment.java new file mode 100644 index 00000000..55730da4 --- /dev/null +++ b/app/src/main/java/app/drydock/review/RecheckAssessment.java @@ -0,0 +1,53 @@ +package app.drydock.review; + +import java.time.Instant; +import java.util.Objects; + +/** + * An agent's statement about whether one base move affects one approved hunk + * (spec §9.7). + * + *

Keyed by the base PAIR it was made about: a later base move is a new + * question, and carrying an old answer forward would be the agent answering + * something it was never asked.

+ * + *

Only {@code affected == true} has an effect. An agent may add staleness + * -- that only ever asks for more reading, and it closes the blind spot + * {@link BaseMove} admits to in its own class comment -- but it may never + * clear an approval, which is the line the whole MCP surface is drawn + * around. It is the asymmetry {@link VerdictMerge} already keeps for a + * section's decision (any CHANGES wins; APPROVED needs every hunk), pointed + * at a different question.

+ * + *

{@code hunkDigest} is a content digest ({@link HunkDigest}), not the + * positional {@code h__} an agent addresses a hunk by on the + * wire: the two are different things, and the translation between them is + * the MCP codec's job. Storing the positional id would strand every + * assessment the moment the diff re-hunked.

+ */ +public record RecheckAssessment(String scopeId, String hunkDigest, String fromBase, String toBase, + boolean affected, String why, Instant at) { + + public RecheckAssessment { + Objects.requireNonNull(scopeId, "scopeId"); + Objects.requireNonNull(hunkDigest, "hunkDigest"); + Objects.requireNonNull(fromBase, "fromBase"); + Objects.requireNonNull(toBase, "toBase"); + Objects.requireNonNull(why, "why"); + Objects.requireNonNull(at, "at"); + } + + /** {@code (scopeId, hunkDigest, fromBase, toBase)}. */ + public record Key(String scopeId, String hunkDigest, String fromBase, String toBase) { + public Key { + Objects.requireNonNull(scopeId, "scopeId"); + Objects.requireNonNull(hunkDigest, "hunkDigest"); + Objects.requireNonNull(fromBase, "fromBase"); + Objects.requireNonNull(toBase, "toBase"); + } + } + + public Key key() { + return new Key(scopeId, hunkDigest, fromBase, toBase); + } +} diff --git a/app/src/main/java/app/drydock/review/RecheckDispatch.java b/app/src/main/java/app/drydock/review/RecheckDispatch.java new file mode 100644 index 00000000..472ed9d8 --- /dev/null +++ b/app/src/main/java/app/drydock/review/RecheckDispatch.java @@ -0,0 +1,58 @@ +package app.drydock.review; + +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; + +/** + * Which base moves have already had an automatic recheck sent for them + * (spec §9.7), so a move earns one dispatch rather than one per render. + * + *

{@link AnnotationStore#assessedAffected} cannot answer this. It returns + * false both for "the agent said unaffected" and for "the agent was never + * asked" -- deliberately, since only true may add staleness -- so it cannot + * distinguish a dispatch still in flight from one that never happened. A + * board re-renders whenever a background git answer lands, and every one of + * those renders falls inside that window. Deduplicating on the store alone + * would therefore dispatch a subagent per render, which is worse than the + * per-base-move flood it was meant to prevent.

+ * + *

Confined to the FX thread, like the base-move memo it sits beside; no + * synchronization, for the same reason.

+ */ +public final class RecheckDispatch { + + /** + * The three parts as a value, not as a joined string. A separator has to + * be argued about -- some byte must be impossible in a scope handle -- and + * a record removes the argument: {@code ("s-a","b","c")} and + * {@code ("s","a-b","c")} are distinct by construction. + */ + private record Move(String scopeId, String fromBase, String toBase) { + Move { + Objects.requireNonNull(scopeId, "scopeId"); + Objects.requireNonNull(fromBase, "fromBase"); + Objects.requireNonNull(toBase, "toBase"); + } + } + + private final Set dispatched = new LinkedHashSet<>(); + + /** + * True exactly once per {@code (scopeId, fromBase, toBase)} -- the caller + * that gets true owns sending this move's recheck. + */ + public boolean claim(String scopeId, String fromBase, String toBase) { + return dispatched.add(new Move(scopeId, fromBase, toBase)); + } + + /** + * Forgets a claim whose hand-off did not happen, so the move can be + * dispatched again later. A send that returned false reached no + * terminal; remembering it as done would cost the scope its recheck + * entirely, with no human present to notice. + */ + public void release(String scopeId, String fromBase, String toBase) { + dispatched.remove(new Move(scopeId, fromBase, toBase)); + } +} diff --git a/app/src/main/java/app/drydock/review/ReviewInstructions.java b/app/src/main/java/app/drydock/review/ReviewInstructions.java index f1bf4412..596f20bd 100644 --- a/app/src/main/java/app/drydock/review/ReviewInstructions.java +++ b/app/src/main/java/app/drydock/review/ReviewInstructions.java @@ -29,4 +29,32 @@ public static String forScope(String scopeId, boolean supportsSubagents) { + work + ". Report only its summary back here." : "Review the changes in this worktree with the drydock review tools: " + work + "."; } + + /** + * What drydock asks when a base move has marked approvals stale (spec + * §9.7). Bounded on purpose: the base delta and the stale hunks, not the + * change. + * + *

Says outright that "unaffected" does not clear an approval. An agent + * should be told the rule rather than left to infer it from what {@code + * review_recheck} happens to refuse.

+ * + *

Only the subagent form, unlike {@link #forScope}: spec §9.7 gives an + * automatic recheck only to a harness that has subagents, so an inline + * form here would be a branch nothing could reach.

+ */ + public static String forRecheck(String scopeId, String fromBase, String toBase) { + Objects.requireNonNull(scopeId, "scopeId"); + // Both bases too: they are concatenated, so a null would reach the + // agent as the literal "null" in a line typed at its prompt. + Objects.requireNonNull(fromBase, "fromBase"); + Objects.requireNonNull(toBase, "toBase"); + String work = "for handle " + scopeId + ", read what changed between " + fromBase + + " and " + toBase + ", and for each approved hunk it could affect call " + + "review_recheck with affected and a one-line why. Marking a hunk affected " + + "asks the human to read it again; marking one unaffected is advice and " + + "does not clear their approval"; + return "Dispatch a subagent to recheck stale approvals: " + work + + ". Report only its summary back here."; + } } diff --git a/app/src/main/java/app/drydock/review/ReviewIntent.java b/app/src/main/java/app/drydock/review/ReviewIntent.java index 4e261f4c..9ca63484 100644 --- a/app/src/main/java/app/drydock/review/ReviewIntent.java +++ b/app/src/main/java/app/drydock/review/ReviewIntent.java @@ -13,6 +13,13 @@ * with no {@code review_intents} call the UI falls back to one intent per * file (schema §2), which is what keeps the verdict bar meaningful with no * reviewer configured.

+ * + *

{@link #reads()} is the agent's own dependency order: the intents this + * one is built on. drydock renders the assertion and never verifies it, + * which is the {@link Collapse} precedent (spec §8) -- what it does with it + * is order the rail foundation first ({@link IntentGrouping#set}), so the + * reading order is one the agent asserted rather than one drydock + * computed.

*/ public record ReviewIntent( String id, @@ -23,7 +30,8 @@ public record ReviewIntent( String rationale, List hunkIds, Optional collapse, - boolean autoApprove) { + boolean autoApprove, + List reads) { /** What kind of change this intent is; drives the tag beside its title. */ public enum Kind { @@ -93,6 +101,25 @@ public record Collapse(String reason, String evidence, int hunkCount, int fileCo throw new IllegalArgumentException("intent id must not be blank"); } hunkIds = List.copyOf(Objects.requireNonNull(hunkIds, "hunkIds")); + reads = List.copyOf(Objects.requireNonNull(reads, "reads")); + } + + /** + * The same intent with nothing declared about what it is built on -- + * {@code reads} is optional on the wire, and the several dozen callers + * that predate it have no opinion about it. They say so once, here, + * rather than each spelling an empty list, which would be a wide edit + * carrying no new decision. + * + *

Every PRODUCTION site names {@code reads} explicitly through the + * canonical constructor even when it passes {@link List#of()}, so + * "declares nothing" is a choice made and visible at each one rather + * than a default it fell into by still compiling.

+ */ + public ReviewIntent(String id, int number, String title, Kind kind, Risk risk, + String rationale, List hunkIds, Optional collapse, + boolean autoApprove) { + this(id, number, title, kind, risk, rationale, hunkIds, collapse, autoApprove, List.of()); } /** @@ -180,7 +207,15 @@ public Optional anchor() { return Optional.empty(); } - private static Optional parseHunkId(String hunkId) { + /** + * The inverse of {@link #hunkId}: {@code file} and {@code index} back out + * of a raw hunk id, or empty for anything not shaped like one. Public so + * a caller that only HAS a hunk id -- {@link + * app.drydock.review.ReadingPath.Link#targetHunkId()}, most notably -- + * can resolve it without building a throwaway one-hunk {@link + * ReviewIntent} purely to call {@link #anchor()} on it. + */ + public static Optional parseHunkId(String hunkId) { if (hunkId == null || !hunkId.startsWith(HUNK_ID_PREFIX)) { return Optional.empty(); } diff --git a/app/src/main/java/app/drydock/review/ReviewVerdict.java b/app/src/main/java/app/drydock/review/ReviewVerdict.java index 0afdb0f5..556f2b1f 100644 --- a/app/src/main/java/app/drydock/review/ReviewVerdict.java +++ b/app/src/main/java/app/drydock/review/ReviewVerdict.java @@ -6,12 +6,21 @@ import java.util.Optional; /** - * The human's decision on one intent (Review handoff §7): keyed by - * {@code (scopeId, intentId)}, because intent ids repeat across scopes for - * the same reason finding ids do. + * The human's decision on one hunk of a diff (Review handoff §7; spec §9.2): + * keyed by {@code (scopeId, hunkDigest)}, because an agent can regroup the + * diff into different intents at any time and a verdict keyed on a grouping + * would be orphaned by that regrouping. A digest over the hunk's own text + * survives regrouping unchanged. + * + *

A digest cannot see the base commit move underneath it -- a rebase + * leaves every hunk byte-identical while the code it sits on changed -- so + * the {@code (baseCommit, headCommit)} this was judged against is recorded + * alongside it, and {@link #staleAgainst} derives whether the base has since + * moved.

*/ -public record ReviewVerdict(String scopeId, String intentId, Decision decision, - Optional note, Instant at) { +public record ReviewVerdict(String scopeId, String hunkDigest, Decision decision, + Optional note, Instant at, + String baseCommit, String headCommit) { /** What was decided. {@code AUTO_APPROVED} is the agent's own assertion, not the human's. */ public enum Decision { @@ -54,24 +63,47 @@ public static Optional fromWire(String raw) { public ReviewVerdict { Objects.requireNonNull(scopeId, "scopeId"); - Objects.requireNonNull(intentId, "intentId"); + Objects.requireNonNull(hunkDigest, "hunkDigest"); Objects.requireNonNull(decision, "decision"); Objects.requireNonNull(note, "note"); Objects.requireNonNull(at, "at"); - if (scopeId.isBlank() || intentId.isBlank()) { - throw new IllegalArgumentException("a verdict is keyed by (scopeId, intentId); neither may be blank"); + Objects.requireNonNull(baseCommit, "baseCommit"); + Objects.requireNonNull(headCommit, "headCommit"); + if (scopeId.isBlank() || hunkDigest.isBlank()) { + throw new IllegalArgumentException( + "a verdict is keyed by (scopeId, hunkDigest); neither may be blank"); } } public Key key() { - return new Key(scopeId, intentId); + return new Key(scopeId, hunkDigest); } - /** {@code (scopeId, intentId)} -- intent ids repeat across scopes. */ - public record Key(String scopeId, String intentId) { + /** {@code (scopeId, hunkDigest)} -- a hunk's content is its identity (spec §9.2). */ + public record Key(String scopeId, String hunkDigest) { public Key { Objects.requireNonNull(scopeId, "scopeId"); - Objects.requireNonNull(intentId, "intentId"); + Objects.requireNonNull(hunkDigest, "hunkDigest"); } } + + /** + * Whether the base has moved since this was given. Only a candidate for + * staleness: whether the move could actually matter is + * {@link BaseMove}'s question, not this record's. + */ + public boolean staleAgainst(String currentBase) { + return !baseCommit.equals(currentBase); + } + + /** + * "Confirm still good": the same decision, re-dated, recorded against the + * base it has now been judged against. Rewriting the base rather than + * storing a confirmed flag keeps one source of truth for staleness -- + * a flag would have to be cleared by the next base move, and forgetting + * to is a silently-approved-stale-code bug. + */ + public ReviewVerdict confirmedAgainst(String currentBase, String currentHead, Instant when) { + return new ReviewVerdict(scopeId, hunkDigest, decision, note, when, currentBase, currentHead); + } } diff --git a/app/src/main/java/app/drydock/review/Sections.java b/app/src/main/java/app/drydock/review/Sections.java new file mode 100644 index 00000000..368418ab --- /dev/null +++ b/app/src/main/java/app/drydock/review/Sections.java @@ -0,0 +1,637 @@ +package app.drydock.review; + +import app.drydock.git.UnifiedDiff; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.SortedSet; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * The change's sections: units of the file-level reference graph in + * dependency order, each carrying the foundation it is read against + * (spec §5.2). + * + *

The failure this replaces, measured on a real C++ change: cards reading + * {@code main/cpp · 12 files}, {@code test/cpp · 4 files}, + * {@code cpp/hotspot · 6 files}. Each individually correct, the rail as a + * whole saying nothing, because (kind, directory) has no structural input at + * all.

+ * + *

How a section is formed. Three edge kinds go in. A + * reference edge (from {@link ChangeGraph}) and an include + * edge are directed: the file that names another depends on it. Two + * conventions are symmetric, and being symmetric is what makes them + * merge rather than merely relate -- a file and its same-basename + * counterpart, and a file that declares no changed symbol of its own but is + * pulled in by exactly one changed file (the {@code counters.h} case). + * {@link Graphs#topologicalOrder} then condenses that graph: a symmetric + * pair is one unit because each is the other's prerequisite, and genuine + * mutual references collapse the same way, which is why {@link + * Section#cycleWith()} is recomputed from the directed edges alone rather + * than read off the unit -- a convention-joined pair is one thing, not a + * cycle, and telling a reviewer otherwise is a lie they would act on.

+ * + *

Sections overlap. A section carries the files its own + * members depend on, so a shared header appears in every section that needs + * it to be understood; with disjoint membership one of those would have to + * lose. Dependents are deliberately NOT pulled in: a change cannot be read + * without its foundation, but it can be read without knowing who calls it, + * and every caller gets its own section further down the rail. The reviewed + * flag is keyed to hunk content, so a file shown three times is still read + * once (spec §5.6, §9).

+ * + *

What a card says. A section is named after the changed + * symbol its unit declares that most looks like the thing it is about -- + * type-shaped first, fan-in only breaking ties within a shape, because fan-in + * alone titles cards after loop variables. A unit that declares nothing + * nameable is named after its own most substantial file, never after a + * directory: the grouping is not directory-derived, so a directory title + * misdescribes it. No two cards may read the same, which {@link + * FallbackIntents} guarantees and this has to guarantee too.

+ * + *

Tests are NOT split out. A test references the symbol under test, so + * the graph already places it; splitting on {@code /test/} would be a path + * heuristic drawing a boundary through a structurally sound group, which is + * the very failure this class replaces.

+ * + *

{@link #of} itself is string work over an already-built graph, but + * {@link ChangeGraph#of} is blocking (it parses every changed file and can + * trigger a first-time native grammar load), so the pair belongs off the FX + * thread.

+ */ +public final class Sections { + + /** One section. {@code cycleWith} is non-empty when it is part of a dependency cycle. */ + public record Section(String title, List files, List hunkIds, + Optional hubSymbol, List cycleWith) { + public Section { + Objects.requireNonNull(title, "title"); + Objects.requireNonNull(hubSymbol, "hubSymbol"); + files = List.copyOf(files); + hunkIds = List.copyOf(hunkIds); + cycleWith = List.copyOf(cycleWith); + } + } + + /** + * A C/C++ include. Anchored at the start of the line so a {@code //} + * comment, a doc block's {@code *} margin and a string literal cannot + * match; the captured token is a path, extension and all. + */ + private static final Pattern INCLUDE = + Pattern.compile("^\\s*#\\s*include\\s*[<\"]([^>\"]+)[>\"]"); + + /** + * A quoted module: JavaScript and TypeScript's {@code from './x'}, + * {@code require('./x')} and bare {@code import './x'}. The keyword has + * to sit immediately before the quote, so prose naming a file does not + * match. + */ + private static final Pattern QUOTED_MODULE = + Pattern.compile("(?:\\bfrom|\\brequire\\s*\\(|^\\s*import)\\s*[\"']([^\"']+)[\"']"); + + /** + * A dotted or {@code ::}-separated module: Java/Kotlin {@code import}, + * Python {@code import}/{@code from}, Rust {@code use}/{@code mod}. + * Anchored, and the token must start like an identifier so the quoted + * forms above fall to {@link #QUOTED_MODULE} instead. + */ + private static final Pattern SYMBOLIC_MODULE = + Pattern.compile("^\\s*(?:import|from|use|mod)\\s+([A-Za-z_$][\\w.:$]*)"); + + private Sections() { + } + + /** {@code diff}'s sections, in reading order. */ + public static List
of(UnifiedDiff diff, ChangeGraph graph) { + SortedSet nodes = new TreeSet<>(); + for (UnifiedDiff.FileDiff file : diff.files()) { + nodes.add(file.path()); + } + + Map> includes = includeEdges(diff, nodes); + Map> depends = dependencyEdges(graph, nodes, includes); + Map> merges = conventionEdges(graph, nodes, includes); + + if (isEmpty(depends) && isEmpty(merges)) { + // Nothing structural to consult: today's (kind, directory) + // clustering is still the best available guess, and saying so is + // better than inventing structure that is not there. + return fromFallback(diff); + } + + Function> dependsOn = file -> { + SortedSet all = new TreeSet<>(depends.get(file)); + all.addAll(merges.get(file)); + return all; + }; + List> units = + Graphs.topologicalOrder(nodes, dependsOn, Comparator.naturalOrder()); + + // Reading position: where each file's own unit sits in the rail. A + // section lists its files by this, not alphabetically, so the file + // being depended on is read before the file using it. + Map position = new TreeMap<>(); + for (int index = 0; index < units.size(); index++) { + for (String file : units.get(index)) { + position.put(file, index); + } + } + Comparator readingOrder = (left, right) -> { + int byUnit = Integer.compare(position.get(left), position.get(right)); + return byUnit != 0 ? byUnit : left.compareTo(right); + }; + + Map byPath = new TreeMap<>(); + for (UnifiedDiff.FileDiff file : diff.files()) { + byPath.put(file.path(), file); + } + + List drafts = new ArrayList<>(); + for (List unit : units) { + SortedSet members = new TreeSet<>(unit); + for (String file : unit) { + members.addAll(depends.get(file)); + } + List ordered = new ArrayList<>(members); + ordered.sort(readingOrder); + drafts.add(new Draft(ordered, hunkIdsOf(byPath, ordered), + hubOf(unit, ordered, graph), cyclesIn(unit, depends), + primaryOf(unit, byPath))); + } + return titled(readable(drafts)); + } + + /** + * A section before it is named. Titling needs the whole rail in hand -- + * no two cards may read the same -- so it cannot happen while the + * sections are still being built one at a time. + * + *

{@code primary} is the unit's own most substantial file, and it is + * what names a section no symbol can name. Units are disjoint, so no two + * drafts can carry the same primary, which is what makes the + * disambiguation in {@link #titled} terminate rather than merely + * usually work.

+ */ + private record Draft(List files, List hunkIds, Optional hub, + List cycleWith, String primary) { + } + + /** + * The rail, minus the cards that say nothing. A unit with no symbol to + * name it -- a header declaring nothing of its own, pulled in by two + * changed files, so neither may claim it -- would otherwise get a card + * titled after its directory, which is the exact failure this class + * replaces, sitting next to the sections that already carry the file. + * It is dropped only when some other section carries all of it, so no + * hunk can fall out of the rail; of two sections carrying the same + * files, the one that reads first is the one kept. + */ + private static List readable(List drafts) { + List visible = new ArrayList<>(); + for (int index = 0; index < drafts.size(); index++) { + Draft draft = drafts.get(index); + if (draft.hub().isEmpty() && coveredByAnother(drafts, index)) { + continue; + } + visible.add(draft); + } + return List.copyOf(visible); + } + + private static boolean coveredByAnother(List drafts, int index) { + List files = drafts.get(index).files(); + for (int other = 0; other < drafts.size(); other++) { + if (other == index || !drafts.get(other).files().containsAll(files)) { + continue; + } + if (drafts.get(other).files().size() > files.size() || other < index) { + return true; + } + } + return false; + } + + // ---- edges -------------------------------------------------------------- + + /** + * Which changed files each file pulls in by name. This is what puts a + * header with no changed symbol of its own in the right section, so it + * has to recognise a dependency rather than a mention: only a line + * SHAPED like an include or an import counts, and the name it carries + * has to match the whole of the other file's name, never a substring of + * it. + */ + private static Map> includeEdges(UnifiedDiff diff, + SortedSet nodes) { + Map> result = emptyEdges(nodes); + for (UnifiedDiff.FileDiff file : diff.files()) { + SortedSet named = result.get(file.path()); + for (UnifiedDiff.Hunk hunk : file.hunks()) { + for (UnifiedDiff.Line line : hunk.lines()) { + // Deleted include lines count too: a dependency being + // removed is part of the same piece of work as what + // replaced it, and dropping it would strand the file. + for (Reference reference : referencesOn(line.text())) { + for (String other : nodes) { + if (!other.equals(file.path()) && reference.names(other)) { + named.add(other); + } + } + } + } + } + } + return result; + } + + /** Directed edges: what a file has to be read against. */ + private static Map> dependencyEdges( + ChangeGraph graph, SortedSet nodes, Map> includes) { + Map> result = emptyEdges(nodes); + for (String file : nodes) { + SortedSet targets = result.get(file); + targets.addAll(graph.filesReferencedBy(file)); + targets.addAll(includes.get(file)); + targets.remove(file); + // Graphs.topologicalOrder rejects an edge pointing outside its + // node set rather than dropping it, so the graph being built + // from a different diff than the one passed in must be filtered + // here, not discovered as an exception three frames down. + targets.retainAll(nodes); + } + return result; + } + + /** + * Symmetric edges: the two claims that two files are ONE thing rather + * than two related things, which is what makes them share a unit. + * + *

Same basename, different extension, and either in the same + * directory or already joined by an include -- {@code guards.h} and + * {@code guards.cpp}, not {@code a/util.py} and {@code b/util.rb}. And a + * file that declares no changed symbol of its own, pulled in by exactly + * one changed file: it has nothing of its own to be a section about, and + * exactly one place it belongs. Pulled in by two, it is a shared + * foundation instead, and appears in both their sections.

+ */ + private static Map> conventionEdges( + ChangeGraph graph, SortedSet nodes, Map> includes) { + Map> result = emptyEdges(nodes); + for (String left : nodes) { + for (String right : nodes) { + if (left.compareTo(right) >= 0 || !sameComponentByName(left, right, includes)) { + continue; + } + result.get(left).add(right); + result.get(right).add(left); + } + } + for (String file : nodes) { + if (!graph.declarationsIn(file).isEmpty()) { + continue; + } + List pullers = new ArrayList<>(); + for (String other : nodes) { + if (!other.equals(file) && includes.get(other).contains(file)) { + pullers.add(other); + } + } + if (pullers.size() == 1) { + result.get(file).add(pullers.get(0)); + result.get(pullers.get(0)).add(file); + } + } + return result; + } + + private static boolean sameComponentByName(String left, String right, + Map> includes) { + return stem(FallbackIntents.fileName(left)).equals(stem(FallbackIntents.fileName(right))) + && !extension(left).equals(extension(right)) + && (FallbackIntents.directoryOf(left).equals(FallbackIntents.directoryOf(right)) + || includes.get(left).contains(right) + || includes.get(right).contains(left)); + } + + // ---- naming ------------------------------------------------------------- + + /** + * What the section is about: the most promising changed symbol its own + * unit declares. + * + *

Fan-in alone is not it. Measured on this branch's own 54-file diff, + * ranking by fan-in titled cards {@code hunk}, {@code isEmpty}, + * {@code has} and {@code files} -- loop variables and one-line accessors + * whose names simply recur in many files -- while the names a reviewer + * would recognise ({@code BaseMove}, {@code HunkDigest}, + * {@code ChangeGraph}) sat one or two references below them. A + * type is what a group of files is about; a member name is what + * they happen to have in common. So a type-shaped name outranks any + * member name, and fan-in only breaks ties within a shape.

+ * + *

Type-shaped means an initial capital. That is a naming convention + * rather than a fact from the parse tree -- it is right for Java, + * Kotlin, C++, Go, Rust, TypeScript and Python types, and wrong for a C + * codebase spelling structs in lower case, which lands on the member + * name it would have picked anyway.

+ * + *

Fan-in is counted twice: within the section (how central the name + * is to what this card shows) and across the change. A foundation + * section holds only itself -- its referencing files are, by + * construction, in the sections further down the rail -- so requiring an + * in-section reference would leave exactly the cards that name real hubs + * unnamed. In-section count therefore ranks, and the change-wide count + * is what a candidate has to have any of.

+ * + *

Only the unit's own files are candidates. The foundation a section + * carries for context is what some other section is about, and naming + * this one after it would give two cards the same title.

+ */ + private static Optional hubOf(List unit, List sectionFiles, + ChangeGraph graph) { + SortedSet declarations = new TreeSet<>(); + for (String file : unit) { + declarations.addAll(graph.declarationsIn(file)); + } + if (declarations.isEmpty()) { + return Optional.empty(); + } + SortedSet section = new TreeSet<>(sectionFiles); + List referenced = new ArrayList<>(); + for (String symbol : declarations) { + SortedSet referencing = new TreeSet<>(graph.filesReferencingSymbol(symbol)); + int across = referencing.size(); + referencing.retainAll(section); + if (across > 0) { + referenced.add(new Candidate(symbol, typeShaped(symbol), + referencing.size(), across)); + } + } + if (!referenced.isEmpty()) { + referenced.sort(Sections::byPromise); + return Optional.of(referenced.get(0).name()); + } + // Nothing here is referenced at all, so there is no hub to measure -- + // only a name to recognise. One declaration is unambiguous; a name + // matching the unit's own file name is the file's subject by + // convention; a lone type among functions is the thing the functions + // are for. That last rung is what titles the guards.h/guards.cpp + // pair "JmpCtxScope" instead of after its folder, which is the case + // this class was commissioned to fix. + if (declarations.size() == 1) { + return Optional.of(declarations.first()); + } + Optional named = onlyOne(declarations, symbol -> matchesFileName(symbol, unit)); + return named.isPresent() ? named : onlyOne(declarations, Sections::typeShaped); + } + + /** One possible hub, with the two counts and the shape that rank it. */ + private record Candidate(String name, boolean type, int inSection, int acrossChange) { + } + + private static int byPromise(Candidate left, Candidate right) { + if (left.type() != right.type()) { + return left.type() ? -1 : 1; + } + if (left.inSection() != right.inSection()) { + return Integer.compare(right.inSection(), left.inSection()); + } + if (left.acrossChange() != right.acrossChange()) { + return Integer.compare(right.acrossChange(), left.acrossChange()); + } + return left.name().compareTo(right.name()); + } + + private static boolean typeShaped(String symbol) { + return !symbol.isEmpty() && Character.isUpperCase(symbol.charAt(0)); + } + + private static boolean matchesFileName(String symbol, List unit) { + for (String file : unit) { + if (stem(FallbackIntents.fileName(file)).equalsIgnoreCase(symbol)) { + return true; + } + } + return false; + } + + /** {@code symbol} when exactly one matches, so a guess is never made from several. */ + private static Optional onlyOne(SortedSet symbols, Predicate matches) { + String found = null; + for (String symbol : symbols) { + if (!matches.test(symbol)) { + continue; + } + if (found != null) { + return Optional.empty(); + } + found = symbol; + } + return Optional.ofNullable(found); + } + + /** + * The unit's own most substantial file: what names a card no symbol can + * name. The most-changed file first, ties by path. + * + *

Deliberately NOT the directory. A section is not directory-derived, + * so a directory title misdescribes the grouping -- and the first + * attempt proved it, titling a card after a package containing none of + * the files the card was about, because it read the directory off the + * first file in reading order, which is a pulled-in foundation rather + * than a member.

+ */ + private static String primaryOf(List unit, Map byPath) { + String best = null; + int bestHunks = -1; + for (String file : unit) { + UnifiedDiff.FileDiff diff = byPath.get(file); + int hunks = diff == null ? 0 : diff.hunks().size(); + if (hunks > bestHunks) { + best = file; + bestHunks = hunks; + } + } + return best; + } + + /** + * The rail, named. {@link FallbackIntents} guarantees that two cards can + * never read the same, and a grouping is only useful if its entries can + * be told apart -- so this makes the same guarantee rather than hoping + * for it. A hub symbol is declared in exactly one file and units are + * disjoint, so hub titles are already unique; a file name is not, and + * any that repeats is re-spelled as the full path of a file only that + * card is about. + */ + private static List
titled(List drafts) { + List provisional = new ArrayList<>(); + Map seen = new TreeMap<>(); + for (Draft draft : drafts) { + String title = name(draft, false); + provisional.add(title); + seen.merge(title, 1, Integer::sum); + } + List
sections = new ArrayList<>(); + for (int index = 0; index < drafts.size(); index++) { + Draft draft = drafts.get(index); + boolean clashes = seen.get(provisional.get(index)) > 1; + sections.add(new Section(clashes ? name(draft, true) : provisional.get(index), + draft.files(), draft.hunkIds(), draft.hub(), draft.cycleWith())); + } + return List.copyOf(sections); + } + + private static String name(Draft draft, boolean qualified) { + int size = draft.files().size(); + String count = size + (size == 1 ? " file" : " files"); + String subject = draft.hub() + .map(hub -> qualified ? hub + " (" + draft.primary() + ")" : hub) + .orElseGet(() -> qualified + ? draft.primary() + : FallbackIntents.fileName(draft.primary())); + return subject + " · " + count; + } + + // ---- cycles ------------------------------------------------------------- + + /** + * The unit's members that genuinely depend on each other, using the + * directed edges alone. A unit is not evidence of a cycle: the + * conventions in {@link #conventionEdges} put files in one unit + * precisely so they are read together, and reporting {@code guards.h} + * and {@code guards.cpp} as a dependency cycle would send a reviewer + * looking for a knot that is not there. + */ + private static List cyclesIn(List unit, + Map> depends) { + if (unit.size() < 2) { + return List.of(); + } + SortedSet members = new TreeSet<>(unit); + List> parts = Graphs.topologicalOrder(members, file -> { + SortedSet inside = new TreeSet<>(depends.get(file)); + inside.retainAll(members); + return inside; + }, Comparator.naturalOrder()); + SortedSet cyclic = new TreeSet<>(); + for (List part : parts) { + if (part.size() > 1) { + cyclic.addAll(part); + } + } + return List.copyOf(cyclic); + } + + // ---- plumbing ----------------------------------------------------------- + + private static List hunkIdsOf(Map byPath, + List files) { + List ids = new ArrayList<>(); + for (String path : files) { + UnifiedDiff.FileDiff file = byPath.get(path); + if (file == null) { + continue; + } + for (int hunk = 0; hunk < file.hunks().size(); hunk++) { + ids.add(ReviewIntent.hunkId(path, hunk)); + } + } + return ids; + } + + private static List
fromFallback(UnifiedDiff diff) { + List
sections = new ArrayList<>(); + for (ReviewIntent intent : FallbackIntents.group(diff)) { + sections.add(new Section(intent.title(), intent.files(), intent.hunkIds(), + Optional.empty(), List.of())); + } + return List.copyOf(sections); + } + + private static Map> emptyEdges(SortedSet nodes) { + Map> result = new TreeMap<>(); + for (String node : nodes) { + result.put(node, new TreeSet<>()); + } + return result; + } + + private static boolean isEmpty(Map> edges) { + return edges.values().stream().allMatch(SortedSet::isEmpty); + } + + /** The file name without its extension: {@code src/guards.h} to {@code guards}. */ + private static String stem(String name) { + int dot = name.lastIndexOf('.'); + return dot <= 0 ? name : name.substring(0, dot); + } + + private static String extension(String path) { + String name = FallbackIntents.fileName(path); + int dot = name.lastIndexOf('.'); + return dot <= 0 ? "" : name.substring(dot + 1); + } + + // ---- what one line names ------------------------------------------------ + + /** + * One file or module named by an include or import line. + * + *

{@code pathLike} tokens ({@code "counters.h"}, {@code "./widget"}) + * carry their own separators; symbolic ones ({@code app.Constants}, + * {@code crate::guards::Scope}) spell a package, and only their last two + * segments can plausibly be a file.

+ */ + private record Reference(String token, boolean pathLike) { + + boolean names(String other) { + String otherName = FallbackIntents.fileName(other); + if (pathLike) { + if (other.equals(token) || other.endsWith("/" + token)) { + return true; + } + String named = FallbackIntents.fileName(token); + return named.equals(otherName) + || (!stem(named).isEmpty() && stem(named).equals(stem(otherName))); + } + // The tail of a package path is the type; the one before it is + // usually the module. Anything further up is a directory, and + // matching on it would join every file under a common package. + List segments = List.of(token.split("[.:/]+")); + String otherStem = stem(otherName); + for (int index = segments.size() - 1; + index >= 0 && index >= segments.size() - 2; index--) { + if (!segments.get(index).isEmpty() && segments.get(index).equals(otherStem)) { + return true; + } + } + return false; + } + } + + private static List referencesOn(String text) { + List references = new ArrayList<>(); + add(references, INCLUDE.matcher(text), true); + add(references, QUOTED_MODULE.matcher(text), true); + add(references, SYMBOLIC_MODULE.matcher(text), false); + return references; + } + + private static void add(List references, Matcher matcher, boolean pathLike) { + while (matcher.find()) { + references.add(new Reference(matcher.group(1), pathLike)); + } + } +} diff --git a/app/src/main/java/app/drydock/review/SymbolScan.java b/app/src/main/java/app/drydock/review/SymbolScan.java new file mode 100644 index 00000000..12c8dbc4 --- /dev/null +++ b/app/src/main/java/app/drydock/review/SymbolScan.java @@ -0,0 +1,423 @@ +package app.drydock.review; + +import app.drydock.git.UnifiedDiff; +import org.treesitter.TSLanguage; +import org.treesitter.TSNode; +import org.treesitter.TSParser; +import org.treesitter.TSTree; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Matcher; + +/** + * One file's symbols: what it declares, what it uses, and whether each sits + * on a changed line (spec §4.2). + * + *

Two front ends behind one shape. With a grammar, declarations come from + * the parse tree. Without one, every occurrence is a use and the + * file declares nothing -- a lexical scan cannot tell a declaration from a + * call without guessing, and a wrong declaration would mint wrong edges + * everywhere the name appears. A file that is not plausibly code at all + * (see {@link #NOT_CODE}) takes neither front end and contributes + * nothing.

+ * + *

A hunk, not a line, is the parsing unit. The first + * design parsed one diff line at a time, on the reasoning that a diff line + * is not a compilation unit. It is worse than that: for anything whose body + * spans lines -- which is every real C++ class -- the opening line alone is + * an incomplete construct, and tree-sitter reports no name for it. Measured + * on the case this feature exists to serve, a five-line + * {@code class JmpCtxScope} declared {@code arm} and {@code disarm} and lost + * {@code JmpCtxScope} entirely. A hunk is a contiguous region of one file, + * so joining its lines is far likelier to parse as real syntax, and it is + * one parse per hunk rather than per line.

+ * + *

A name inside a comment is not a use. This falls out + * of parsing a hunk rather than a line, but it is a policy and not an + * accident, so it is stated here. A whole {@code //} line always parsed as + * a comment; what did NOT was an INTERIOR line of a block comment, which + * is most of the documentation in this codebase. Read alone, + * {@code * ranks {@code BaseMove} above {@link + * HunkDigest}} is not a comment -- it is an asterisk and some + * identifiers -- so line-at-a-time lexed the doc words as names and minted + * real reference edges from them. Measured on this branch's own diff that + * was 108 of 337 edges, more than the non-code denylist removes. With the + * hunk in hand the grammar sees one comment node and yields nothing from + * it.

+ * + *

The new behaviour is the right one: prose that NAMES a thing is not + * code that DEPENDS on it, and nobody ever decided that a + * {@code {@link}} should couple two files in the review rail. + * Guarded by {@code aNameThatAppearsOnlyInACommentIsNotAUse}, because a + * {@link #walk} change or a grammar bump that starts descending into + * comments again would otherwise restore those 108 edges with no signal at + * all.

+ * + *

Blocking: parsing -- and, the first time any language is used, loading + * its native grammar library via {@link GrammarRegistry} -- both do real + * work (native calls, disk I/O). Never call {@link #of} on the FX thread.

+ */ +public final class SymbolScan { + + /** + * One symbol occurrence. {@code hunk} is its index within {@code path}'s + * hunks, the same index {@link ReviewIntent#hunkId} counts, so a caller + * can say which hunk a name is in and not merely which file. It is free + * here -- {@link #of} is already looping hunks -- and it is the whole + * difference between "these two files are related" and a claim about a + * specific hunk. + */ + public record Symbol(String name, String path, int hunk, boolean declaration, + boolean onChangedLine) { + } + + /** + * tree-sitter node types that introduce a name, across the shipped + * grammars. Verified against the real grammars (not assumed): parsed + * representative snippets for every shipped language and read the + * S-expression tree-sitter itself printed, plus the field name each + * child carries. That surfaced two gaps the original sketch of this + * list did not cover -- {@code type_spec} (Go's {@code struct}/{@code + * interface}/type-alias name lives one level under {@code + * type_declaration}, which is otherwise a dead end) and the four + * enum-member containers ({@code enum_constant} for Java, {@code + * enumerator} for C/C++, {@code enum_variant} for Rust, {@code + * enum_entry} for Kotlin) -- both added here rather than left silently + * unhandled. + */ + private static final List DECLARATION_NODES = List.of( + "class_declaration", "interface_declaration", "record_declaration", + "enum_declaration", "method_declaration", "constructor_declaration", + "function_definition", "function_declarator", "function_declaration", + "struct_specifier", "class_specifier", "enum_specifier", "type_definition", + "field_declaration", "function_item", "struct_item", "enum_item", "impl_item", + "class_definition", "type_alias_declaration", "object_declaration", + "type_spec", "enum_constant", "enumerator", "enum_variant", "enum_entry"); + + /** + * Node types tree-sitter uses for a bare name, across the shipped + * grammars. {@code simple_identifier} is Kotlin's spelling -- without it + * every Kotlin name (declared or used) is invisible to this scan, since + * Kotlin never emits plain {@code identifier} nodes. + * + *

{@code namespace_identifier} is the qualifier of a C/C++ {@code + * A::b}, and it is here so that a qualified name references what + * qualifies it: {@code void JmpCtxScope::arm() {}} in a + * {@code .cpp} otherwise names nothing its own header declares, and the + * pair never links by symbol. The equivalent shapes in the other shipped + * languages need no entry -- confirmed by dumping their trees, Java's + * {@code Foo.bar()}, Rust's {@code Foo::bar} and Go's {@code pkg.Sym} + * all spell the qualifier with a plain {@code identifier}, which is + * already listed.

+ */ + private static final List NAME_NODES = List.of( + "identifier", "type_identifier", "field_identifier", "simple_identifier", + "namespace_identifier"); + + /** + * Child field names that mark an identifier as the thing being + * declared, as opposed to a type reference, a result type or a + * parameter. Java/Kotlin-family and Go/Rust/Python/JS/TS grammars name + * it {@code name}; C/C++ name it {@code declarator} (a C declarator can + * itself be a nested {@link #DECLARATION_NODES} entry, e.g. {@code + * function_definition}'s {@code declarator} is a {@code + * function_declarator}, whose own {@code declarator} field is the + * identifier -- the recursion in {@link #walk} unwinds that correctly). + */ + private static final Set NAME_FIELDS = Set.of("name", "declarator"); + + /** + * Extensions whose files are not code, and so contribute nothing -- + * not even uses. + * + *

"A file with no grammar contributes uses only" was written for an + * unsupported language, not for prose. Measured on this + * branch's own 54-file diff, 51 of 337 reference edges (15%) originated + * in files that are not code at all: a design document quoting Java in + * fenced blocks minted an edge to each of seventeen changed classes, and + * {@code app.css} reached {@code Sections.java} through a style class + * name. Those edges are not wrong about the text; they are wrong about + * the code, and they merge sections that have no structural relation.

+ * + *

An explicit denylist rather than a cleverer test. Anything that + * tried to infer "is this code" would be a guess whose failures are + * invisible, and the honest cost of a denylist is stated rather than + * hidden: it lets through every extension nobody listed, and every file + * with no extension at all -- {@code Makefile} and {@code gradlew} are + * code and should pass, {@code LICENSE} and {@code CODEOWNERS} are not + * and still contribute uses. Those are prose without identifiers, so + * they mint few edges; a document full of code blocks is the case that + * actually mattered, and it has an extension.

+ */ + private static final Set NOT_CODE = Set.of( + "md", "markdown", "txt", "rst", "adoc", "org", + "css", "scss", "sass", "less", + "json", "yaml", "yml", "toml", "ini", "cfg", "conf", "lock", + "html", "htm", "xml", "xsd", "dtd", "svg", + "png", "jpg", "jpeg", "gif", "ico", "webp", "pdf", + "properties", "csv", "tsv", "patch", "diff", "log"); + + private SymbolScan() { + } + + /** {@code file}'s symbols, in reading order within each hunk. */ + public static List of(UnifiedDiff.FileDiff file) { + if (!plausiblyCode(file.path())) { + return List.of(); + } + Optional grammar = GrammarRegistry.forPath(file.path()); + List symbols = new ArrayList<>(); + for (int index = 0; index < file.hunks().size(); index++) { + UnifiedDiff.Hunk hunk = file.hunks().get(index); + if (grammar.isPresent()) { + // The new state first (context + additions), then the old + // one, so the output is stable and a context line is + // reported exactly once. + scanView(grammar.get(), file.path(), index, hunk, UnifiedDiff.Line.Kind.ADD, + true, symbols); + scanView(grammar.get(), file.path(), index, hunk, UnifiedDiff.Line.Kind.DEL, + false, symbols); + } else { + for (UnifiedDiff.Line line : hunk.lines()) { + lexical(symbols, file.path(), index, line.text(), isChanged(line)); + } + } + } + return List.copyOf(symbols); + } + + /** + * Whether {@code path} is worth scanning at all. Extension-only, matched + * the way {@link GrammarRegistry} matches: the last dot after the last + * slash, lowercased. + */ + private static boolean plausiblyCode(String path) { + if (path == null || path.endsWith("/")) { + return false; + } + int dot = path.lastIndexOf('.'); + int slash = path.lastIndexOf('/'); + if (dot < 0 || dot < slash || dot == path.length() - 1) { + return true; + } + return !NOT_CODE.contains(path.substring(dot + 1).toLowerCase(Locale.ROOT)); + } + + /** + * Scans one state of {@code hunk} into {@code out}. + * + *

A hunk interleaves ADD, DEL and CONTEXT lines, and joining all + * three produces a fragment that is not valid source in either state -- + * a deleted {@code if} and the added one replacing it, both present, + * with two bodies and one closing brace. So each state is parsed on its + * own: the new state is CONTEXT + ADD, the old state is CONTEXT + DEL, + * and each is a coherent view of one file.

+ * + *

Context lines appear in both views, so exactly one view reports + * them: {@code reportContext} is true for the new state and false for + * the old, which reports only its DEL lines. Every source line is + * therefore scanned once, as it was when this was line-at-a-time, and a + * DEL line is still read in the surrounding syntax it was deleted from + * rather than in isolation. A hunk with no deletions -- the common case + * -- parses once.

+ * + *

A fresh {@link TSParser} (and the {@link TSTree} it returns) per + * view is deliberate, not a leak: the binding exposes no public {@code + * close()}/{@code delete()} on either type -- decompiling {@code + * TSParser}'s and {@code TSTree}'s constructors shows each registers a + * {@code java.lang.ref.Cleaner} action that calls the native {@code + * ts_*_delete} when the object becomes unreachable. There is nothing a + * manual call could free that the Cleaner does not already own.

+ */ + private static void scanView(TSLanguage language, String path, int hunkIndex, + UnifiedDiff.Hunk hunk, UnifiedDiff.Line.Kind changedKind, + boolean reportContext, List out) { + List lines = new ArrayList<>(); + boolean anyReported = false; + for (UnifiedDiff.Line line : hunk.lines()) { + if (line.kind() != UnifiedDiff.Line.Kind.CONTEXT && line.kind() != changedKind) { + continue; + } + lines.add(line); + anyReported |= reportContext || line.kind() == changedKind; + } + if (!anyReported) { + return; + } + Fragment fragment = Fragment.of(lines, reportContext); + TSTree tree; + try { + TSParser parser = new TSParser(); + parser.setLanguage(language); + tree = parser.parseString(null, fragment.text()); + } catch (RuntimeException e) { + // A fragment the grammar cannot even tokenise (verified: a lone + // unpaired UTF-16 surrogate throws "Invalid UTF-8 source input" + // from the native layer) is not a reason to lose the file -- + // fall back to the same lexical scan an ungrammared file gets, + // for the lines this view is responsible for. + // + // Scoped to just the native-facing calls: catching a wider block + // here would let a bug in walk() -- our own Java, not the + // grammar -- disappear into this same "expected fallback" path + // with no log and no test signal. Absent and broken must not + // look the same. + for (int index = 0; index < lines.size(); index++) { + if (fragment.reports(index)) { + lexical(out, path, hunkIndex, lines.get(index).text(), + fragment.changed(index)); + } + } + return; + } + walk(tree.getRootNode(), fragment, path, hunkIndex, out); + } + + /** + * One parsed view of a hunk: the joined source, its UTF-8 bytes, and + * where each line begins in them. + * + *

The byte offsets are what makes per-hunk parsing keep the + * per-symbol answer the line-at-a-time version gave for free. {@link + * TSNode#getStartByte()} is a UTF-8 BYTE offset (confirmed: a line with + * two-byte characters before an identifier has a byte length longer than + * its char length, and the identifier's own node range is the byte span, + * not the char span), so {@code lineStart} is measured in bytes too -- + * counting characters would drift by one per non-ASCII byte and + * attribute a symbol to the wrong line, or slice a name in half.

+ * + *

{@code text} and {@code utf8} are the same content twice on + * purpose: the parser takes a {@code String} and answers in bytes, and + * re-encoding per symbol would be the same work done once per name + * instead of once per hunk. Purely internal -- the array components mean + * the generated {@code equals} is identity-based, and nothing compares + * two of these.

+ */ + private record Fragment(String text, byte[] utf8, int[] lineStart, + boolean[] reportedLines, boolean[] changedLines) { + + static Fragment of(List lines, boolean reportContext) { + StringBuilder joined = new StringBuilder(); + int[] lineStart = new int[lines.size()]; + boolean[] reported = new boolean[lines.size()]; + boolean[] changed = new boolean[lines.size()]; + int offset = 0; + for (int index = 0; index < lines.size(); index++) { + UnifiedDiff.Line line = lines.get(index); + lineStart[index] = offset; + reported[index] = reportContext || isChanged(line); + changed[index] = isChanged(line); + joined.append(line.text()).append('\n'); + offset += line.text().getBytes(StandardCharsets.UTF_8).length + 1; + } + String text = joined.toString(); + return new Fragment(text, text.getBytes(StandardCharsets.UTF_8), lineStart, + reported, changed); + } + + /** + * The line {@code byteOffset} falls in. {@code lineStart} is + * strictly increasing (every line contributes at least its + * newline), so the binary search's insertion point is one past the + * containing line. + */ + int lineAt(int byteOffset) { + int found = Arrays.binarySearch(lineStart, byteOffset); + int index = found >= 0 ? found : -found - 2; + return Math.min(Math.max(index, 0), lineStart.length - 1); + } + + /** Whether this view is the one that reports line {@code index}. */ + boolean reports(int index) { + return reportedLines[index]; + } + + boolean changed(int index) { + return changedLines[index]; + } + } + + private static boolean isChanged(UnifiedDiff.Line line) { + return line.kind() != UnifiedDiff.Line.Kind.CONTEXT; + } + + /** + * Walks the tree looking for name tokens. A node in {@link + * #DECLARATION_NODES} marks only the identifier sitting in its own + * {@code name}/{@code declarator} field as a declaration -- not every + * identifier in its subtree. Marking the whole subtree (the naive + * reading of "this is a declaring node") would brand a call inside a + * method body as a declaration of the method's own name, which is + * exactly the false edge this design exists to avoid. Kotlin's grammar + * carries no field names at all (confirmed by dumping every child's + * field name), so when a declaration node's child has none, the first + * bare name-shaped child stands in for the missing field. + */ + private static void walk(TSNode node, Fragment fragment, String path, int hunkIndex, + List out) { + if (DECLARATION_NODES.contains(node.getType())) { + int count = node.getChildCount(); + for (int i = 0; i < count; i++) { + TSNode child = node.getChild(i); + String field = node.getFieldNameForChild(i); + boolean isDeclaredName = isNameNode(child) + && (field == null || NAME_FIELDS.contains(field)); + if (isDeclaredName) { + addSymbol(out, fragment, child, path, hunkIndex, true); + } else { + walk(child, fragment, path, hunkIndex, out); + } + } + return; + } + if (isNameNode(node)) { + addSymbol(out, fragment, node, path, hunkIndex, false); + return; + } + for (int i = 0; i < node.getChildCount(); i++) { + walk(node.getChild(i), fragment, path, hunkIndex, out); + } + } + + private static boolean isNameNode(TSNode node) { + return NAME_NODES.contains(node.getType()); + } + + /** + * {@code node}'s text, sliced from the fragment's own UTF-8 bytes rather + * than {@code String.substring} on the joined text, because the node + * range is a byte range (see {@link Fragment}). The line the node starts + * on decides both whether this view reports it at all and whether it + * counts as changed. + */ + private static void addSymbol(List out, Fragment fragment, TSNode node, String path, + int hunkIndex, boolean declaration) { + int start = node.getStartByte(); + int index = fragment.lineAt(start); + if (!fragment.reports(index)) { + return; + } + String name = new String(fragment.utf8(), start, node.getEndByte() - start, + StandardCharsets.UTF_8); + if (SymbolWords.isSymbol(name)) { + out.add(new Symbol(name, path, hunkIndex, declaration, fragment.changed(index))); + } + } + + private static void lexical(List out, String path, int hunkIndex, String text, + boolean changed) { + Matcher matcher = SymbolWords.IDENTIFIER.matcher(text); + while (matcher.find()) { + String name = matcher.group(); + if (SymbolWords.isSymbol(name)) { + out.add(new Symbol(name, path, hunkIndex, false, changed)); + } + } + } +} diff --git a/app/src/main/java/app/drydock/review/VerdictMerge.java b/app/src/main/java/app/drydock/review/VerdictMerge.java new file mode 100644 index 00000000..0f1379af --- /dev/null +++ b/app/src/main/java/app/drydock/review/VerdictMerge.java @@ -0,0 +1,66 @@ +package app.drydock.review; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * A section's decision, derived from its hunks' (spec §9.1). + * + *

The merge is deliberately asymmetric, and the asymmetry is inherited + * rather than invented: it is the rule {@code AnnotationStore}'s legacy + * verdict migration was written around, promoted from a one-off carry to the + * live derivation now that sections overlap and cannot own a verdict of + * their own.

+ * + *
    + *
  • Any {@code CHANGES} makes the section {@code CHANGES}. "Something in + * here needs work" stays true of a section however it is drawn.
  • + *
  • An approval needs EVERY hunk settled. Approving a section is a claim + * that the human read all of it, so one unread hunk leaves it + * unsettled. Silently approving code nobody looked at is the one + * outcome this must never produce.
  • + *
+ */ +public final class VerdictMerge { + + private VerdictMerge() { + } + + /** + * The section's decision, or empty when its hunks do not support one. + * {@code hunkVerdicts} carries one entry per hunk in the section, empty + * where that hunk is unsettled. + */ + public static Optional derive( + List> hunkVerdicts) { + Objects.requireNonNull(hunkVerdicts, "hunkVerdicts"); + if (hunkVerdicts.isEmpty()) { + return Optional.empty(); + } + boolean anyUnsettled = false; + boolean anyHumanApproval = false; + for (Optional verdict : hunkVerdicts) { + if (verdict.isEmpty()) { + anyUnsettled = true; + continue; + } + switch (verdict.get().decision()) { + // Checked before the unsettled test: a changes request is + // already true of the section, and waiting for the rest to be + // read before saying so would hide it exactly when it matters. + case CHANGES -> { + return Optional.of(ReviewVerdict.Decision.CHANGES); + } + case APPROVED -> anyHumanApproval = true; + case AUTO_APPROVED -> { } + } + } + if (anyUnsettled) { + return Optional.empty(); + } + return Optional.of(anyHumanApproval + ? ReviewVerdict.Decision.APPROVED + : ReviewVerdict.Decision.AUTO_APPROVED); + } +} diff --git a/app/src/main/java/app/drydock/ui/MainWorkspace.java b/app/src/main/java/app/drydock/ui/MainWorkspace.java index 4794edda..58e42988 100644 --- a/app/src/main/java/app/drydock/ui/MainWorkspace.java +++ b/app/src/main/java/app/drydock/ui/MainWorkspace.java @@ -47,6 +47,8 @@ import app.drydock.mcp.WorkspaceMcpSessionContext; import app.drydock.process.SshCommandBuilder; import app.drydock.review.AnnotationStore; +import app.drydock.review.BaseMove; +import app.drydock.review.ChangeGraph; import app.drydock.review.IntentGrouping; import app.drydock.review.ReviewAnnotation; import app.drydock.review.ReviewIntent; @@ -122,9 +124,11 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.TreeSet; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; @@ -182,9 +186,19 @@ public final class MainWorkspace extends BorderPane implements WorkspaceNavigato * on to land, and the budget would stop bounding anything. */ /** Virtual threads for handoff git work; this class has no shared pool. */ - private static final java.util.concurrent.Executor HANDOFF_EXECUTOR = + private static final Executor HANDOFF_EXECUTOR = runnable -> Thread.ofVirtual().name("drydock-handoff").start(runnable); + /** + * Virtual threads for the review board's git lookups -- resolving a + * scope's base and head refs to commits, and diffing a base move. Both + * are asked for from the FX thread while rendering, so neither may run on + * it; separate from {@link #HANDOFF_EXECUTOR} only so a stack trace says + * which of the two is stuck. + */ + private static final Executor REVIEW_GIT_EXECUTOR = + runnable -> Thread.ofVirtual().name("drydock-review-git").start(runnable); + /** Bound on diffing one scope to read its intents; the seed is not worth a hang. */ private static final long INTENT_DIFF_TIMEOUT_SECONDS = 10; @@ -1726,6 +1740,17 @@ private void pollForTab(Predicate matches, String what, * showing" means now that review is something a session HAS rather than * a place the app navigates to. */ + /** + * Diagnostic-only: opens the Review board's out-of-diff fan-in popover + * (see {@code SessionReviewView#diagOpenFanIn}). Only the {@code + * diag.tabScript} driver calls this. + */ + public String diagOpenFanIn() { + return showingReviewBoard() + .map(SessionReviewView::diagOpenFanIn) + .orElse("no review board showing"); + } + private Optional showingReviewBoard() { return currentlySelected() .filter(open -> open.activeSubTab() == OpenSessionTab.SubTab.REVIEW) @@ -1913,6 +1938,12 @@ private final class ReviewHost implements SessionReviewView.Host { @Override public Optional bodyFor(ReviewScope scope) { + // The board is rendering this scope: the moment to re-read what + // its base ref points at. A base branch tip moves under a + // long-running session, and a baseline resolved once and kept for + // the life of the workspace would never notice -- which is the + // whole thing staleness exists to catch. + refreshBaseline(scope); // M2 returns the diff column here; until then the view renders // its own placeholder, which is what the empty Optional means. return Optional.empty(); @@ -1951,40 +1982,78 @@ public List findings(ReviewScope scope) { } @Override - public List intents(ReviewScope scope, UnifiedDiff diff) { - List grouped = intentGrouping.intentsFor(scope.id(), diff); - // Verdicts are keyed by intent id, and the fallback grouping's - // ids changed when it stopped emitting one intent per file -- - // so an approval given before that would read as unsettled. - // Called here rather than once at startup because the grouping is - // only knowable after the scope's diff resolves; the store makes - // it idempotent and cheap once there is nothing left to carry. - annotationStore.migrateLegacyVerdicts(scope.id(), grouped); - return grouped; + public List intents(ReviewScope scope, UnifiedDiff diff, + Optional graph) { + return intentGrouping.intentsFor(scope.id(), diff, graph); + } + + @Override + public long groupingVersion(ReviewScope scope) { + return intentGrouping.version(scope.id()); + } + + @Override + public boolean hasReviewerGrouping(ReviewScope scope) { + return intentGrouping.hasReviewerGrouping(scope.id()); } @Override - public Optional verdict(ReviewScope scope, ReviewIntent intent) { - return annotationStore.verdict(scope.id(), intent.id()); + public Optional verdict(ReviewScope scope, String hunkDigest) { + return annotationStore.verdict(scope.id(), hunkDigest); } @Override - public void setVerdict(ReviewScope scope, ReviewIntent intent, - Optional decision) { + public void setVerdict(ReviewScope scope, ReviewIntent intent, List hunkDigests, + Optional decision, boolean blocked) { if (decision.isEmpty()) { - annotationStore.clearVerdict(scope.id(), intent.id()); + for (String digest : hunkDigests) { + annotationStore.clearVerdict(scope.id(), digest); + } return; } // Approval is refused, not merely discouraged, while a blocking - // finding of this intent is open (spec §4.6). Checked here as well - // as in the bar so the keyboard path cannot slip past the button's - // refusal. - if (decision.get() == ReviewVerdict.Decision.APPROVED - && blockingFindingOpen(scope, intent)) { + // finding of this intent is open (spec §4.6). blocked is the + // view's own computation (SessionReviewView#blockingFindingOpen), + // not recomputed here: a host free to derive its own answer from + // intent alone once disagreed with the verdict bar's rendered + // "blocked" for a finding naming a DIFFERENT, still-current + // intent that happened to share a file -- the bar showed clear, + // and this refused anyway, with no way for a keypress to tell. + if (decision.get() == ReviewVerdict.Decision.APPROVED && blocked) { return; } - annotationStore.putVerdict(new ReviewVerdict(scope.id(), intent.id(), decision.get(), - Optional.empty(), Instant.now())); + ReviewBaseline baseline = baselineOf(scope); + for (String digest : hunkDigests) { + annotationStore.putVerdict(new ReviewVerdict(scope.id(), digest, decision.get(), + Optional.empty(), Instant.now(), baseline.base(), baseline.head())); + } + } + + @Override + public void confirmStillGood(ReviewScope scope, List hunkDigests) { + ReviewBaseline baseline = baselineOf(scope); + Instant now = Instant.now(); + for (String digest : hunkDigests) { + annotationStore.verdict(scope.id(), digest).ifPresent(verdict -> + annotationStore.putVerdict( + verdict.confirmedAgainst(baseline.base(), baseline.head(), now))); + } + } + + @Override + public String currentBase(ReviewScope scope) { + return baselineOf(scope).base(); + } + + @Override + public BaseMove.Delta baseMove(ReviewScope scope, String recordedBase) { + return reviewBaseMove(scope, recordedBase); + } + + @Override + public boolean assessedAffected(ReviewScope scope, String hunkDigest, + String fromBase, String toBase) { + return annotationStore.assessedAffected(scope.id(), hunkDigest, fromBase, toBase); } @Override @@ -2038,10 +2107,10 @@ public void overrideSeverity(ReviewScope scope, ReviewAnnotation finding, Severi } @Override - public void askAgentToFix(ReviewScope scope, ReviewIntent intent, - List findings) { + public boolean askAgentToFix(ReviewScope scope, ReviewIntent intent, + List findings) { if (findings.isEmpty()) { - return; + return false; } StringBuilder prompt = new StringBuilder("Address these review findings on \"") .append(intent.title()).append("\", then summarize what you changed: "); @@ -2051,12 +2120,14 @@ public void askAgentToFix(ReviewScope scope, ReviewIntent intent, .append(finding.startKey()).append(": ") .append(finding.displayTitle().replaceAll("\\s+", " ")).append(". "); } - if (sendToBoundSession(scope, prompt.toString().strip())) { - for (ReviewAnnotation finding : findings) { - annotationStore.mutate(finding.key(), - current -> current.withStatus(AnnotationStatus.SENT)); - } + if (!sendToBoundSession(scope, prompt.toString().strip())) { + return false; + } + for (ReviewAnnotation finding : findings) { + annotationStore.mutate(finding.key(), + current -> current.withStatus(AnnotationStatus.SENT)); } + return true; } @Override @@ -2229,6 +2300,64 @@ public boolean runReview(ReviewScope scope) { } return sendToBoundSession(scope, reviewInstruction(scope)); } + + /** + * Sends the recheck through the same one-line prompt path a review + * takes. Returns what the hand-off returned: an automatic dispatch has + * no human watching it, so a false swallowed here would cost the scope + * its recheck with nothing to show that it never happened. + * + *

Refuses a tab whose agent process has exited, the way {@link + * #requestHandoffRefresh} and the Explorer bridge already do. + * {@code sendToBoundSession} answers "a tab object exists", not "the + * agent received it": typing into a dead terminal would return true, + * the claim would stand, and the recheck would be lost with nothing + * logged. The two senders a human drives get away without this check + * because a human sees the reply never come.

+ * + *

The prompt is typed synchronously, on the render pass that + * decided to send it. Deferring it would make the returned boolean a + * lie -- the caller releases its claim on false, and a value returned + * before the send cannot report one -- and the cost is bounded to once + * per base move by that same claim.

+ */ + @Override + public boolean dispatchRecheck(ReviewScope scope, String fromBase, String toBase) { + if (scope.sessionId().isEmpty()) { + return false; + } + OpenSessionTab open = scope.sessionId().map(openTabs::get).orElse(null); + if (open == null || open.isProcessExited()) { + return false; + } + boolean handedOff = sendToBoundSession(scope, + ReviewInstructions.forRecheck(scope.id(), fromBase, toBase)); + // The one automatic dispatch on this surface, and the only one with + // no human watching it land. Review called out that it left no + // trace anywhere: a recheck that silently never happened looked + // exactly like one nobody needed. + LOG.log(Level.INFO, () -> (handedOff ? "Dispatched" : "Could not dispatch") + + " a recheck for scope " + scope.id() + " (" + fromBase + " -> " + toBase + ")"); + return handedOff; + } + + /** + * Spec §9.7 -- only a harness that can run the recheck in a subagent + * is asked without a human having asked. The alternative for the + * others is not "ask when idle": Codex and Pi both report no activity + * at all ({@code AgentProvider.activity()} is empty for both), so + * there is no idle signal to gate on, and Claude -- the only provider + * with subagents -- is the only one that has one. + */ + @Override + public boolean supportsAutomaticRecheck(ReviewScope scope) { + return supportsSubagents(scope); + } + + @Override + public boolean assessedMove(ReviewScope scope, String fromBase, String toBase) { + return annotationStore.assessedMove(scope.id(), fromBase, toBase); + } } /** @@ -2245,20 +2374,160 @@ public boolean runReview(ReviewScope scope) { * inline form; there is no session to resolve an agent kind from.

*/ private String reviewInstruction(ReviewScope scope) { - boolean supportsSubagents = scope.sessionId() + return ReviewInstructions.forScope(scope.id(), supportsSubagents(scope)); + } + + /** + * Whether the scope's bound session's agent declares subagents. A scope + * with no bound session -- the PR-not-yet-checked-out case -- falls back + * to the inline form; there is no session to resolve an agent kind from. + */ + private boolean supportsSubagents(ReviewScope scope) { + return scope.sessionId() .flatMap(id -> sessionManager.sessions().stream() .filter(candidate -> candidate.id().equals(id)) .findFirst()) .map(session -> agentRegistry.supportsSubagents(session.agentKind())) .orElse(false); - return ReviewInstructions.forScope(scope.id(), supportsSubagents); } - private boolean blockingFindingOpen(ReviewScope scope, ReviewIntent intent) { - return annotationStore.forScope(scope.id()).stream() - .filter(finding -> finding.intentId() - .map(id -> id.equals(intent.id())).orElse(true)) - .anyMatch(ReviewAnnotation::blocksApproval); + /** + * The commits a scope's base and head REFS resolve to. Verdicts are + * stamped with these rather than with {@code scope.base()} / + * {@code scope.head()}, which are branch names: a verdict recorded + * against {@code "main"} and compared against {@code "main"} could never + * be stale, so staleness would be an inert no-op (spec §9.2). + */ + private record ReviewBaseline(String base, String head) { + } + + /** What a scope resolves to before git has answered, and when it cannot. */ + private static final ReviewBaseline UNRESOLVED_BASELINE = new ReviewBaseline( + SessionReviewView.UNRESOLVED_BASE, SessionReviewView.UNRESOLVED_BASE); + + /** Resolved baselines by scope id; FX thread only. */ + private final Map baselineByScope = new LinkedHashMap<>(); + + /** Scope ids with a baseline resolution in flight, so a render storm spawns one git. */ + private final Set baselineInFlight = new LinkedHashSet<>(); + + /** Base-move deltas by {@code (scopeId, oldBase, newBase)}; FX thread only. */ + private final Map baseMoveByMove = new LinkedHashMap<>(); + + private final Set baseMoveInFlight = new LinkedHashSet<>(); + + /** + * What {@code scope} resolves to right now. Never blocks: this is called + * from the board's render, on the FX thread. An unresolved answer is + * {@link #UNRESOLVED_BASELINE}, which reads as stale rather than as + * fresh -- absent must not look like zero. + */ + private ReviewBaseline baselineOf(ReviewScope scope) { + ReviewBaseline known = baselineByScope.get(scope.id()); + if (known != null) { + return known; + } + refreshBaseline(scope); + return UNRESOLVED_BASELINE; + } + + /** + * Re-reads {@code scope}'s base and head refs off the FX thread. Whatever + * is cached stands until the new answer lands, so a re-read never + * flickers every card to "base moved" on its way to saying nothing moved. + */ + private void refreshBaseline(ReviewScope scope) { + if (!baselineInFlight.add(scope.id())) { + return; + } + Path root = scope.diffRoot(); + String baseRef = scope.base(); + String headRef = scope.head(); + CompletableFuture + .supplyAsync(() -> new ReviewBaseline(resolveRef(root, baseRef), + resolveRef(root, headRef)), REVIEW_GIT_EXECUTOR) + .whenComplete((resolved, failure) -> Platform.runLater(() -> { + baselineInFlight.remove(scope.id()); + if (failure != null || resolved == null) { + // Recorded as unresolved rather than left absent: absent + // means baselineOf spawns this again on the very next + // render -- once per card, per rail rebuild, forever. + // A later bodyFor for this scope re-reads it anyway. + LOG.log(Level.WARNING, "Could not resolve the review base of scope " + + scope.id() + "; its verdicts cannot be dated", failure); + baselineByScope.put(scope.id(), UNRESOLVED_BASELINE); + refreshReviewBoards(); + return; + } + baselineByScope.put(scope.id(), resolved); + refreshReviewBoards(); + })); + } + + /** One ref, resolved to a commit; {@code "unresolved"} when git cannot say. */ + private String resolveRef(Path root, String ref) { + try { + return gitStatusService.commitForRefBlocking(root, ref) + .orElse(SessionReviewView.UNRESOLVED_BASE); + } catch (GitException e) { + LOG.log(Level.WARNING, () -> "Could not resolve review ref " + ref + " in " + root + + ": " + e.getMessage()); + return SessionReviewView.UNRESOLVED_BASE; + } + } + + /** + * What moved between {@code recordedBase} and {@code scope}'s current + * base, memoized per move. Never blocks, for {@link #baselineOf}'s + * reason; an answer that has not arrived is {@link + * BaseMove.Delta#unresolvable}, which is "could matter" -- the safe + * direction, and the one a reader can act on. + */ + private BaseMove.Delta reviewBaseMove(ReviewScope scope, String recordedBase) { + String currentBase = baselineOf(scope).base(); + if (SessionReviewView.UNRESOLVED_BASE.equals(recordedBase) + || SessionReviewView.UNRESOLVED_BASE.equals(currentBase)) { + // "unresolved" is not a revision. Handing it to git diff spawns a + // command that always fails, logs a warning describing no real + // problem, and memoizes an answer that the very next baseline + // makes wrong. Unresolvable is the honest answer, and the view + // renders it as "cannot tell" rather than as "the base moved". + return new BaseMove.Delta(true, new TreeSet<>()); + } + if (recordedBase.equals(currentBase)) { + // Not a move at all. Asking git would be a process spawn to be + // told what the two equal strings already said. + return new BaseMove.Delta(false, new TreeSet<>()); + } + String move = scope.id() + '\0' + recordedBase + '\0' + currentBase; + BaseMove.Delta known = baseMoveByMove.get(move); + if (known != null) { + return known; + } + if (baseMoveInFlight.add(move)) { + Path worktree = scope.diffRoot(); + CompletableFuture + .supplyAsync(() -> BaseMove.between(worktree, recordedBase, currentBase), + REVIEW_GIT_EXECUTOR) + .whenComplete((delta, failure) -> Platform.runLater(() -> { + baseMoveInFlight.remove(move); + // A failed future is recorded as unresolvable rather + // than left absent: absent would re-spawn the same + // git on the very next render, forever. + baseMoveByMove.put(move, failure == null && delta != null + ? delta + : new BaseMove.Delta(true, new TreeSet<>())); + refreshReviewBoards(); + })); + } + return new BaseMove.Delta(true, new TreeSet<>()); + } + + /** Re-renders every open board, a background git answer having landed. */ + private void refreshReviewBoards() { + for (OpenSessionTab open : openTabs.values()) { + open.reviewView().ifPresent(SessionReviewView::refreshReviewState); + } } /** @@ -4030,6 +4299,27 @@ public void diagTypeInExplorer(String text) { * session on its shell terminal, which is the state in which the rename * and sidebar-filter paths used to lose every keystroke to the shell. */ + /** Diagnostic-only: opens the gutter comment composer on the first change. */ + public void diagComment() { + currentlySelected().ifPresentOrElse( + open -> open.reviewView().ifPresentOrElse( + view -> System.out.println("[diag] comment -> " + view.diagOpenComposer()), + () -> System.out.println("[diag] comment: Review sub-tab not open")), + () -> System.out.println("[diag] comment: no selected tab")); + } + + /** Diagnostic-only: one key into the selected tab's Review view. */ + public void diagReviewKey(String keyName) { + currentlySelected().ifPresentOrElse( + open -> open.reviewView().ifPresentOrElse( + view -> { + view.diagReviewKey(KeyCode.valueOf(keyName)); + System.out.println("[diag] reviewkey " + keyName + " delivered"); + }, + () -> System.out.println("[diag] reviewkey: Review sub-tab not open")), + () -> System.out.println("[diag] reviewkey: no selected tab")); + } + public void diagShowSubTab(String name) { OpenSessionTab.SubTab subTab = switch (name.strip().toLowerCase(Locale.ROOT)) { case "terminal" -> OpenSessionTab.SubTab.TERMINAL; diff --git a/app/src/main/java/app/drydock/ui/PanelHeader.java b/app/src/main/java/app/drydock/ui/PanelHeader.java index 31c2190e..0af7cde2 100644 --- a/app/src/main/java/app/drydock/ui/PanelHeader.java +++ b/app/src/main/java/app/drydock/ui/PanelHeader.java @@ -74,6 +74,11 @@ public Region node() { return button; } + /** Swaps the title text -- for a panel that renders more than one mode under one header. */ + public void setTitle(String text) { + title.setText(text); + } + public void setTitleVisible(boolean visible) { title.setVisible(visible); title.setManaged(visible); diff --git a/app/src/main/java/app/drydock/ui/ShortcutsOverlay.java b/app/src/main/java/app/drydock/ui/ShortcutsOverlay.java index ac1fa53b..8cdece20 100644 --- a/app/src/main/java/app/drydock/ui/ShortcutsOverlay.java +++ b/app/src/main/java/app/drydock/ui/ShortcutsOverlay.java @@ -22,7 +22,7 @@ * Review keys that belong to features not yet built are added with those * features, never ahead of them. */ -final class ShortcutsOverlay { +public final class ShortcutsOverlay { private record Section(String title, String[][] shortcuts) { } @@ -49,11 +49,14 @@ private record Section(String title, String[][] shortcuts) { } {"Focus mode — collapse every rail", "f"}, {"Cycle density: cozy · compact · dense", "d"}, {"Show or hide unchanged lines", "c"}, - {"Previous / next intent", "[ / ]"}, - {"Next unsettled intent", "n"}, - {"Approve the current intent", "a"}, - {"Request changes", "r"}, - {"Undo this intent's verdict", "u"}, + {"Reading path / intents", "p"}, + {"Previous / next intent (or path row)", "[ / ]"}, + {"Next unsettled intent (or hunk)", "n"}, + {"Approve (section, or next unread hunk in the diff)", "a"}, + {"Request changes (section, or next unread hunk in the diff)", "r"}, + {"Undo (section, or next unread hunk in the diff)", "u"}, + {"Approve every hunk in this file", "⇧A"}, + {"Request changes on this file", "⇧R"}, {"Submit the review", "⏎"}, {"Collapse the intents", "i"}, {"Collapse the findings margin", "m"}, @@ -88,6 +91,17 @@ static List diagKeysFor(String sectionTitle) { return List.of(); } + /** + * The keys this overlay advertises for Review, so a test in {@code + * app.drydock.ui.review} -- a different package, so it cannot reach + * {@link #diagKeysFor}'s package-private access -- can hold the two in + * step: anything advertised here must be bound in {@code + * SessionReviewView.handleShortcut}, and vice versa. + */ + public static List reviewShortcutKeys() { + return diagKeysFor("IN REVIEW"); + } + static Region create(Runnable onClose) { Label title = new Label("Keyboard shortcuts"); title.getStyleClass().add("modal-title"); diff --git a/app/src/main/java/app/drydock/ui/review/ReviewDiffColumn.java b/app/src/main/java/app/drydock/ui/review/ReviewDiffColumn.java index 3fbdc46c..bb1036f1 100644 --- a/app/src/main/java/app/drydock/ui/review/ReviewDiffColumn.java +++ b/app/src/main/java/app/drydock/ui/review/ReviewDiffColumn.java @@ -3,7 +3,10 @@ import app.drydock.git.DiffScope; import app.drydock.git.DiffService; import app.drydock.git.UnifiedDiff; +import app.drydock.review.OutOfDiffFanIn; +import app.drydock.review.ReadingPath; import app.drydock.review.ReviewAnnotation; +import app.drydock.review.ReviewIntent; import app.drydock.review.ReviewScope; import app.drydock.review.Severity; import app.drydock.ui.UiErrors; @@ -19,6 +22,7 @@ import javafx.scene.control.ListCell; import javafx.scene.control.ListView; import javafx.scene.control.Tooltip; +import javafx.scene.input.MouseEvent; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; @@ -36,8 +40,10 @@ import java.util.ArrayList; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.function.BooleanSupplier; /** * The Review diff column (spec §4.4): hunk cards over a virtualized row @@ -225,6 +231,15 @@ public void focusFinding(Pin pin) { private boolean showContext = true; private final Set expandedRuns = new HashSet<>(); + /** + * Each hunk's {@link ReadingPath.Link}s, keyed by {@link ReviewIntent#hunkId} + * -- see {@link #setLinks}. Empty until the host has a {@link + * app.drydock.review.ChangeGraph} to compute them from, which is fine: a + * hunk absent from this map simply gets no footer row (spec §7.2), not a + * wrong one. + */ + private Map> linksByHunk = Map.of(); + /** * The intent the column is filtered to, or {@code null} for the whole * scope. Selecting an intent in the rail used only to scroll this column, @@ -350,6 +365,24 @@ private void clearSelectionAnchor() { list.getStyleClass().add("review-diff-list"); list.setFocusTraversable(false); + // Not Tab-traversable (above), but a click still has to plant real + // Scene focus here: SessionReviewView.settleUnit() (spec §9.6) reads + // the Scene's focus owner to tell a hunk-scoped a/r/u from a + // section-scoped one, and a click is the only way a reader lands in + // this column today. Node.requestFocus() does not require + // focusTraversable -- that flag only gates the Tab engine -- so this + // does not reopen Tab-key traversal into the list. + list.addEventFilter(MouseEvent.MOUSE_PRESSED, e -> { + // TEMPORARY: investigating a CI-only failure where a TestFX + // press never lands real focus here (see + // SessionReviewView#diagFocusInDiffColumn's javadoc, and + // ReviewViewFixture#focusDiffColumn). Confirms whether the press + // ever reaches this filter at all on CI, and what it actually + // hit -- remove once that investigation closes. + System.out.println("[diag] review-diff-list MOUSE_PRESSED target=" + e.getTarget() + + " sceneXY=" + e.getSceneX() + "," + e.getSceneY()); + list.requestFocus(); + }); list.setCellFactory(view -> new DiffCell()); // Long lines wrap; the column never scrolls sideways. See // viewportWidth for what this replaces. @@ -823,6 +856,21 @@ Set diagSelectedKeys() { return Set.copyOf(selectedKeys); } + /** + * One key of the gutter selection -- {@code " "}, the + * same shape every key in this class already uses -- so {@link + * SessionReviewView} can resolve which hunk a/r/u act on in HUNK mode + * (spec §9.6). Any one key of the range answers this: {@link + * DiffLineSelection} clamps a selection to a single hunk, so every key + * in it names the same one. Empty while nothing is selected -- a + * selection lives only as long as its composer does (see {@link + * #closeComposer}), so this is naturally empty once the reader has + * moved on from a comment. + */ + Optional currentLineSelection() { + return selectedKeys.stream().findFirst(); + } + /** * Diagnostic/test-only: the current selection anchor's row index, or * {@code -1} for none. Exists so a stale-index guard can be proven by @@ -846,10 +894,21 @@ void revealLine(String file, String lineKey) { } /** - * Scrolls to the {@code hunkIndex}-th hunk card of {@code file} -- what - * selecting an intent brings into view. Falls back to the file's first - * card when it has fewer hunks than that (the diff was re-read and the - * grouping is one generation behind). + * Scrolls to {@code file}'s hunk whose REAL index (into its own + * {@code UnifiedDiff.FileDiff.hunks()}) is {@code hunkIndex} -- what + * selecting an intent, a PATH step, or a link footer brings into view. + * Falls back to the file's first rendered card when that exact hunk is + * not among them (the diff was re-read and the grouping is one + * generation behind, or the column is filtered to hunks that do not + * include it). + * + *

Matched by {@link ReviewDiffRow.HunkHeader#hunkIndex()} rather than + * by counting rendered headers in order: a filter that hides some of a + * file's hunks (an intent naming only some of them) used to make the + * Nth RENDERED header stand in for hunk N, landing on the wrong hunk + * while still reporting success -- a link footer for hunk 2 of a + * three-hunk file would land on whichever hunk happened to render + * first if hunk 2 itself were filtered out.

* *

Returns whether the file was reached. It can genuinely be absent: * the intent rail is built from the whole diff while these rows stop at @@ -861,7 +920,6 @@ void revealLine(String file, String lineKey) { */ boolean revealHunk(String file, int hunkIndex) { int firstCard = -1; - int seen = 0; for (int i = 0; i < rows.size(); i++) { if (!(rows.get(i) instanceof ReviewDiffRow.HunkHeader header) || !header.file().equals(file)) { @@ -870,7 +928,7 @@ boolean revealHunk(String file, int hunkIndex) { if (firstCard < 0) { firstCard = i; } - if (seen++ == hunkIndex) { + if (header.hunkIndex() == hunkIndex) { list.scrollTo(i); return true; } @@ -1038,7 +1096,35 @@ private void rebuild() { } private ReviewDiffRows.Options buildOptions() { - return new ReviewDiffRows.Options(showContext, expandedRuns, MAX_RENDERED_ROWS, hunkFilter()); + return new ReviewDiffRows.Options(showContext, expandedRuns, MAX_RENDERED_ROWS, hunkFilter(), linksByHunk); + } + + /** + * What each hunk has to do with the rest of the diff (spec §7.2), keyed + * by {@link ReviewIntent#hunkId}. The host calls this whenever its + * {@link ReadingPath.Path} changes -- most often once its {@link + * app.drydock.review.ChangeGraph} finishes building, well after the diff + * itself rendered. + * + *

Deliberately not {@link #rebuild()}: that scrolls back to the top, + * and the host calls this on state changes that have nothing to do with + * where the reader is scrolled to (the same reason {@link #expandRun} + * avoids it). A no-op re-publish of the same map -- the common case, + * since most refreshes have nothing new to say about links -- skips the + * rebuild entirely rather than re-computing identical rows.

+ */ + void setLinks(Map> byHunkId) { + Map> copy = Map.copyOf(byHunkId); + if (copy.equals(linksByHunk)) { + return; + } + linksByHunk = copy; + rows.setAll(ReviewDiffRows.build(displayedDiff, buildOptions())); + // The graph this map is computed from lands asynchronously, well + // after a reader may have already opened the gutter composer -- a + // rebuild that dropped it here would lose an in-progress comment to + // a background refresh the reader never asked for. + insertComposerRow(); } /** @@ -1164,6 +1250,7 @@ protected void updateItem(ReviewDiffRow row, boolean empty) { case ReviewDiffRow.Truncation truncation -> message("… diff truncated at " + truncation.limit() + " rows"); case ReviewDiffRow.Message text -> message(text.text()); + case ReviewDiffRow.LinkRow linkRow -> buildLinkRow(linkRow); }; if (node instanceof Region region) { // Width only, and to the VIEWPORT -- never to this cell. See @@ -1480,6 +1567,156 @@ private void showLens(String symbol, Node anchor) { }); } + /** + * The out-of-diff fan-in popover (spec §7.4): every place the symbols + * {@code file} declares are used OUTSIDE this change, with the file and + * line of each. + * + *

The symbol lens's popover on a third source, deliberately: same + * frame, same chips, same one-click occurrence rows, and the SAME {@code + * lensPopup} field -- so it is already part of Escape's unwind order + * ({@link #lensOpen}, {@link #hideLens}) and opening either one closes + * the other, with no second popover to keep in sync. Inventing a second + * interaction for the same gesture is how two popovers start + * disagreeing.

+ * + *

The rows are NOT contorted into {@link SymbolIndex.Occurrence}: + * that record's {@code inDiff} flag drives the lens's in-diff / + * not-touched chip, and every occurrence here is out-of-diff by + * construction -- so the chip says exactly that instead of pretending to + * a distinction this source cannot make.

+ * + *

{@code bySymbol} is rendered in its own iteration order; the caller + * owns determinism (see {@code SessionReviewView.fanInOccurrences}).

+ */ + void showFanIn(String file, Map> bySymbol, + Node anchor, BooleanSupplier askTheAgent) { + if (bySymbol.isEmpty()) { + return; + } + hideLens(); + int total = bySymbol.values().stream().mapToInt(List::size).sum(); + + VBox content = new VBox(6); + content.getStyleClass().add("review-lens"); + + Label title = new Label(file); + title.getStyleClass().add("review-lens-title"); + title.setWrapText(true); + // "usages", in those words: this list IS the usages view, so it says + // so rather than linking somewhere else for it. + Label summary = new Label(total + (total == 1 ? " usage" : " usages") + + " outside this change · " + bySymbol.size() + + (bySymbol.size() == 1 ? " changed symbol" : " changed symbols")); + summary.getStyleClass().add("review-lens-summary"); + Label caveat = new Label("Lexical git grep of the worktree — occurrences, not resolved " + + "references. It cannot tell you whether a change here breaks any of them."); + caveat.getStyleClass().add("review-lens-caveat"); + caveat.setWrapText(true); + + // Where the design is honest about its ceiling: nothing mechanical + // and diff-scoped can say whether this change breaks these callers, + // so the popover puts the reader one click from the party that can. + // The label says what the button DOES, both halves of it: the + // question is filed as a review comment on this file whether or not + // a session is there to receive it, and a reviewer who is not told + // that finds a stray comment they did not knowingly write. + Button ask = new Button("Ask the agent — files a review comment"); + ask.getStyleClass().add("review-fanin-ask"); + ask.setMaxWidth(Double.MAX_VALUE); + ask.setWrapText(true); + + // Reused by every row: the Explorer jump can fail (no session, or + // its tab is closed), and a row that silently does nothing is worse + // than one that says why. + Label notice = new Label(); + notice.getStyleClass().add("review-fanin-notice"); + notice.setWrapText(true); + notice.setVisible(false); + notice.setManaged(false); + + // Wired AFTER `notice` exists, and it does NOT hide the popover + // first: a hand-off that could not happen has to have somewhere to + // say so, and hiding the only surface before running the action + // leaves nowhere. Exactly the ordering openOutsideFile uses. + ask.setOnAction(e -> { + if (askTheAgent.getAsBoolean()) { + hideLens(); + return; + } + notice.setText("Filed as a review comment on " + file + + ", but nothing was sent — open this scope's session first; " + + "the agent is asked through it."); + notice.setVisible(true); + notice.setManaged(true); + }); + + content.getChildren().addAll(title, summary, caveat, ask, notice); + + for (Map.Entry> entry : bySymbol.entrySet()) { + Label symbol = new Label(entry.getKey()); + symbol.getStyleClass().add("review-fanin-symbol"); + content.getChildren().add(symbol); + for (OutOfDiffFanIn.Occurrence occurrence : entry.getValue()) { + Label chip = new Label("outside this change"); + chip.getStyleClass().addAll("review-lens-chip", "not-touched"); + Label where = new Label(occurrence.file() + ":" + occurrence.line()); + where.getStyleClass().add("review-lens-where"); + Button jump = new Button(occurrence.text().strip().length() > 60 + ? occurrence.text().strip().substring(0, 59) + "…" + : occurrence.text().strip()); + jump.getStyleClass().add("review-lens-line"); + jump.setOnAction(e -> openOutsideFile(occurrence, notice)); + HBox row = new HBox(6, chip, where); + row.setAlignment(Pos.CENTER_LEFT); + content.getChildren().addAll(row, jump); + } + } + + ScrollPane scroll = new ScrollPane(content); + scroll.setFitToWidth(true); + scroll.setMaxHeight(320); + scroll.getStyleClass().add("review-lens-scroll"); + + lensPopup = new Popup(); + lensPopup.setAutoHide(true); + lensPopup.getContent().add(scroll); + var bounds = anchor.localToScreen(anchor.getBoundsInLocal()); + if (bounds != null) { + // Positioned by the anchor, OWNED by this column. A Popup hides + // itself the moment its owner node leaves the scene, and the + // anchor here is a rail row that every refresh replaces -- so + // owning it would close this popover on the next refresh, + // including the one its own "ask" button causes. This column + // outlives every such rebuild. + lensPopup.show(this, bounds.getMinX(), bounds.getMaxY() + 4); + } + } + + /** + * Opens one out-of-diff occurrence in the Explorer. Unlike the lens's + * own rows, {@link #revealLine} is no use here: the file is OUTSIDE the + * diff, so this column has no row to reveal. A refused jump writes into + * {@code notice} rather than being swallowed -- {@link + * ExplorerBridge#openFileAtLine} returns false when there is nowhere to + * open it, and this branch has twice had to fix a control that reported + * nothing when it did nothing. + */ + private void openOutsideFile(OutOfDiffFanIn.Occurrence occurrence, Label notice) { + if (displayedScope == null) { + return; + } + if (explorerBridge.openFileAtLine(displayedScope, Path.of(occurrence.file()), + occurrence.line())) { + hideLens(); + return; + } + notice.setText("Could not open " + occurrence.file() + + " — open this scope's session first; the Explorer lives in it."); + notice.setVisible(true); + notice.setManaged(true); + } + /** Closes the lens popover; part of Escape's unwind order. */ void hideLens() { if (lensPopup != null) { @@ -1508,6 +1745,73 @@ private Region buildCollapsedRun(ReviewDiffRow.CollapsedRun run) { return button; } + /** + * A hunk's footer row: what it has to do with a hunk in another file + * (spec §7.2). {@code link.label()} already names a file and a symbol -- + * never {@link ReadingPath.Link#targetHunkId()} -- so the button's own + * text is exactly that label with a glyph naming the relationship in + * front of it. + * + *

{@code .review-link-row} carries its OWN {@code -fx-text-fill} in + * {@code app.css}, the same fix {@code .review-collapsed-run} already + * needed: a plain {@code Button.setText} has no fill of its own here -- + * only {@code .review-intent-card}'s child {@code Label}s do -- so it + * falls back to modena's light-button default against this column's dark + * background (Task 18's 1.13:1 defect, on a different row).

+ */ + private Region buildLinkRow(ReviewDiffRow.LinkRow row) { + ReadingPath.Link link = row.link(); + Button button = new Button(glyphFor(link.kind()) + " " + link.label()); + button.getStyleClass().add("review-link-row"); + button.setMaxWidth(Double.MAX_VALUE); + button.setAlignment(Pos.CENTER_LEFT); + button.setTooltip(new Tooltip("Jump to " + link.label())); + button.setOnAction(e -> selectLinkTarget(link.targetHunkId())); + return button; + } + + /** The arrow a link row opens with, naming the relationship {@link ReadingPath.Link#label()} does not. */ + private static String glyphFor(String kind) { + if (ReadingPath.CALLS.equals(kind)) { + return "↳ calls"; + } + if (ReadingPath.CALLED_BY.equals(kind)) { + return "↳ called by"; + } + return "↔"; + } + + /** + * Resolves a raw hunk id -- exactly what a link's own label never shows + * -- back to the (file, index) {@link #revealHunk} already knows how to + * scroll to. The same scroll-into-view path an intent or a PATH step + * uses, so a link click and a rail click land the reader in the same + * place through the same code. + */ + private void selectLinkTarget(String hunkId) { + ReviewIntent.parseHunkId(hunkId).ifPresent(anchor -> { + // A link crosses files by construction (spec §7.2: cross-file + // only), so its target is routinely a hunk the CURRENT filter + // does not show at all -- PATH mode narrows the column to a + // synthetic one-hunk intent, and an ordinary intent filter can + // just as easily name only some of a file's hunks. Widening + // FIRST is what makes the click land instead of silently + // scrolling nowhere on a column revealHunk cannot search. + if (!hunkFilter().includes(anchor.file(), anchor.hunkIndex())) { + showWholeScope = true; + rebuild(); + } + boolean reached = revealHunk(anchor.file(), anchor.hunkIndex()); + if (!reached) { + // Not swallowed: a link whose target could not be reached + // (past the row cap, most likely) must not look identical to + // one that worked -- the same display/action divergence this + // whole row exists to avoid. + LOG.log(Level.WARNING, "Link footer could not reach its target hunk: " + hunkId); + } + }); + } + private static Region message(String text) { Label label = new Label(text); label.getStyleClass().add("review-diff-message"); diff --git a/app/src/main/java/app/drydock/ui/review/ReviewDiffRow.java b/app/src/main/java/app/drydock/ui/review/ReviewDiffRow.java index dce6141c..2ebce2f2 100644 --- a/app/src/main/java/app/drydock/ui/review/ReviewDiffRow.java +++ b/app/src/main/java/app/drydock/ui/review/ReviewDiffRow.java @@ -1,6 +1,7 @@ package app.drydock.ui.review; import app.drydock.git.UnifiedDiff; +import app.drydock.review.ReadingPath; /** * One row of the Review diff column: pure data (no scene graph), so the @@ -33,13 +34,21 @@ enum Edge { * jump into the Explorer. {@code startLine} is the 1-based new-file line * the jump targets (the old-file line for a pure deletion). * + *

{@code hunkIndex} is the hunk's REAL index within its file's own + * {@code UnifiedDiff.FileDiff.hunks()} -- not its position among the + * headers a filtered render happens to show. {@link ReviewDiffColumn#revealHunk} + * used to count rendered headers instead, which matched the wrong hunk + * (and reported success doing it) the moment a filter hid some of a + * file's hunks: exactly the shape {@code hunkFilter} produces for an + * intent that names only some of a file's hunks.

+ * *

{@code untracked} and {@code staged} carry {@link UnifiedDiff.FileDiff}'s * own flags for the {@code untracked}/{@code staged} chip -- they travel * with the row rather than being re-derived in the renderer, because * {@code buildHunkHeader} only ever sees the row, never the file it came * from.

*/ - record HunkHeader(String file, String range, int startLine, boolean untracked, boolean staged) + record HunkHeader(String file, String range, int startLine, boolean untracked, boolean staged, int hunkIndex) implements ReviewDiffRow { @Override public Edge edge() { @@ -107,4 +116,21 @@ public Edge edge() { return Edge.BODY; } } + + /** + * A link to a related hunk in another file (spec §7.2), appended after + * its source hunk's own rows so that folding, density and the + * unchanged-run collapse apply to it with no new cases -- a parallel + * rendering path for links would drift from this one at the first thing + * they disagreed about. {@code edge} follows the same rule every other + * card row does: {@link ReviewDiffRows} gives {@code BOTTOM} to whichever + * row -- a line, a collapsed run, or the last link -- actually closes the + * card. + * + *

{@link ReadingPath.Link#label()} already names a file and a symbol, + * never {@link ReadingPath.Link#targetHunkId()} itself -- the id is what + * a click acts on, not what the row shows.

+ */ + record LinkRow(ReadingPath.Link link, Edge edge) implements ReviewDiffRow { + } } diff --git a/app/src/main/java/app/drydock/ui/review/ReviewDiffRows.java b/app/src/main/java/app/drydock/ui/review/ReviewDiffRows.java index f678fd93..50dfc347 100644 --- a/app/src/main/java/app/drydock/ui/review/ReviewDiffRows.java +++ b/app/src/main/java/app/drydock/ui/review/ReviewDiffRows.java @@ -1,9 +1,12 @@ package app.drydock.ui.review; import app.drydock.git.UnifiedDiff; +import app.drydock.review.ReadingPath; +import app.drydock.review.ReviewIntent; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Set; /** @@ -32,23 +35,34 @@ interface HunkFilter { boolean includes(String file, int hunkIndex); } - /** What the column is currently showing. */ + /** + * What the column is currently showing. {@code linksByHunk} carries each + * hunk's {@link ReadingPath.Link}s, keyed by {@link ReviewIntent#hunkId}; + * a hunk absent from the map gets no footer row at all, rather than an + * empty one -- the same "no card for nothing to say" rule {@link #build} + * already applies to a hunk with no rows to show. + */ record Options(boolean showContext, Set expandedRuns, int maxRows, - HunkFilter filter) { + HunkFilter filter, Map> linksByHunk) { Options { expandedRuns = Set.copyOf(expandedRuns); if (maxRows <= 0) { throw new IllegalArgumentException("maxRows must be positive: " + maxRows); } filter = filter == null ? HunkFilter.ALL : filter; + linksByHunk = linksByHunk == null ? Map.of() : Map.copyOf(linksByHunk); } Options(boolean showContext, Set expandedRuns, int maxRows) { - this(showContext, expandedRuns, maxRows, HunkFilter.ALL); + this(showContext, expandedRuns, maxRows, HunkFilter.ALL, Map.of()); + } + + Options(boolean showContext, Set expandedRuns, int maxRows, HunkFilter filter) { + this(showContext, expandedRuns, maxRows, filter, Map.of()); } static Options defaults(int maxRows) { - return new Options(true, Set.of(), maxRows, HunkFilter.ALL); + return new Options(true, Set.of(), maxRows, HunkFilter.ALL, Map.of()); } } @@ -90,10 +104,14 @@ static List build(UnifiedDiff diff, Options options) { } /** - * One hunk's card: a header plus its body rows, with the last body row - * marked {@link ReviewDiffRow.Edge#BOTTOM} so the card closes. A hunk - * whose every line is dropped (all context, with context hidden) yields - * no card at all rather than an empty one. + * One hunk's card: a header plus its body rows, plus a footer row for + * each of the hunk's {@link ReadingPath.Link}s (spec §7.2) -- last, so a + * reader reaches "what this hunk has to do with the rest of the diff" + * only after having read the hunk itself. Whichever row ends up last, + * body or link, is marked {@link ReviewDiffRow.Edge#BOTTOM} so the card + * closes on it. A hunk whose every line is dropped (all context, with + * context hidden) yields no card at all rather than an empty one -- + * links belong to a hunk, not to a card with nothing else in it. */ private static List buildCard(UnifiedDiff.FileDiff file, UnifiedDiff.Hunk hunk, int hunkIndex, Options options) { @@ -103,9 +121,14 @@ private static List buildCard(UnifiedDiff.FileDiff file, UnifiedD } List card = new ArrayList<>(); card.add(new ReviewDiffRow.HunkHeader(file.path(), rangeLabel(hunk), startLine(hunk), - file.untracked(), file.staged())); - card.addAll(body.subList(0, body.size() - 1)); - card.add(withBottomEdge(body.get(body.size() - 1))); + file.untracked(), file.staged(), hunkIndex)); + card.addAll(body); + String hunkId = ReviewIntent.hunkId(file.path(), hunkIndex); + for (ReadingPath.Link link : options.linksByHunk().getOrDefault(hunkId, List.of())) { + card.add(new ReviewDiffRow.LinkRow(link, ReviewDiffRow.Edge.BODY)); + } + int last = card.size() - 1; + card.set(last, withBottomEdge(card.get(last))); return card; } @@ -155,6 +178,7 @@ private static ReviewDiffRow withBottomEdge(ReviewDiffRow row) { case ReviewDiffRow.CollapsedRun run -> new ReviewDiffRow.CollapsedRun(run.file(), run.hunkIndex(), run.runIndex(), run.count(), ReviewDiffRow.Edge.BOTTOM); + case ReviewDiffRow.LinkRow link -> new ReviewDiffRow.LinkRow(link.link(), ReviewDiffRow.Edge.BOTTOM); default -> row; }; } diff --git a/app/src/main/java/app/drydock/ui/review/ReviewIntentRail.java b/app/src/main/java/app/drydock/ui/review/ReviewIntentRail.java index 3f8d20fb..3ee7645d 100644 --- a/app/src/main/java/app/drydock/ui/review/ReviewIntentRail.java +++ b/app/src/main/java/app/drydock/ui/review/ReviewIntentRail.java @@ -1,5 +1,8 @@ package app.drydock.ui.review; +import app.drydock.review.ChangeGraph; +import app.drydock.review.ReadingPath; +import app.drydock.review.Provenance; import app.drydock.review.ReviewIntent; import app.drydock.review.ReviewVerdict; import app.drydock.ui.PanelHeader; @@ -10,8 +13,10 @@ import javafx.css.PseudoClass; import javafx.geometry.Pos; import javafx.scene.Node; +import javafx.scene.Parent; import javafx.scene.control.Button; import javafx.scene.control.Label; +import javafx.scene.control.Labeled; import javafx.scene.control.ScrollPane; import javafx.scene.control.Tooltip; import javafx.scene.layout.HBox; @@ -21,11 +26,19 @@ import javafx.util.Duration; import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; import java.util.LinkedHashMap; import java.util.List; +import java.util.Objects; import java.util.Map; import java.util.Optional; +import java.util.Set; +import java.util.function.BiConsumer; import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collectors; /** * The intent rail (spec §4.2): one card per intent, with its number, title, @@ -50,6 +63,13 @@ final class ReviewIntentRail extends VBox { */ private static final double CARD_WIDTH_INSET = 2 * (6 + 8 + 1); + /** + * How much narrower a fan-in row's reason is than the rest of the card: + * {@code .review-fanin-count}'s own 3px side padding, both sides. See + * {@link #reasonNode}. + */ + private static final double FANIN_TEXT_INSET = 2 * 3; + // The handler is read at click time, so it can be installed after construction. private final PanelHeader header = PanelHeader.left( "INTENTS", "", "Collapse or expand the intents (i)", @@ -57,17 +77,95 @@ final class ReviewIntentRail extends VBox { private final VBox cards = new VBox(); private final ScrollPane scroll = new ScrollPane(cards); + /** + * See {@link #groupingPending}. Its own row rather than folded into the + * header's hint: the hint already carries "{@code N/M · i}" in the same + * ~154px the header's padding and title leave out of the rail's 232px + * (196px narrow) width, and appending "· refining grouping…" (another + * ~190px at 10px) either truncated the whole hint under {@code + * ELLIPSIS} overrun or ate the settled/counted counter beside it -- + * exactly the "{@code R..}"/"{@code ...}" truncation this project has + * shipped once already. Wrapped, on its own line, it cannot collide + * with anything else in the header. + */ + private final Label pendingBanner = new Label("refining grouping…"); + private final Map buttonsByIntentId = new LinkedHashMap<>(); + private final Map buttonsByHunkId = new LinkedHashMap<>(); + + /** + * The rail's two ways of listing the same diff (spec §7.1): today's + * cards, or one row per hunk in reading order. A mode of the rail, not a + * fourth column -- the width budget that ruled out a concept map rules + * out a new column just as firmly, and {@link RailLayout} is untouched. + */ + enum Mode { INTENTS, PATH } + + private Mode mode = Mode.INTENTS; + + /** + * Whether the order the rail is listing was measured here or claimed by + * the agent (spec §6.5). A property of the ORDER, so it belongs to the + * rail rather than to a card: §8's three sources are three sources for + * the whole sequence. PATH mode is measured by construction -- §6.4 has + * {@link app.drydock.review.ReadingPath} order the computed grouping only. + */ + private Provenance provenance = Provenance.MEASURED; + + /** + * {@code PATH} mode's rows, already in reading order and already + * numbered against {@link ReadingPath.Path#sections()} -- see {@link + * #showPath}. Never re-sorted or renumbered here: {@link + * ReadingPath.Step#sectionNumber} is the one authority for both, and a + * rail that recomputed either would risk disagreeing with the entry + * point it is handed (spec §6, Task 17). + */ + private List pathSteps = List.of(); + + /** The hunk id {@code PATH} mode highlights as selected. */ + private String selectedHunkId; + + private Consumer onPathSelected = step -> { }; + + /** + * Which {@code PATH} rows have somewhere to click their fan-in, and what + * to do when a reader clicks it (spec §7.4). + * + *

Asked per row rather than carried on {@link ReadingPath.Step}, + * because the scan lands after the path is first computed: the rail is + * rebuilt on the refresh that follows it, and this reads whatever is + * true at that moment. False by default, so a rail with nothing wired -- + * every rail test that does not care -- renders exactly today's plain + * reason label.

+ */ + private Predicate fanInAvailable = step -> false; + + private BiConsumer onFanIn = (step, anchor) -> { }; private List intents = List.of(); - private java.util.function.Function> verdictLookup = - intent -> Optional.empty(); + /** + * How a card learns what its section adds up to. A section has no verdict + * of its own now -- overlapping sections cannot own one -- so the rail is + * handed the derived state rather than a stored {@link ReviewVerdict}. + */ + private Function stateLookup = + intent -> SectionStates.SectionState.unknown(); private Consumer onSelected = intent -> { }; private Runnable onToggleCollapse = () -> { }; private String selectedId; private boolean collapsed; private boolean narrow; + /** + * True while the grouping shown is provisional: a computed + * {@link ChangeGraph} is still building, and what is on screen is the + * (kind, directory) fallback the real grouping may still replace. Shown + * via {@link #pendingBanner} rather than silently, so a reviewer + * mid-read is not surprised by cards changing under them with no + * warning at all. + */ + private boolean groupingPending; + /** Non-zero while the narrow Browse page sizes this rail; see {@link #setSpanWidth}. */ private double spanWidth; @@ -86,19 +184,46 @@ final class ReviewIntentRail extends VBox { scroll.getStyleClass().add("review-intent-scroll"); VBox.setVgrow(scroll, Priority.ALWAYS); - getChildren().setAll(header.node(), scroll); + pendingBanner.getStyleClass().add("review-intent-pending"); + pendingBanner.setWrapText(true); + pendingBanner.setManaged(false); + pendingBanner.setVisible(false); + + getChildren().setAll(header.node(), pendingBanner, scroll); } void setOnSelected(Consumer handler) { this.onSelected = handler == null ? intent -> { } : handler; } + /** Which of the rail's two modes is showing. Whichever of {@link #setIntents}/{@link #showPath} ran last. */ + Mode mode() { + return mode; + } + + void setOnPathSelected(Consumer handler) { + this.onPathSelected = handler == null ? step -> { } : handler; + } + + /** + * Wires {@code PATH} mode's fan-in affordance: {@code available} decides + * which rows get one, {@code onRequested} is handed the row and the + * control it was clicked on, so a popover can anchor to it. + */ + void setFanIn(Predicate available, + BiConsumer onRequested) { + this.fanInAvailable = available == null ? step -> false : available; + this.onFanIn = onRequested == null ? (step, anchor) -> { } : onRequested; + } + void setOnToggleCollapse(Runnable handler) { this.onToggleCollapse = handler == null ? () -> { } : handler; } - void setVerdictLookup(java.util.function.Function> lookup) { - this.verdictLookup = lookup == null ? intent -> Optional.empty() : lookup; + void setSectionStateLookup(Function lookup) { + this.stateLookup = lookup == null + ? intent -> SectionStates.SectionState.unknown() + : lookup; } /** @@ -128,13 +253,43 @@ String message() { private Empty emptyReason = Empty.NONE; /** Replaces the rail's contents and marks {@code selectedIntentId} as current. */ - void setIntents(List newIntents, String selectedIntentId, Empty reason) { + void setIntents(List newIntents, String selectedIntentId, Empty reason, + Provenance provenance) { + this.mode = Mode.INTENTS; + this.provenance = Objects.requireNonNull(provenance, "provenance"); this.intents = List.copyOf(newIntents); this.selectedId = selectedIntentId; this.emptyReason = reason == null ? Empty.NONE : reason; rebuild(); } + /** + * Switches the rail to {@code PATH} mode: one row per hunk in reading + * order, across section boundaries (spec §7.1, Task 18). {@code steps} + * is rendered exactly as handed in -- already the path's order, already + * numbered against {@link ReadingPath.Path#sections()} -- so the rail + * has nothing left to reconcile between "card 1" and the entry point + * (see the class-level correction this task was given: rendering the + * grouping's own order while numbering off the path's is the exact way + * {@code START HERE} ends up on the wrong card). + */ + void showPath(List steps, String selectedHunkId, Empty reason) { + this.mode = Mode.PATH; + this.pathSteps = List.copyOf(steps); + this.selectedHunkId = selectedHunkId; + this.emptyReason = reason == null ? Empty.NONE : reason; + rebuild(); + } + + /** See {@link #groupingPending}. */ + void setGroupingPending(boolean pending) { + if (groupingPending == pending) { + return; + } + groupingPending = pending; + rebuild(); + } + boolean collapsed() { return collapsed; } @@ -228,14 +383,29 @@ private void rebuild() { header.showCollapsed(collapsed); header.setTitleVisible(!collapsed); header.setHintVisible(!collapsed); + header.setTitle(mode == Mode.PATH ? "PATH" : "INTENTS"); + if (mode == Mode.PATH) { + rebuildPath(); + return; + } + rebuildIntents(); + } + + private void rebuildIntents() { + // Sections, not hunks: the verdict bar below counts hunks, and two + // counts of the same thing in two places is one of them being wrong. long counted = intents.stream().filter(ReviewIntent::countsTowardProgress).count(); long settled = intents.stream() .filter(ReviewIntent::countsTowardProgress) - .filter(intent -> verdictLookup.apply(intent).isPresent()) + .filter(intent -> stateLookup.apply(intent).decision().isPresent()) .count(); header.setHint(settled + "/" + counted + " · i"); + boolean showBanner = groupingPending && !collapsed; + pendingBanner.setManaged(showBanner); + pendingBanner.setVisible(showBanner); + buttonsByIntentId.clear(); List nodes = new ArrayList<>(); for (ReviewIntent intent : intents) { @@ -256,22 +426,228 @@ private void rebuild() { applySelection(); } + /** + * {@code PATH} mode's render: one row per {@link ReadingPath.Step}, in + * the exact order {@link #showPath} was handed -- see that method's + * javadoc for why this never re-sorts or renumbers. + */ + private void rebuildPath() { + header.setHint(pathSteps.size() + (pathSteps.size() == 1 ? " hunk · i" : " hunks · i")); + + boolean showBanner = groupingPending && !collapsed; + pendingBanner.setManaged(showBanner); + pendingBanner.setVisible(showBanner); + + buttonsByHunkId.clear(); + List nodes = new ArrayList<>(); + String lastFile = null; + int indexInFile = 0; + for (int i = 0; i < pathSteps.size(); i++) { + ReadingPath.Step step = pathSteps.get(i); + indexInFile = step.file().equals(lastFile) ? indexInFile + 1 : 0; + lastFile = step.file(); + int hunksInFile = hunksInFile(step.file()); + Button row = buildPathRow(step, indexInFile, hunksInFile); + buttonsByHunkId.put(step.hunkId(), row); + nodes.add(row); + } + if (nodes.isEmpty() && !collapsed) { + Label message = new Label(emptyReason != Empty.NONE + ? emptyReason.message() + : groupingPending + ? "Working out the reading order…" + : "No reading order for this diff"); + message.getStyleClass().add("review-intent-empty"); + message.setWrapText(true); + nodes.add(message); + } + cards.getChildren().setAll(nodes); + applySelection(); + } + + private int hunksInFile(String file) { + return (int) pathSteps.stream().filter(step -> step.file().equals(file)).count(); + } + + /** + * One {@code PATH} row: its section badge (or {@code START HERE} for the + * entry point -- {@link ReadingPath.Step#entryPoint}, which is exactly + * the first row here since {@code steps} arrives in reading order), the + * file and which of its hunks this is, WHY this file sits where it does, + * and its links. + * + *

The reason is stated as a fact about the FILE, never the hunk: a + * {@link ReadingPath.Step#reason} is computed once per file and copied + * onto every hunk of it (spec's own correction on this task), so a file + * with two hunks that do nothing structurally interesting would otherwise + * read "builds on ①" under both -- a false statement about a hunk that + * does not itself build on anything. Prefixing it "file " keeps the claim + * honest regardless of which hunk of the file this row is.

+ * + *

Built from Labels, never {@code Button.setText}. A + * plain {@code Button}'s own text has no {@code -fx-text-fill} of its + * own in this stylesheet -- {@code .review-intent-card} sets border and + * background only -- so it falls back to modena's default button text + * colour, tuned for a LIGHT button face, against this rail's dark + * background. Measured on a real screenshot: the selected row's own text + * came out at 1.13:1 contrast, worse than the unselected 1.70:1, because + * the lighter {@code :selected} background made a light-on-light problem + * WORSE. {@link #buildCard}'s intents cards never hit this: their text + * lives in child {@code Label}s carrying their own {@code -fx-text-fill} + * (see {@code .review-intent-title} et al. in {@code app.css}), which + * {@code :selected} brightens explicitly. Rebuilt the same way here -- + * {@code review-path-badge}/{@code -file}/{@code -reason}/{@code -links} + * each carry an explicit fill, unselected and selected both.

+ */ + private Button buildPathRow(ReadingPath.Step step, int indexInFile, int hunksInFile) { + Button row = new Button(); + // No provenance modifier, and not by omission: spec §6.4 has + // ReadingPath order the COMPUTED grouping only, so a PATH row is + // measured by construction and marking it would be the only place on + // this surface where the marker could lie. + row.getStyleClass().add("review-intent-card"); + row.setMaxWidth(Double.MAX_VALUE); + row.setAlignment(Pos.TOP_LEFT); + row.setOnAction(e -> onPathSelected.accept(step)); + + Label badge = new Label(step.entryPoint() + ? "START HERE " + SectionStates.sectionMark(step.sectionNumber()) + : SectionStates.sectionMark(step.sectionNumber())); + badge.getStyleClass().add("review-path-badge"); + + Label where = new Label(hunksInFile > 1 + ? step.file() + " · hunk " + (indexInFile + 1) + "/" + hunksInFile + : step.file()); + where.getStyleClass().add("review-path-file"); + where.setWrapText(true); + HBox.setHgrow(where, Priority.ALWAYS); + HBox headerRow = new HBox(6, badge, where); + headerRow.setAlignment(Pos.TOP_LEFT); + + Label reason = new Label("file " + step.reason()); + reason.getStyleClass().add("review-path-reason"); + reason.setWrapText(true); + + VBox content = new VBox(4, headerRow, reasonNode(step, reason)) { + @Override + protected double computePrefHeight(double width) { + // Same reason buildCard's own content VBox overrides this: + // the Button asks for prefHeight(-1), and a wrapping Label + // answers that at its MINIMUM width -- one word per line -- + // unless told the width it will actually render at. + return super.computePrefHeight(width < 0 ? getPrefWidth() : width); + } + }; + if (!step.links().isEmpty()) { + Label links = new Label((step.links().size() == 1 ? "→ " : "→ " + step.links().size() + " links: ") + + step.links().stream().map(ReadingPath.Link::label).collect(Collectors.joining("; "))); + links.getStyleClass().add("review-path-links"); + links.setWrapText(true); + content.getChildren().add(links); + } + // Bound to the CARDS COLUMN, exactly as buildCard's own content is, + // and for the identical reason: a graphic bound back to its own + // Button is a cycle that leaves both wrapping labels measuring at + // zero width on the pass that fixes the height. + content.prefWidthProperty().bind(cards.widthProperty().subtract(CARD_WIDTH_INSET)); + content.maxWidthProperty().bind(content.prefWidthProperty()); + row.setGraphic(content); + return row; + } + + /** + * The reason line, as a control when there is something behind it to + * open (spec §7.4). A fan-in reason -- "called from 7 places outside the + * change" -- is the one reason on this rail that names evidence the + * reader cannot see from here, and a count with nowhere to click is a + * statistic rather than comprehension. + * + *

The very same {@code reason} Label becomes the button's graphic + * rather than the button minting its own text: {@link ReadingPath} is + * the one author of that sentence, so there is no second copy to drift, + * the "file " prefix that scopes the claim to the FILE survives, and the + * text stays on a {@code Label} carrying its own {@code -fx-text-fill} + * -- a plain {@code Button.setText} here is the 1.13:1 contrast defect + * {@link #buildPathRow}'s own javadoc documents.

+ */ + private Node reasonNode(ReadingPath.Step step, Label reason) { + if (!fanInAvailable.test(step)) { + return reason; + } + Button button = new Button(); + button.getStyleClass().add("review-fanin-count"); + // The reason has to WRAP inside the button, and a wrapping Label + // wraps at the width it is asked to measure itself at. A Button asks + // its graphic for prefHeight(-1), and a wrapping Label answers THAT + // as a single line -- so the button sized itself to one line and cut + // the rest, which the Label renders as an ellipsis. A real screenshot + // of the running app caught exactly that: "file called from 16 places + // outside the…" on the rail's only row. This project has shipped that + // truncation once already ("R..", "..."). + // + // Same fix, same shape, as buildPathRow's own content VBox: a holder + // that substitutes its real width for the -1, bound to the CARDS + // COLUMN and never to the button around it -- a graphic bound back to + // its own container is the feedback loop this file documents. + VBox holder = new VBox(reason) { + @Override + protected double computePrefHeight(double width) { + return super.computePrefHeight(width < 0 ? getPrefWidth() : width); + } + }; + holder.prefWidthProperty().bind( + cards.widthProperty().subtract(CARD_WIDTH_INSET + FANIN_TEXT_INSET)); + holder.maxWidthProperty().bind(holder.prefWidthProperty()); + button.setGraphic(holder); + button.setMaxWidth(Double.MAX_VALUE); + button.setAlignment(Pos.TOP_LEFT); + button.setTooltip(new Tooltip("Show where this file's changed symbols are used " + + "outside the change")); + button.setOnAction(e -> { + // CONSUMED, or this row's own Button catches the same + // ActionEvent on its way up, selects the row, and rebuilds the + // rail -- which detaches the node the popover is anchored to and + // hides it again in the same gesture that opened it. Asking to + // see the callers is not asking to move the cursor. + e.consume(); + onFanIn.accept(step, button); + }); + return button; + } + private Button buildCard(ReviewIntent intent) { Button card = new Button(); card.getStyleClass().add("review-intent-card"); + // Only CLAIMED adds a modifier: decorating every row would make the + // distinction say nothing (spec §6.5). The TOOLTIP names the warrant + // either way, which is not the same thing -- a word that says + // "measured" carries information, whereas a border every row has + // carries none. + if (!provenance.styleClass().isEmpty()) { + card.getStyleClass().add(provenance.styleClass()); + } card.setMaxWidth(Double.MAX_VALUE); card.setTooltip(new Tooltip(intent.number() + " · " + intent.title() - + (intent.rationale().isBlank() ? "" : " — " + intent.rationale()))); + + (intent.rationale().isBlank() ? "" : " — " + intent.rationale()) + + " · " + provenance.label())); card.setOnAction(e -> onSelected.accept(intent)); Label number = new Label(String.valueOf(intent.number())); number.getStyleClass().add("review-intent-number"); - Optional verdict = verdictLookup.apply(intent); - boolean settled = verdict.isPresent() || intent.autoApprove(); + SectionStates.SectionState state = stateLookup.apply(intent); + Optional decision = state.decision(); + boolean settled = decision.isPresent() || intent.autoApprove(); + boolean moved = state.staleness() == SectionStates.Staleness.MOVED; if (settled) { card.getStyleClass().add("settled"); } + if (moved) { + card.getStyleClass().add("stale"); + } + if (state.hunksMissing()) { + card.getStyleClass().add("adrift"); + } Region heat = new Region(); heat.getStyleClass().addAll("review-intent-heat", intent.risk().styleClass()); @@ -284,7 +660,7 @@ private Button buildCard(ReviewIntent intent) { // the reader to guess what it said. Region dot = new Region(); dot.getStyleClass().addAll("review-intent-dot", - decisionStyleClass(verdict, intent)); + decisionStyleClass(decision, intent)); content.getChildren().add(dot); } card.setGraphic(content); @@ -332,10 +708,64 @@ protected double computePrefHeight(double width) { }); content.getChildren().add(heat); if (settled) { - Label label = new Label(verdict.map(v -> v.decision().label()) + Label label = new Label(decision.map(ReviewVerdict.Decision::label) .orElse(ReviewVerdict.Decision.AUTO_APPROVED.label())); - label.getStyleClass().addAll("review-intent-settled", decisionStyleClass(verdict, intent)); + label.getStyleClass().addAll("review-intent-settled", decisionStyleClass(decision, intent)); content.getChildren().add(label); + } else if (state.hunksMissing()) { + // Not "unread": there is nothing here to read. Said outright, + // because such a section can never be settled and the reader + // would otherwise hunt for the hunks it is asking about. + Label adrift = new Label("hunks are no longer in this diff"); + adrift.getStyleClass().add("review-intent-adrift"); + adrift.setWrapText(true); + content.getChildren().add(adrift); + } else if (state.recordedHunks() > 0) { + // Part-settled reads as untouched otherwise: the card looks + // exactly like one nobody has opened, and the reader re-reads + // hunks they already signed off. recordedHunks, not + // settledHunks (spec correction 6a): a section with one + // stale-approved hunk and one genuinely unread one has + // settledHunks()==0, which would drop this whole label and + // understate to "untouched" even though one hunk WAS recorded + // -- ⚠ base moved is the only thing that would still say so. + // The two agree whenever nothing here is stale, so this only + // ever changes what the label shows in exactly that gap. + Label progress = new Label(state.recordedHunks() + "/" + state.totalHunks() + " hunks"); + progress.getStyleClass().add("review-intent-hunk-progress"); + content.getChildren().add(progress); + } + // Only while the section is unsettled: on a settled card its own + // verdict already explains the state, and the marker would be noise. + if (!settled && !state.settledElsewhere().isEmpty()) { + // A hunk this section shares was settled elsewhere, which moved + // this card's count without the reader touching it. Naming where + // it is shared is what keeps that from reading as state changing + // on its own. + Label elsewhere = new Label("✓ reviewed in " + + String.join(" ", state.settledElsewhere())); + elsewhere.getStyleClass().add("review-intent-settled-elsewhere"); + elsewhere.setWrapText(true); + content.getChildren().add(elsewhere); + } + // UNKNOWN says nothing: the delta is still in flight, or the old base + // cannot be diffed. Neither is evidence that the base moved. + if (moved) { + // Spec §9.7: "Assessments render as claimed, not measured." A hunk + // the file-level filter caught and one an AGENT asserted was + // disturbed are the same words otherwise, and §6.5 exists because + // they fail differently: the filter can be checked by looking, the + // assertion only against the code the agent says it read. + boolean claimed = state.stalenessProvenance() == Provenance.CLAIMED; + Label stale = new Label(claimed + ? "⚠ agent: base moved — confirm" + : "⚠ base moved — confirm"); + stale.getStyleClass().add("review-intent-stale"); + if (claimed) { + stale.getStyleClass().add(Provenance.CLAIMED.styleClass()); + } + stale.setWrapText(true); + content.getChildren().add(stale); } // Bound to the CARDS COLUMN, never to the card. A Button takes its // width from its graphic, so a graphic bound back to the button is a @@ -350,11 +780,47 @@ protected double computePrefHeight(double width) { return card; } - private static String decisionStyleClass(Optional verdict, ReviewIntent intent) { - ReviewVerdict.Decision decision = verdict.map(ReviewVerdict::decision) + private static String decisionStyleClass(Optional decision, + ReviewIntent intent) { + return "decision-" + decision .orElse(intent.autoApprove() ? ReviewVerdict.Decision.AUTO_APPROVED - : ReviewVerdict.Decision.APPROVED); - return "decision-" + decision.wireName(); + : ReviewVerdict.Decision.APPROVED) + .wireName(); + } + + /** + * Diagnostic-only: opens the first {@code PATH} row's fan-in popover by + * firing that row's OWN control, never by calling the handler behind it. + * The popover is a {@code Popup} -- a separate window a scene snapshot of + * the primary stage cannot see and synthetic Robot input cannot reach in + * a diag run -- so a visual pass over it needs this hook; firing the real + * button means the hook fails if the control is ever left unwired. + */ + String diagOpenFanIn() { + for (Button row : buttonsByHunkId.values()) { + Button fanIn = firstFanIn(row.getGraphic()); + if (fanIn != null) { + fanIn.fire(); + return "fired " + labelTexts(fanIn).stream().findFirst().orElse("(no label)"); + } + } + return "no fan-in control on any of " + buttonsByHunkId.size() + " path rows"; + } + + private static Button firstFanIn(Node node) { + if (node instanceof Button button + && button.getStyleClass().contains("review-fanin-count")) { + return button; + } + if (node instanceof Parent parent) { + for (Node child : parent.getChildrenUnmodifiable()) { + Button found = firstFanIn(child); + if (found != null) { + return found; + } + } + } + return null; } /** Diagnostic-only: how many cards the rail drew, and how tall each one is. */ @@ -373,5 +839,61 @@ private void applySelection() { entry.getValue().pseudoClassStateChanged(PseudoClass.getPseudoClass("selected"), entry.getKey().equals(selectedId)); } + for (Map.Entry entry : buttonsByHunkId.entrySet()) { + entry.getValue().pseudoClassStateChanged(PseudoClass.getPseudoClass("selected"), + entry.getKey().equals(selectedHunkId)); + } + } + + /** + * Test-only: PATH mode's rendered row texts, in rendered order -- + * {@code buttonsByHunkId} is a {@link LinkedHashMap} populated in the + * same loop that renders {@link #cards}, so its values() order matches. + * Reads every {@link Label}'s text inside the row's graphic (badge, + * file, reason, links), joined by newlines, since {@link #buildPathRow} + * puts the row's text on child Labels rather than the Button itself. + */ + List diagPathRowTexts() { + return buttonsByHunkId.values().stream() + .map(button -> String.join("\n", labelTexts(button.getGraphic()))) + .toList(); + } + + /** Every {@link Label}'s text under {@code node}, depth-first. */ + private static List labelTexts(Node node) { + List texts = new ArrayList<>(); + collectLabelTexts(node, texts, + Collections.newSetFromMap(new IdentityHashMap())); + return texts; + } + + /** + * Reads {@code getGraphic()} explicitly, not just children. + * A {@link Labeled}'s graphic becomes one of its children only once its + * SKIN exists, which is a layout pulse away from the moment the row is + * built -- and {@link #reasonNode} now hangs a fan-in row's reason Label + * off a nested Button as exactly that graphic. Walking children alone + * therefore reported a fan-in row with NO reason text at all for the + * first pulse or two after a render, which makes any assertion over + * these texts timing-dependent: an {@code assertFalse(anyMatch(...))} + * could pass because the text had not been parented yet rather than + * because it was absent. {@code seen} keeps the graphic from being + * counted twice once the skin does parent it. + */ + private static void collectLabelTexts(Node node, List into, Set seen) { + if (node == null || !seen.add(node)) { + return; + } + if (node instanceof Label label) { + into.add(label.getText()); + } + if (node instanceof Labeled labeled) { + collectLabelTexts(labeled.getGraphic(), into, seen); + } + if (node instanceof Parent parent) { + for (Node child : parent.getChildrenUnmodifiable()) { + collectLabelTexts(child, into, seen); + } + } } } diff --git a/app/src/main/java/app/drydock/ui/review/ReviewVerdictBar.java b/app/src/main/java/app/drydock/ui/review/ReviewVerdictBar.java index 7890c9d7..3446c6ee 100644 --- a/app/src/main/java/app/drydock/ui/review/ReviewVerdictBar.java +++ b/app/src/main/java/app/drydock/ui/review/ReviewVerdictBar.java @@ -7,6 +7,7 @@ import javafx.scene.control.Button; import javafx.scene.control.Label; import javafx.scene.control.Tooltip; +import javafx.scene.input.MouseEvent; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; import javafx.scene.layout.Region; @@ -29,16 +30,43 @@ final class ReviewVerdictBar extends VBox { /** What the bar needs from its host. All calls happen on the FX thread. */ interface Host { - void approve(ReviewIntent intent); - - void requestChanges(ReviewIntent intent); - - /** "Ask the agent to fix it" -- hands the intent's findings to the bound session. */ - void askAgentToFix(ReviewIntent intent); - - /** {@code u} -- undoes this intent's verdict. */ + /** + * {@code unit} is the acting unit CAPTURED at the moment the reader + * pressed the button (or the live one, for a keyboard/programmatic + * fire with no press to capture) -- never re-read at release time. + * A real mouse press on this button moves Scene focus off the diff + * column before the button's own action fires (JavaFX requests focus + * on press for a focusable control), which would otherwise flip + * {@link SessionReviewView#settleUnit()} to {@code SECTION} + * mid-press and settle the wrong thing on release. + */ + void approve(ReviewIntent intent, SessionReviewView.SettleUnit unit); + + void requestChanges(ReviewIntent intent, SessionReviewView.SettleUnit unit); + + /** + * "Ask the agent to fix it" -- hands the intent's open findings to + * the bound session. False when nothing was handed over: there is no + * session to hand them to, or the intent has no open finding to send. + * + *

A boolean for the same reason {@code openInExplorer} and {@code + * SessionReviewView.Host#askAgentToFix} are: this button can do + * NOTHING while looking exactly as though it worked, and a control + * that reports nothing when it did nothing is the defect family this + * branch has now spent three rounds on.

+ */ + boolean askAgentToFix(ReviewIntent intent); + + /** {@code u} -- undoes this intent's verdict; also "Re-review" on the stale banner. */ void undo(ReviewIntent intent); + /** + * "Confirm still good" on the stale banner (spec §9.2): rewrites + * the section's stale verdicts against the current base rather than + * clearing them. + */ + void confirmStillGood(ReviewIntent intent); + /** {@code n} -- moves to the next unsettled intent. */ void nextUnsettled(); @@ -52,17 +80,67 @@ interface Host { void nextIntent(); } + /** + * A section's stale verdict (spec §9.2): the base it was approved + * against, and the scope's base now. Not a {@link ReviewVerdict} -- + * a section owns no verdict of its own, only what its hunks merge to. + */ + record StaleInfo(String oldBase, String newBase) { + } + private final Host host; private final Label intentLabel = new Label(); private final Button previousButton = new Button("‹"); private final Button nextButton = new Button("›"); - private final Button approveButton = new Button("Approve intent"); - private final Button requestChangesButton = new Button("Request change"); + // Text and tooltip are both rewritten by render() to name the acting + // unit ("Approve (hunk)"); the constructor's construction argument is + // only ever visible for the single frame before the first render(). + private final Button approveButton = new Button(); + private final Button requestChangesButton = new Button(); private final Button askAgentButton = new Button("Ask the agent to fix it"); private final Button undoButton = new Button("change"); private final Label settledLabel = new Label(); + /** + * Why approval is refused. Shortened to its glyph by {@link + * #fitActionRow} when the row cannot hold the sentence; the tooltip + * carries the whole thing either way. + */ + private static final String BLOCKING_REFUSAL = "⚠ a blocking finding is still open"; + + /** + * Why "Ask the agent to fix it" handed nothing over. Both causes, because + * the boolean it acts on cannot tell them apart and naming the wrong one + * is worse than naming the pair; short, because the footer at the bar's + * real width has room for about forty characters. A constant so a test + * cannot hold a copy that drifts -- which is exactly how three submit + * refusals came to be measured at a width production never gives them. + */ + static final String NOTHING_TO_SEND = "no open findings, or no session"; + + static final String NOTHING_TO_SEND_DETAIL = + "This intent has no open finding to hand over, or this scope has no bound session to " + + "hand it to. Open the scope's session first."; + private final Label refusalLabel = new Label(); + /** + * Why an "Ask the agent to fix it" click handed nothing over -- a THIRD + * refusal, and a third Label, for the reason {@link #submitRefusalLabel} + * documents: the three are independently true (an intent can have a + * blocking finding open, no session to hand it to, AND a diff that has + * not landed). This one sits in the FOOTER rather than beside its own + * button -- see {@link #showAskRefused} for the measurement that put it + * there. + * + *

Transient, unlike {@link #refusalLabel}: it describes one click, + * not a state, so {@link #update} clears it the moment anything the bar + * renders from has changed.

+ */ + private final Label askRefusalLabel = new Label(); + /** The stale-verdict banner (spec §9.2): text plus its two answers. */ + private final Label staleLabel = new Label(); + private final Button confirmStillGoodButton = new Button("Confirm still good"); + private final Button reReviewButton = new Button("Re-review"); private final Label progressLabel = new Label(); /** "3 left · n jumps to the next" -- the first thing dropped when the row is tight. */ private final Label navHint = new Label(); @@ -82,12 +160,43 @@ interface Host { private final Label submitRefusalLabel = new Label(); private final Button submitButton = new Button("Submit review ⏎"); private final HBox actionRow = new HBox(10); + /** Fields, not locals: {@link #fitFooter} has to measure this row after construction. */ + private final HBox footer = new HBox(10); + private final Region footerSpacer = new Region(); private ReviewIntent intent; - private Optional verdict = Optional.empty(); + /** + * The SECTION's decision, derived from its hunks by {@code VerdictMerge} + * -- not a stored {@link ReviewVerdict}. Sections overlap and so cannot + * own a verdict of their own; what the bar shows is what their hunks add + * up to. + */ + private Optional decision = Optional.empty(); private boolean blocked; - private int settledCount; - private int totalCount; + private int settledHunks; + private int totalHunks; + private Optional stale = Optional.empty(); + /** + * What {@code a}/{@code r}/{@code u} act on right now (spec §9.6), + * stated on the Approve/Request-changes buttons themselves ("Approve + * (hunk)") rather than in a separate label: a droppable label is not on + * screen at the code column's floor, and a button whose own text + * contradicts what it does ("Approve intent" acting on one hunk) is + * worse than no unit statement at all. + */ + private SessionReviewView.SettleUnit actingUnit = SessionReviewView.SettleUnit.SECTION; + /** + * The acting unit captured at the moment a real mouse press landed on + * {@link #approveButton}/{@link #requestChangesButton} -- empty between + * presses, and for a keyboard or programmatic {@code fire()} that never + * pressed at all. A press moves Scene focus (JavaFX requests it on + * press for any focusable control -- see {@code app.css}'s {@code + * .review-verdict-action:focused}), which can flip {@link #actingUnit} + * mid-press if the reader had the diff column focused; the button must + * still act on what it READ when pressed, not what focus became by the + * time the reader let go. + */ + private Optional pressedUnit = Optional.empty(); ReviewVerdictBar(Host host) { this.host = host; @@ -102,7 +211,8 @@ interface Host { // title yields. Its tooltip carries what the ellipsis takes. intentLabel.setMinWidth(0); for (Button action : List.of(previousButton, nextButton, approveButton, - requestChangesButton, askAgentButton, undoButton)) { + requestChangesButton, askAgentButton, undoButton, confirmStillGoodButton, + reReviewButton)) { action.setMinWidth(Region.USE_PREF_SIZE); } navHint.getStyleClass().add("review-verdict-hint"); @@ -117,23 +227,72 @@ interface Host { nextButton.setOnAction(e -> host.nextIntent()); approveButton.getStyleClass().addAll("review-verdict-action", "primary"); - approveButton.setTooltip(new Tooltip("Approve this intent (a)")); - approveButton.setOnAction(e -> withIntent(host::approve)); + approveButton.addEventFilter(MouseEvent.MOUSE_PRESSED, e -> pressedUnit = Optional.of(actingUnit)); + approveButton.setOnAction(e -> { + SessionReviewView.SettleUnit unit = consumePressedUnit(); + withIntent(intent -> host.approve(intent, unit)); + }); requestChangesButton.getStyleClass().add("review-verdict-action"); - requestChangesButton.setTooltip(new Tooltip("Request changes on this intent (r)")); - requestChangesButton.setOnAction(e -> withIntent(host::requestChanges)); + requestChangesButton.addEventFilter(MouseEvent.MOUSE_PRESSED, + e -> pressedUnit = Optional.of(actingUnit)); + requestChangesButton.setOnAction(e -> { + SessionReviewView.SettleUnit unit = consumePressedUnit(); + withIntent(intent -> host.requestChanges(intent, unit)); + }); askAgentButton.getStyleClass().add("review-verdict-action"); askAgentButton.setTooltip(new Tooltip("Hand this intent's open findings to the bound session")); - askAgentButton.setOnAction(e -> withIntent(host::askAgentToFix)); + askAgentButton.setOnAction(e -> withIntent(intent -> { + if (host.askAgentToFix(intent)) { + clearAskRefused(); + return; + } + // Both causes, because the bar cannot tell them apart from a + // boolean and must not guess at one: naming the wrong one is + // worse than naming the pair. Short because the footer at the + // code column's floor has room for about forty characters and + // not one more -- see showAskRefused -- so the sentence lives in + // the tooltip, the way intentLabel's does. + showAskRefused(NOTHING_TO_SEND, NOTHING_TO_SEND_DETAIL); + })); + // Both classes, exactly as submitRefusalLabel does: the shared one + // for the visual treatment, its own so a test can find THIS label + // rather than the blocking-finding one beside it. + askRefusalLabel.getStyleClass().addAll("review-verdict-refusal", "review-verdict-ask-refusal"); + askRefusalLabel.setVisible(false); + askRefusalLabel.setManaged(false); undoButton.getStyleClass().add("review-verdict-action"); undoButton.setTooltip(new Tooltip("Undo this intent's verdict (u)")); undoButton.setOnAction(e -> withIntent(host::undo)); + // Each also carries a class of its own: the stale banner is the one + // place two "review-verdict-action" buttons show at once with no + // decision-dependent branch to tell them apart by position alone. + confirmStillGoodButton.getStyleClass().addAll("review-verdict-action", "primary", + "review-verdict-confirm-stale"); + confirmStillGoodButton.setTooltip( + new Tooltip("Keep this verdict, recorded against the base as it is now")); + confirmStillGoodButton.setOnAction(e -> withIntent(host::confirmStillGood)); + + reReviewButton.getStyleClass().addAll("review-verdict-action", "review-verdict-re-review"); + reReviewButton.setTooltip(new Tooltip("Clear this verdict so the section can be re-read")); + reReviewButton.setOnAction(e -> withIntent(host::undo)); + + staleLabel.getStyleClass().add("review-verdict-stale"); + staleLabel.setWrapText(true); + settledLabel.getStyleClass().add("review-verdict-settled"); refusalLabel.getStyleClass().add("review-verdict-refusal"); + // Never squeezed: the intent TITLE is the one thing in this row + // allowed to give way (see intentLabel's own minWidth(0)), and + // without this the row took its last three pixels out of the + // refusal instead -- eliding even the bare glyph, which is the one + // character that cannot be spared. + refusalLabel.setMinWidth(Region.USE_PREF_SIZE); + refusalLabel.setTooltip(new Tooltip("An open finding of this intent blocks approval. " + + "Resolve it, or lower its severity, in the findings margin.")); refusalLabel.setVisible(false); refusalLabel.setManaged(false); @@ -148,7 +307,13 @@ interface Host { progressTrack.setMinWidth(120); progressTrack.setMaxWidth(120); - hintLabel.getStyleClass().add("review-verdict-hint"); + // Both classes, the same split submitRefusalLabel uses: the shared + // one for the visual treatment, its own so a test can find THIS + // label rather than navHint, which shares the first. A test that + // could not tell them apart is why fitFooter shipped ungated on + // width -- theHintIsBackAsSoonAsThereIsRoomForIt was matching + // navHint's text and never looked at this label at all. + hintLabel.getStyleClass().addAll("review-verdict-hint", "review-verdict-shortcut-hint"); // Both classes: "review-verdict-refusal" for the shared visual // treatment, "review-verdict-submit-refusal" purely so a test can // find THIS label rather than the blocking-finding one that shares @@ -160,10 +325,9 @@ interface Host { submitButton.setTooltip(new Tooltip("Submit the review (⏎)")); submitButton.setOnAction(e -> host.submit()); - Region footerSpacer = new Region(); HBox.setHgrow(footerSpacer, Priority.ALWAYS); - HBox footer = new HBox(10, progressLabel, progressBar, hintLabel, submitRefusalLabel, - footerSpacer, submitButton); + footer.getChildren().setAll(progressLabel, progressBar, hintLabel, askRefusalLabel, + submitRefusalLabel, footerSpacer, submitButton); footer.setAlignment(Pos.CENTER_LEFT); footer.getStyleClass().add("review-verdict-footer"); @@ -178,25 +342,101 @@ private void withIntent(java.util.function.Consumer action) { } /** - * Updates everything the bar shows. + * The unit an Approve/Request-changes press just captured, or the LIVE + * one when nothing was captured -- a keyboard activation or a test's + * {@code Button.fire()} never presses at all, so those correctly read + * whatever is current right now rather than a stale snapshot from + * whenever this button was last physically pressed. + */ + private SessionReviewView.SettleUnit consumePressedUnit() { + SessionReviewView.SettleUnit unit = pressedUnit.orElse(actingUnit); + pressedUnit = Optional.empty(); + return unit; + } + + /** + * Updates what the bar says about the intent now being settled. * + * @param currentDecision the section's decision, derived from its hunks; + * empty while any of them is unread * @param blocked whether an open blocking finding refuses approval of this intent */ - void update(ReviewIntent currentIntent, Optional currentVerdict, boolean blocked, - int settled, int total) { + void update(ReviewIntent currentIntent, Optional currentDecision, + boolean blocked) { this.intent = currentIntent; - this.verdict = currentVerdict; + this.decision = currentDecision; this.blocked = blocked; - this.settledCount = settled; - this.totalCount = total; // Whatever changed enough to call update() again supersedes a // stale-diff refusal from an earlier click -- the reader has moved // on (a different scope, a diff that landed), so the message would // now be talking about a click that is no longer the most recent one. clearSubmitRefused(); + // Same reasoning, one row up: whatever changed enough to call + // update() supersedes a hand-off refusal from an earlier click. + clearAskRefused(); + render(); + } + + /** + * Progress is counted in distinct hunks, never in sections: sections + * overlap, so the sum of their sizes exceeds the number of hunks and + * "n/m sections settled" measures nothing (spec §5.6). + */ + void showProgress(int settled, int total) { + this.settledHunks = settled; + this.totalHunks = total; + render(); + } + + /** + * Told whether the section now showing has a stale verdict (spec §9.2): + * present swaps the normal actions for the banner and its two answers, + * "Confirm still good" and "Re-review". Empty renders nothing extra -- + * {@link SectionStates.Staleness#UNKNOWN} must say nothing, never warn, + * so this is only ever called with a value once {@code MOVED} is + * actually established. + */ + void showStale(Optional info) { + this.stale = info; render(); } + /** + * Told what {@code a}/{@code r}/{@code u} act on right now (spec §9.6), + * so the Approve/Request-changes buttons can say so: a key whose target + * depends on focus has to state what it is about to do, or the reader + * is guessing. + */ + void showActingUnit(SessionReviewView.SettleUnit unit) { + this.actingUnit = unit; + render(); + } + + /** + * The word the unit reads as on a button: "Approve (section)", + * "Request changes (file)". HUNK reads as "next unread hunk," not + * "hunk" alone (reversed ruling): a completed gutter click opens the + * comment composer and steals real keyboard focus into its text field, + * which the existing {@code TextInputControl} guard then makes a/r + * type into rather than trigger, and closing that composer clears the + * gutter selection along with it -- so on every real reader path, HUNK + * mode settles the section's first UNSETTLED hunk, never literally the + * one under the pointer. The label has to promise what the code + * actually does. + */ + private static String unitWord(SessionReviewView.SettleUnit unit) { + return switch (unit) { + case HUNK -> "next unread hunk"; + case SECTION -> "section"; + case FILE -> "file"; + // PATH mode: literally the row on screen, never a hunt through + // a section -- distinct wording from HUNK on purpose, since HUNK + // promises "the next unread one," a promise this case does not + // make or need. + case PATH_STEP -> "hunk"; + }; + } + /** * Told by the destination that {@link Host#submit()} could not run and * why -- e.g. the selected scope's diff has not landed, or failed to @@ -206,16 +446,128 @@ void update(ReviewIntent currentIntent, Optional currentVerdict, * looking broken. Cleared by the next {@link #update}. */ void showSubmitRefused(String reason) { + showSubmitRefused(reason, reason); + } + + /** + * As above, with a longer explanation on hover -- the same split {@link + * #showAskRefused} makes, and for the same measured reason: the footer + * has about 290px for a refusal at the code column's floor, and a + * sentence longer than that is elided mid-word. {@code reason} is what + * has to fit; {@code detail} is what the ellipsis would have taken. + */ + void showSubmitRefused(String reason, String detail) { submitRefusalLabel.setText("⚠ " + reason); + submitRefusalLabel.setTooltip(new Tooltip(detail)); submitRefusalLabel.setVisible(true); submitRefusalLabel.setManaged(true); submitButton.pseudoClassStateChanged(javafx.css.PseudoClass.getPseudoClass("refused"), true); + // The two footer refusals are MUTUALLY EXCLUSIVE. Raised together -- + // submit refuses, the reader then clicks "Ask the agent to fix it" on + // the same intent, and neither path calls update() -- they and the + // Submit button share one row's width three ways, and the primary + // action reads "Sub…". They also describe one sequence of clicks, so + // the newer one is the one the reader is owed. + clearAskRefused(); + fitFooter(); + } + + /** The short form a human recognises a commit by; the sha itself if it is already short. */ + private static String shortSha(String sha) { + return sha.length() > 7 ? sha.substring(0, 7) : sha; + } + + /** + * Says why an "Ask the agent to fix it" click handed nothing over, in + * the same visual language {@link #refusalLabel} uses for a refused + * approval and {@link #showSubmitRefused} for a refused submit. Cleared + * by the next {@link #update}. + * + *

In the footer, not beside its own button, and that + * was measured rather than chosen. At {@code RailLayout.CODE_MIN_WIDTH} + * -- the width the bar has to be operable at, since with every rail + * collapsed it is the only surface left -- the action row has about 25px + * of slack once its four actions have taken their preferred widths, and + * this label was laid out at 25 of the 319px it asked for. A refusal + * elided to an unreadable sliver is the same defect as the silence it + * replaces. The footer is the row immediately below, already the home of + * {@link #submitRefusalLabel}, and {@link #askAgentButton} carries the + * {@code :refused} pseudo-class meanwhile, so the two read as one + * event.

+ */ + private void showAskRefused(String reason, String detail) { + askRefusalLabel.setText("⚠ " + reason); + askRefusalLabel.setTooltip(new Tooltip(detail)); + askRefusalLabel.setVisible(true); + askRefusalLabel.setManaged(true); + askAgentButton.pseudoClassStateChanged( + javafx.css.PseudoClass.getPseudoClass("refused"), true); + // See showSubmitRefused: one refusal in this footer at a time. + clearSubmitRefused(); + fitFooter(); + } + + private void clearAskRefused() { + askRefusalLabel.setVisible(false); + askRefusalLabel.setManaged(false); + askAgentButton.pseudoClassStateChanged( + javafx.css.PseudoClass.getPseudoClass("refused"), false); + fitFooter(); + } + + /** + * The footer's own version of {@link #fitActionRow}'s trade: while a + * refusal is showing AND the row is too tight to hold both, the standing + * hint gives up its room to it. + * + *

Measured, not assumed. At the {@code CODE_MIN_WIDTH} floor the + * footer had 264px for a refusal that asked for 319 -- and taking it + * squeezed {@code Submit} to 39px of the 95 it wanted, which trades one + * unreadable control for another. "press ? for shortcuts" is a standing + * reminder; a refusal is about the click the reader just made, and it + * outranks it for as long as it is up.

+ * + *

Gated on the WIDTH, not merely on the refusal, the + * same way {@link #fitActionRow} gates {@code navHint}. The first version + * dropped the hint whenever a refusal showed, at any width at all -- so a + * 1400px bar with hundreds of pixels to spare still hid it, which is a + * cost paid by a layout that was never short of room.

+ */ + private void fitFooter() { + double width = footer.getWidth(); + boolean refusing = askRefusalLabel.isManaged() || submitRefusalLabel.isManaged(); + boolean room = !refusing || width <= 0 || width - footerWidthWithoutHint() >= hintLabel.prefWidth(-1); + hintLabel.setVisible(room); + hintLabel.setManaged(room); + } + + /** What the footer needs with the hint dropped -- see {@link #fitFooter}. */ + private double footerWidthWithoutHint() { + double needed = footer.getInsets().getLeft() + footer.getInsets().getRight(); + int slots = 0; + for (javafx.scene.Node child : footer.getChildren()) { + if (child == hintLabel || (!child.isManaged() && child != hintLabel)) { + continue; + } + slots++; + if (child == footerSpacer) { + continue; + } + // The LARGER of pref and min. The progress bar's 120px floor is a + // CSS -fx-min-width, and its preferred width is the fill's ~17px + // -- so measuring pref alone under-counts this row by a hundred + // pixels and concludes there is room for a hint there is not. + needed += Math.max(child.prefWidth(-1), child.minWidth(-1)); + } + // +1 slot for the hint itself, whose room is what this is deciding. + return needed + footer.getSpacing() * Math.max(0, slots); } private void clearSubmitRefused() { submitRefusalLabel.setVisible(false); submitRefusalLabel.setManaged(false); submitButton.pseudoClassStateChanged(javafx.css.PseudoClass.getPseudoClass("refused"), false); + fitFooter(); } private void render() { @@ -234,35 +586,64 @@ private void render() { previousButton.setDisable(false); nextButton.setDisable(false); - navHint.setText(settledCount >= totalCount + navHint.setText(settledHunks >= totalHunks ? "all settled — ⏎ submits" - : (totalCount - settledCount) + " left · n jumps to the next"); - - if (verdict.isPresent()) { - settledLabel.setText(verdict.get().decision().label()); + : (totalHunks - settledHunks) + " hunks left · n jumps to the next"); + + if (stale.isPresent()) { + // Takes priority over the settled branch below: a stale section + // DOES have a decision recorded, but it was given against a base + // that has since moved, so the plain "settled, here is undo" row + // would understate what is actually being asked of the reader. + staleLabel.setText("⚠ approved against base " + shortSha(stale.get().oldBase()) + + " · base is now " + shortSha(stale.get().newBase())); + actionRow.getChildren().setAll(previousButton, nextButton, intentLabel, + staleLabel, confirmStillGoodButton, reReviewButton, actionSpacer, navHint); + } else if (decision.isPresent()) { + settledLabel.setText(decision.get().label()); settledLabel.getStyleClass().removeIf(styleClass -> styleClass.startsWith("decision-")); - settledLabel.getStyleClass().add("decision-" + verdict.get().decision().wireName()); + settledLabel.getStyleClass().add("decision-" + decision.get().wireName()); actionRow.getChildren().setAll(previousButton, nextButton, intentLabel, settledLabel, undoButton, actionSpacer, navHint); } else { - refusalLabel.setText("⚠ a blocking finding is still open"); + // Named after the acting unit, not "intent": a button whose own + // label contradicts what it is about to do (spec §9.6) is worse + // than no unit statement, and this is the one surface that is + // never dropped for width, unlike a separate label would be. + String unit = unitWord(actingUnit); + approveButton.setText("Approve (" + unit + ")"); + requestChangesButton.setText("Request changes (" + unit + ")"); + // HUNK gets its own plain-language tooltip: "this hunk" would + // still read as "the one under the pointer," which is exactly + // the promise the reversed ruling says the code cannot keep. + if (actingUnit == SessionReviewView.SettleUnit.HUNK) { + approveButton.setTooltip(new Tooltip( + "Approves the next unread hunk in this section (a)")); + requestChangesButton.setTooltip(new Tooltip( + "Requests changes on the next unread hunk in this section (r)")); + } else { + approveButton.setTooltip(new Tooltip("Approve this " + unit + " (a)")); + requestChangesButton.setTooltip( + new Tooltip("Request changes on this " + unit + " (r)")); + } + refusalLabel.setText(BLOCKING_REFUSAL); refusalLabel.setVisible(blocked); refusalLabel.setManaged(blocked); approveButton.pseudoClassStateChanged( javafx.css.PseudoClass.getPseudoClass("refused"), blocked); actionRow.getChildren().setAll(previousButton, nextButton, intentLabel, - approveButton, requestChangesButton, askAgentButton, refusalLabel, - actionSpacer, navHint); + approveButton, requestChangesButton, askAgentButton, + refusalLabel, actionSpacer, navHint); } fitActionRow(actionRow.getWidth()); - progressLabel.setText(settledCount + "/" + totalCount + " intents settled"); + progressLabel.setText(settledHunks + "/" + totalHunks + " hunks reviewed"); progressTrack.setPrefWidth(120); - progressFill.setPrefWidth(totalCount == 0 ? 0 : 120.0 * settledCount / totalCount); + progressFill.setPrefWidth(totalHunks == 0 ? 0 : 120.0 * settledHunks / totalHunks); submitButton.setDisable(false); - submitButton.setText(settledCount >= totalCount + submitButton.setText(settledHunks >= totalHunks ? "Submit review ⏎" - : "Submit (" + (totalCount - settledCount) + " left)"); + : "Submit (" + (totalHunks - settledHunks) + " left)"); } /** @@ -271,6 +652,15 @@ private void render() { * it is the one thing on the bar stated nowhere else only in part: the * count repeats in the progress line and in the Submit button, and the * key it names lives in the shortcuts overlay. + * + *

A reservation, not a floor. It is what {@link + * #actionRowWidth} sets aside when deciding what else fits; the layout + * never enforces it, because {@code intentLabel.setMinWidth(0)} + * deliberately lets the title be the thing that yields. At {@code + * CODE_MIN_WIDTH} with the four actions present the title measures 14px + * against this 96 -- and that is the design working, not failing. Making + * it a real floor would mean dropping an action button at that width, + * which is a decision about the bar, not a bug in this constant.

*/ private static final double INTENT_LABEL_MIN = 96; @@ -292,6 +682,12 @@ protected void layoutChildren() { // layout pass is the earliest point the measurements are real, and // running here re-checks after a font or density change too. fitActionRow(actionRow.getWidth()); + // The footer's own fit, for the identical reason and at the identical + // moment: showAskRefused/showSubmitRefused run outside a layout pass, + // where footer.getWidth() is whatever the LAST pass left (0 before + // the first), so the decision they make there is provisional. This is + // the one that sticks. + fitFooter(); super.layoutChildren(); } @@ -299,6 +695,37 @@ private void fitActionRow(double width) { if (width <= 0) { return; } + // The blocking refusal shortens to its glyph before the nav hint is + // dropped, because it cannot be dropped: unlike the hint it is the + // reason a control the reader is pressing refuses to work. + // + // Measured: at the CODE_MIN_WIDTH floor this row has about 25px left + // once its four actions have taken their widths, and the sentence + // asks for 146 -- so it was elided to "⚠ a bl…", which says nothing + // the ⚠ alone does not. The full text stays on hover either way, so + // the short form loses no information a reader cannot reach. + if (refusalLabel.isManaged()) { + refusalLabel.setText(BLOCKING_REFUSAL); + if (actionRowWidth(width, null) > width) { + refusalLabel.setText("⚠"); + } + } + boolean room = width - actionRowWidth(width, navHint) >= navHint.prefWidth(-1); + navHint.setVisible(room); + navHint.setManaged(room); + } + + /** + * What the action row needs at its current contents, counting {@code + * excluded} (when given) as taking no room of its own -- {@code navHint} + * for the decision about whether to keep it, nothing for the decision + * above it. + * + *

{@code navHint} is measured even while it is unmanaged so the + * decision does not oscillate: dropping it would otherwise free the room + * that immediately justifies bringing it back.

+ */ + private double actionRowWidth(double width, javafx.scene.Node excluded) { double needed = actionRow.getInsets().getLeft() + actionRow.getInsets().getRight() + INTENT_LABEL_MIN; int slots = 0; @@ -307,15 +734,16 @@ private void fitActionRow(double width) { continue; } slots++; - if (child == actionSpacer || child == navHint || child == intentLabel) { + if (child == actionSpacer || child == intentLabel || child == excluded + || child == navHint) { continue; } - needed += child.prefWidth(-1); + needed += Math.max(child.prefWidth(-1), child.minWidth(-1)); } - needed += actionRow.getSpacing() * Math.max(0, slots - 1); - boolean room = width - needed >= navHint.prefWidth(-1); - navHint.setVisible(room); - navHint.setManaged(room); + if (excluded != navHint) { + needed += navHint.prefWidth(-1); + } + return needed + actionRow.getSpacing() * Math.max(0, slots - 1); } /** Test-only: whether approval is currently being refused. */ diff --git a/app/src/main/java/app/drydock/ui/review/SectionStates.java b/app/src/main/java/app/drydock/ui/review/SectionStates.java new file mode 100644 index 00000000..c8273337 --- /dev/null +++ b/app/src/main/java/app/drydock/ui/review/SectionStates.java @@ -0,0 +1,752 @@ +package app.drydock.ui.review; + +import app.drydock.git.UnifiedDiff; +import app.drydock.review.BaseMove; +import app.drydock.review.ChangeGraph; +import app.drydock.review.HunkDigest; +import app.drydock.review.IntentHunks; +import app.drydock.review.Provenance; +import app.drydock.review.RecheckDispatch; +import app.drydock.review.ReviewIntent; +import app.drydock.review.ReviewScope; +import app.drydock.review.ReviewVerdict; +import app.drydock.review.VerdictMerge; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.SortedSet; +import java.util.TreeSet; + +/** + * What a section of the review board says about itself, derived from the + * hunks it covers (spec §9.1). + * + *

Sections overlap and a verdict is keyed by a hunk's content digest, so + * nothing about a section is stored: its decision, its counts, whether the + * base has moved under it and which of its neighbours settled a hunk it + * shares are all worked out from the store on every render. That derivation + * is this class, and it is deliberately outside {@link SessionReviewView}: + * its only inputs are a {@link SessionReviewView.Host}, a {@link UnifiedDiff} + * and a list of {@link ReviewIntent}, none of them scene graph, so it can be + * tested without a {@code Stage} and read without the 1700 lines of view + * around it.

+ * + *

Not thread-safe, and not required to be: it is called from the board's + * render, which is the FX thread. Nothing here does I/O -- the two questions + * that need git ({@link SessionReviewView.Host#currentBase} and {@link + * SessionReviewView.Host#baseMove}) are answered from the host's own cache. + * The one exception is {@link #requestRechecks}, which types a prompt into a + * terminal; it is bounded to once per base move and says why it cannot be + * deferred.

+ */ +final class SectionStates { + + /** + * Whether a base move since a verdict could have changed what was + * approved. + * + *

Three states, not two. "The base moved under this" and "we cannot + * say yet" are different claims, and while the delta is still being + * computed off the FX thread only the second one is true -- warning then + * would put a confirm-me banner on every settled card of a review nobody + * has touched.

+ */ + enum Staleness { + /** The base has not moved, or the move provably could not touch this section. */ + FRESH, + /** The base moved and could have touched it: the reader has to confirm. */ + MOVED, + /** + * Cannot be told -- the delta is still in flight, or the old base can + * no longer be diffed at all. Rendered as nothing, never as a + * warning: an unanswered question is not a finding. + */ + UNKNOWN + } + + /** + * One section's rendered state, derived from its hunks (spec §9.1). + * + * @param decision what its hunks merge to, empty while any is unread -- + * includes a stale hunk's verdict; the decision is not + * what staleness puts in question + * @param settledHunks how many of its hunks carry a verdict that is + * NOT stale (spec §9.2) -- a hunk whose base has moved in + * a way that could matter does not count here, the same + * rule {@link #settledHunkCount} applies globally, so a + * card's own "n/total" and the verdict bar's progress line + * cannot disagree about what is actually settled + * @param recordedHunks how many of its hunks carry ANY verdict at all, + * stale or not (Task 18 follow-up, correction 6a). A section + * with one stale-approved hunk and one genuinely unread one + * has {@code settledHunks() == 0} -- correct for "is this + * settled" -- but a card that reads NOTHING at all still + * understates what happened: one hunk WAS recorded, it is + * only its freshness in question, and {@code ⚠ base moved} + * is the only thing on the card that says so. This is what + * the progress LABEL reads instead, so "1/2 hunks" survives + * a stale hunk the way the numeric decision does not have to. + * @param totalHunks how many hunks it covers at all + * @param staleness whether a base move since a verdict could have changed + * what was approved + * @param settledElsewhere the marks of the other sections sharing a + * settled hunk with this one, so a count that advanced + * without the reader touching this card is explained + * @param stalenessProvenance whose judgement the staleness is (spec §9.7: + * "Assessments render as CLAIMED, not measured"). A base move + * the file-level filter found is drydock's own measurement; a + * hunk marked stale because an AGENT asserted the move + * disturbed it is the agent's claim, and §6.5 exists so a + * reviewer can tell whose judgement they are looking at -- + * the two fail differently and are checkable differently + * @param hunksMissing whether this section names hunks and the diff has + * none of them -- a grouping that has drifted off the diff, + * which must not be mistaken for a section nobody has read + */ + record SectionState(Optional decision, int settledHunks, + int recordedHunks, int totalHunks, Staleness staleness, + List settledElsewhere, boolean hunksMissing, + Provenance stalenessProvenance) { + + SectionState { + settledElsewhere = List.copyOf(settledElsewhere); + } + + /** + * A section nothing can be said about yet -- no diff, or no scope. + * Distinct from a section with nothing settled: this one renders no + * counts at all, because zero hunks reviewed and "not known yet" are + * not the same claim. + */ + static SectionState unknown() { + return new SectionState(Optional.empty(), 0, 0, 0, Staleness.UNKNOWN, List.of(), false, + Provenance.MEASURED); + } + + /** + * A section whose hunk ids name nothing in the current diff. Hunk ids + * are positional ({@code h__}), so a re-diff can strand + * a grouping the agent supplied earlier; the card has to SAY so, + * because a section with no settleable hunks can never be approved + * and would otherwise refuse Submit forever with no visible reason. + */ + static SectionState notInDiff() { + return new SectionState(Optional.empty(), 0, 0, 0, Staleness.FRESH, List.of(), true, + Provenance.MEASURED); + } + } + + /** + * What the board is showing right now: which scope, which diff, and the + * grouping over it. + * + *

Passed to every method rather than held as mutable state, so a + * caller cannot derive one section against the scope now selected and its + * neighbour against the one before it. The view assembles it once per + * render from the same three things the rail is built from.

+ * + *

{@code graph} is empty both before one has been requested and while + * it is still building off the FX thread (see {@link + * SessionReviewView.Host#intents}) -- staleness widening falls back to a + * section's own files rather than ever triggering a build itself.

+ */ + record Board(ReviewScope scope, UnifiedDiff diff, List sections, + Optional graph) { + Board { + Objects.requireNonNull(scope, "scope"); + Objects.requireNonNull(diff, "diff"); + Objects.requireNonNull(graph, "graph"); + sections = List.copyOf(sections); + } + + /** Convenience for callers with no graph on hand -- most tests. */ + Board(ReviewScope scope, UnifiedDiff diff, List sections) { + this(scope, diff, sections, Optional.empty()); + } + } + + private final SessionReviewView.Host host; + + /** + * One diff's hunk digests, memoized per intent. Every card of the rail + * asks for its section's state on every rebuild, and each answer walks the + * diff hashing hunks -- on a large diff that is thousands of SHA-256s per + * keystroke, on the FX thread. + * + *

Keyed by the whole {@link ReviewIntent}, not by its id: a reviewer + * may re-issue the same id over DIFFERENT hunks, and an id-keyed memo + * would then answer with the hunks of a grouping that no longer exists. + * Emptied whenever the diff INSTANCE changes (identity, not equality), + * since re-scoping and reloading both hand over a new one.

+ */ + private UnifiedDiff digestedDiff; + private final Map> digestsByIntent = new LinkedHashMap<>(); + + SectionStates(SessionReviewView.Host host) { + this.host = Objects.requireNonNull(host, "host"); + } + + /** + * The content digests of the hunks {@code intent} covers, memoized for + * the diff they were taken from (see {@link #digestsByIntent}). + */ + List digestsOf(Board board, ReviewIntent intent) { + UnifiedDiff diff = board.diff(); + if (diff != digestedDiff) { + digestedDiff = diff; + digestsByIntent.clear(); + } + return digestsByIntent.computeIfAbsent(intent, key -> IntentHunks.digestsOf(key, diff)); + } + + /** + * The sections progress is measured over and Submit demands a verdict on: + * those that count toward progress AND still have a hunk in the diff. + * + *

Collapsed intents do not count toward progress: the point of the + * collapse is that there is nothing to read. Neither does a section whose + * hunk ids no longer resolve -- there is nothing to settle in it, so + * counting it would make the review permanently incomplete.

+ */ + List counted(Board board) { + return board.sections().stream() + .filter(ReviewIntent::countsTowardProgress) + .filter(intent -> hasResolvableHunks(board, intent)) + .toList(); + } + + /** + * Whether {@code intent} has any hunk in the current diff at all. + * + *

False for a section whose {@code hunkIds} name hunks the diff no + * longer has -- ids are positional, so a re-diff strands them. Such a + * section can never be settled (there is nothing to record a verdict + * against), so it must not be counted toward progress or demanded by + * Submit: doing so refuses Submit forever and jumps to the one card that + * cannot be settled.

+ */ + boolean hasResolvableHunks(Board board, ReviewIntent intent) { + return !digestsOf(board, intent).isEmpty(); + } + + /** + * Every hunk the counted sections cover, once each -- what progress is + * measured in. Sections overlap, so the sum of their sizes exceeds the + * number of hunks and would let a shared hunk be settled twice + * (spec §5.6). + */ + List distinctDigests(Board board) { + Set distinct = new LinkedHashSet<>(); + for (ReviewIntent intent : counted(board)) { + distinct.addAll(digestsOf(board, intent)); + } + return List.copyOf(distinct); + } + + /** + * How many of {@link #distinctDigests} carry a verdict that is not + * stale (spec §9.2). A stale verdict does not count toward "everything + * settled" -- {@link SessionReviewView#submitReview} refuses one, so a + * progress line and nav hint that counted it would tell the reader the + * opposite of what the key does: "all settled -- ⏎ submits" over a + * section Submit is about to refuse. + */ + int settledHunkCount(Board board) { + Set stale = staleDigests(board); + int settled = 0; + for (String digest : distinctDigests(board)) { + if (host.verdict(board.scope(), digest).isPresent() && !stale.contains(digest)) { + settled++; + } + } + return settled; + } + + /** + * Every digest among the counted sections' hunks whose verdict is stale + * in a way that could matter (spec §9.2) -- what {@link + * #settledHunkCount} excludes. Computed directly over each counted + * section's own hunks and file list, the same inputs {@link #stateOf} + * already uses per section, rather than re-deriving a section from a + * bare digest: a hunk shared by two sections is asked once per section + * here, but a digest already marked stale by one is not re-checked by + * the other, since {@code MOVED} could only be found the same way + * twice. + */ + private Set staleDigests(Board board) { + Set stale = new LinkedHashSet<>(); + String base = host.currentBase(board.scope()); + for (ReviewIntent intent : counted(board)) { + Collection files = filesAffectingScope(board, intent); + for (String digest : digestsOf(board, intent)) { + if (stale.contains(digest)) { + continue; + } + Optional verdict = host.verdict(board.scope(), digest); + if (verdict.isPresent() + && stalenessOf(board, verdict.get(), base, files) == Staleness.MOVED) { + stale.add(digest); + } + } + } + return stale; + } + + /** + * What a section's hunks merge to (spec §9.1) -- {@link VerdictMerge}'s + * rule, over the verdicts of the hunks it covers. + * + *

Deliberately the light derivation, free of everything {@link + * #stateOf} adds: it is what one section asks of ANOTHER, and asking + * through the full state would recurse between two sections sharing a + * hunk.

+ */ + Optional decisionOf(Board board, ReviewIntent intent) { + return VerdictMerge.derive(digestsOf(board, intent).stream() + .map(digest -> host.verdict(board.scope(), digest)) + .toList()); + } + + /** One section's rendered state, derived from its hunks (spec §9.1). */ + SectionState stateOf(Board board, ReviewIntent intent) { + List digests = digestsOf(board, intent); + if (digests.isEmpty()) { + // A section that names hunks none of which are in the diff is a + // drifted grouping, not an unread section, and says so. + return intent.hunkIds().isEmpty() + ? SectionState.unknown() + : SectionState.notInDiff(); + } + String base = host.currentBase(board.scope()); + Collection files = filesAffectingScope(board, intent); + List> perHunk = new ArrayList<>(); + Set elsewhere = new LinkedHashSet<>(); + Staleness staleness = Staleness.FRESH; + // Whose judgement the staleness is (spec §9.7). Set only when an + // agent's assessment is what made a hunk MOVED -- the file filter + // finding the move itself is drydock measuring. + Provenance stalenessProvenance = Provenance.MEASURED; + int settled = 0; + int recorded = 0; + for (String digest : digests) { + Optional verdict = host.verdict(board.scope(), digest); + perHunk.add(verdict); + if (verdict.isPresent()) { + recorded++; + // MOVED outranks UNKNOWN outranks FRESH: one hunk known to + // have moved is the strongest thing true of the section. + Staleness hunk = stalenessOf(board, verdict.get(), base, files); + if (hunk == Staleness.MOVED + || (hunk == Staleness.UNKNOWN && staleness == Staleness.FRESH)) { + staleness = hunk; + } + if (hunk == Staleness.MOVED && host.assessedAffected(board.scope(), digest, + verdict.get().baseCommit(), base)) { + stalenessProvenance = Provenance.CLAIMED; + } + // A stale verdict still merges into the section's DECISION + // (perHunk, below) -- the decision persists, only its + // freshness is in question -- but does not count toward + // the numeric "n/total", the same exclusion + // settledHunkCount applies globally (spec §9.2). Without + // this a card could read "3/3 hunks" while the verdict + // bar's own progress line, one floor up, read "2/3" for + // the identical section. recordedHunks is the escape hatch: + // it counts this hunk anyway, so the card's PROSE progress + // label does not understate to zero just because the one + // thing it has to say is stale (spec correction 6a). + if (hunk != Staleness.MOVED) { + settled++; + } + collectSharingSections(board, digest, intent, elsewhere); + } + } + return new SectionState(VerdictMerge.derive(perHunk), settled, recorded, digests.size(), + staleness, List.copyOf(elsewhere), false, stalenessProvenance); + } + + /** + * Asks the agent which approvals a base move disturbed, at most once per + * move (spec §9.7). + * + *

Driven from the render pass rather than from the moment the move is + * detected, because that is where staleness is already known: a section + * whose {@link SectionState#staleness()} is not {@code FRESH} is exactly + * one the move survived {@link BaseMove#couldMatter}'s file filter for. + * Gating on that reuses the relevance test instead of repeating it, and a + * move touching nothing this scope reads spends no subagent.

+ * + *

The render pass runs many times per move, so the guard cannot be the + * annotation store: {@link AnnotationStore#assessedAffected} reads the + * same for "assessed unaffected" and for "never asked", and therefore + * cannot see a dispatch still in flight. {@link RecheckDispatch} is that + * memory. A hand-off that returned false is released again, since it + * reached no terminal and no human is present to notice.

+ */ + void requestRechecks(Board board, RecheckDispatch dispatch) { + if (!host.supportsAutomaticRecheck(board.scope())) { + // Spec §9.7: inline harnesses do not get one. Checked before + // anything is claimed, so nothing accumulates for a scope that + // can never be asked. + return; + } + String base = host.currentBase(board.scope()); + if (SessionReviewView.UNRESOLVED_BASE.equals(base)) { + // Not a revision, so there is no base PAIR to ask about. The + // reader already sees these as stale-until-confirmed. + return; + } + Set recordedBases = new LinkedHashSet<>(); + for (ReviewIntent intent : counted(board)) { + // No section-level pre-filter. One was here and it was removed: + // stateOf does a strict SUPERSET of this loop's work -- the same + // stalenessOf per digest, plus collectSharingSections walking every + // other section, plus a VerdictMerge -- and there is no stateOf + // cache, so guarding with it could only ever ADD work per render. + // It was also behaviourally dead: deleting it changed no test, + // because "no hunk MOVED" already implies "no verdict MOVED". + Collection files = filesAffectingScope(board, intent); + for (String digest : digestsOf(board, intent)) { + host.verdict(board.scope(), digest) + // "unresolved" is not a revision on either side of the + // pair. The current base is refused above; a RECORDED + // one carries the same sentinel whenever the baseline + // was unresolved when the human settled the hunk. + // Checked BEFORE stalenessOf, which would otherwise + // hand the sentinel to the host as a base to diff. + .filter(verdict -> !SessionReviewView.UNRESOLVED_BASE + .equals(verdict.baseCommit())) + // The instruction says "for each APPROVED hunk", and a + // requested-changes verdict is not one. Written as "not + // CHANGES" rather than as a list of the approving + // decisions on purpose: nothing ENFORCES which values + // can be stored -- putVerdict takes any Decision and + // the load path accepts "auto-approved" off disk -- so + // an enumeration would silently drop an approval the + // day a new writer or a hand-edited file produces one. + // This direction fails toward asking: an extra recheck + // costs one agent run, a missed one leaves a human's + // approval unexamined and says nothing. + .filter(verdict -> verdict.decision() != ReviewVerdict.Decision.CHANGES) + // Knowingly forfeited here: a base move that is + // PERMANENTLY unresolvable -- the old base force-pushed + // away or deleted -- stays UNKNOWN and is never asked + // about. BaseMove.Delta cannot tell "in flight" from + // "gone", and an agent handed a base git can no longer + // resolve could not answer anyway. Telling the two + // apart needs a third state on Delta, which is a + // design change, not a condition to bolt on here. + // + // THE relevance test, per verdict and not per section. + // Each approval carries its OWN recorded base, and the + // host answers baseMove per base: one hunk's move can + // be MOVED while its neighbour's is still UNKNOWN (the + // git for that pair is in flight) or FRESH (that pair + // provably touched nothing this scope reads). Gating + // only on the section let a neighbour's MOVED drag + // both bases into the dispatch -- asking before + // couldMatter had answered, with the claim permanent. + .filter(verdict -> stalenessOf(board, verdict, base, files) + == Staleness.MOVED) + .ifPresent(verdict -> recordedBases.add(verdict.baseCommit())); + } + } + for (String from : recordedBases) { + if (host.assessedMove(board.scope(), from, base)) { + // SOME assessment for this pair is already on disk -- not + // necessarily about every approval recorded against it, since + // review_recheck is agent-initiated and may answer partially. + // Deliberate: the in-memory claim dies with the view, so + // without this a restart re-asks forever, and the human still + // sees the per-hunk stale mark either way. + continue; + } + // A released claim is retried on the NEXT render, and every one + // after it, until the hand-off lands. Deliberately unbounded: the + // check short-circuits on a dead or absent tab before typing + // anything, and a recheck silently abandoned is the failure this + // whole path exists to avoid. + if (dispatch.claim(board.scope().id(), from, base) + && !host.dispatchRecheck(board.scope(), from, base)) { + dispatch.release(board.scope().id(), from, base); + } + } + } + + /** + * Whether one verdict's base has moved under it, and whether that can be + * told at all. An unresolvable delta is {@link Staleness#UNKNOWN}, never + * {@code MOVED}: {@link BaseMove#couldMatter} answers true for it because + * it is the safe direction for a DECISION, but it is not evidence of a + * move and must not be rendered as one. + * + *

An agent's recheck ({@link SessionReviewView.Host#assessedAffected}, + * spec §9.7) is asked SECOND, after the base is known to have moved and + * before the file-level filter gets to dismiss the move. That order is + * the asymmetry: the agent can only turn what the filter would have + * called {@code FRESH} -- or what it cannot resolve at all -- into {@code + * MOVED}, never the reverse. The filter is lexical and admits it cannot + * see a base change that alters behaviour without touching a file this + * scope names; this is the only thing that can. An agent's "unaffected" + * reaches nothing here, by construction rather than by a branch: it is + * indistinguishable from never having been asked.

+ */ + private Staleness stalenessOf(Board board, ReviewVerdict verdict, String base, + Collection files) { + if (!verdict.staleAgainst(base)) { + // Not a move at all, so there is no move for a recheck to be + // about: a verdict recorded against the current base is fresh + // whatever any agent said about some earlier pair. + return Staleness.FRESH; + } + if (host.assessedAffected(board.scope(), verdict.hunkDigest(), verdict.baseCommit(), base)) { + return Staleness.MOVED; + } + BaseMove.Delta delta = host.baseMove(board.scope(), verdict.baseCommit()); + if (delta.unresolvable()) { + return Staleness.UNKNOWN; + } + return BaseMove.couldMatter(delta, files) ? Staleness.MOVED : Staleness.FRESH; + } + + /** + * The files a base move has to touch before it can matter to {@code + * intent}: its own files ({@link #filesOf}), plus -- when the scope's + * {@link ChangeGraph} is already in hand -- the files declaring symbols + * those files reference (spec §9.2's second half). Falls back to {@link + * #filesOf} alone when the graph is absent (still building, failed, or + * never requested for a reviewer-supplied grouping): widening is an + * improvement over the narrower set, never a reason to build one. + */ + private static Collection filesAffectingScope(Board board, ReviewIntent intent) { + List own = filesOf(board, intent); + Optional graph = board.graph(); + if (graph.isEmpty()) { + return own; + } + SortedSet widened = new TreeSet<>(own); + for (String file : own) { + widened.addAll(graph.get().filesReferencedBy(file)); + } + return widened; + } + + /** + * The mark of the FIRST other section sharing {@code digest} (in rail + * order), so a count that advanced without the reader touching this card + * is explained. + * + *

Not conditioned on the sibling being fully settled. A sibling that + * settled one shared hunk moves this card's count by exactly as much as a + * fully settled one does, and leaving that case unmarked solves the + * "state changing on its own" problem only for the easy half of it.

+ * + *

Named at most once, never every sharer (spec + * correction 6b). A verdict is keyed {@code (scopeId, hunkDigest)} alone + * -- nothing records WHICH section's card the reader actually settled it + * through -- so with three or more sections sharing one hunk there is no + * way to single out the one that "reviewed" it; naming all of them + * credited sections that, as far as this model can tell, reviewed + * nothing. Stopping at the first candidate is the fix this can honestly + * make without inventing provenance a verdict does not carry: with + * exactly one other sharer -- every case this class is tested against + * today -- it names that same one section as before.

+ */ + private void collectSharingSections(Board board, String digest, ReviewIntent self, + Set into) { + for (ReviewIntent other : board.sections()) { + if (other.id().equals(self.id()) || !other.countsTowardProgress()) { + continue; + } + if (digestsOf(board, other).contains(digest)) { + into.add(sectionMark(other.number())); + return; + } + } + } + + /** + * The files a section covers, for {@link BaseMove#couldMatter}. An intent + * that names no hunks covers the whole diff (see {@link + * ReviewIntent#containsHunk}), so its files are the diff's -- an empty + * list there would read as "touches nothing" and quietly make every base + * move irrelevant to it. + */ + private static List filesOf(Board board, ReviewIntent intent) { + List named = intent.files(); + if (!named.isEmpty()) { + return named; + } + return board.diff().files().stream().map(UnifiedDiff.FileDiff::path).toList(); + } + + /** + * The digest of {@code intent}'s FIRST hunk -- its anchor, and where + * selecting the section scrolls the diff column to (see {@code + * SessionReviewView#revealCurrentIntent}). The ultimate fallback for + * HUNK mode (see {@link #digestOfCurrentHunk}): once nothing is + * selected and nothing is left unsettled, this is what {@code a}/ + * {@code r}/{@code u} still have something to act on. Empty for a + * section with no resolvable hunk at all. + */ + Optional digestOfAnchorHunk(Board board, ReviewIntent intent) { + List digests = digestsOf(board, intent); + return digests.isEmpty() ? Optional.empty() : Optional.of(digests.get(0)); + } + + /** + * The first of {@code intent}'s hunks with no verdict yet. The middle + * fallback for HUNK mode: with the diff column acting and no gutter + * selection open, {@code a} has to walk forward through what is still + * unread rather than park on the anchor hunk forever -- pressing it + * once approves hunk one, pressing it again must not re-approve hunk + * one a second time while hunks two and up sit unread. + */ + Optional digestOfFirstUnsettledHunk(Board board, ReviewIntent intent) { + for (String digest : digestsOf(board, intent)) { + if (host.verdict(board.scope(), digest).isEmpty()) { + return Optional.of(digest); + } + } + return Optional.empty(); + } + + /** + * The digest HUNK mode acts on (spec §9.6), in priority order: the hunk + * under the diff column's gutter selection when one is open ({@code + * selectionKey}, {@code " "} -- see {@link + * ReviewDiffColumn#currentLineSelection}); else the section's first + * unsettled hunk, so the reader can walk forward with repeated presses + * of {@code a}/{@code r} rather than re-settling the same hunk forever; + * else its anchor hunk, so a fully-settled section still has something + * for {@code u} to undo. + */ + Optional digestOfCurrentHunk(Board board, ReviewIntent intent, + Optional selectionKey) { + return selectionKey.flatMap(key -> selectionFile(key) + .flatMap(file -> selectionLineKey(key) + .flatMap(lineKey -> digestOfLine(board, file, lineKey)))) + .or(() -> digestOfFirstUnsettledHunk(board, intent)) + .or(() -> digestOfAnchorHunk(board, intent)); + } + + /** The digest of the hunk containing {@code file}'s line {@code lineKey}, if any. */ + private Optional digestOfLine(Board board, String file, String lineKey) { + for (UnifiedDiff.FileDiff candidate : board.diff().files()) { + if (!candidate.path().equals(file)) { + continue; + } + for (UnifiedDiff.Hunk hunk : candidate.hunks()) { + for (UnifiedDiff.Line line : hunk.lines()) { + if (line.lineKey().equals(lineKey)) { + return Optional.of(HunkDigest.of(file, hunk)); + } + } + } + } + return Optional.empty(); + } + + /** + * The file {@code ⇧A}/{@code ⇧R} settle every hunk of (spec §9.6): the + * file under the diff column's gutter selection when one is open, else + * {@code intent}'s own anchor file (see {@link #fileOf}). + */ + Optional currentFileOf(Board board, ReviewIntent intent, Optional selectionKey) { + return selectionKey.flatMap(SectionStates::selectionFile) + .or(() -> fileOf(board, intent)); + } + + /** + * The file the diff column is anchored on when {@code intent} is + * selected -- the fallback for {@link #currentFileOf} once nothing is + * selected. The intent's own anchor file when it names one, else the + * first file it covers at all (see {@link #filesOf}). + */ + private Optional fileOf(Board board, ReviewIntent intent) { + return intent.anchor().map(ReviewIntent.Anchor::file) + .or(() -> filesOf(board, intent).stream().findFirst()); + } + + private static Optional selectionFile(String key) { + int lastSpace = key.lastIndexOf(' '); + return lastSpace < 0 ? Optional.empty() : Optional.of(key.substring(0, lastSpace)); + } + + private static Optional selectionLineKey(String key) { + int lastSpace = key.lastIndexOf(' '); + return lastSpace < 0 ? Optional.empty() : Optional.of(key.substring(lastSpace + 1)); + } + + /** + * Every hunk digest of {@code file} across the WHOLE diff, in diff + * order -- not just the slice one section names. {@code ⇧A}/{@code ⇧R} + * settle the file regardless of which section(s) claim its hunks. + */ + List digestsOfFile(Board board, String file) { + for (UnifiedDiff.FileDiff candidate : board.diff().files()) { + if (candidate.path().equals(file)) { + List digests = new ArrayList<>(); + for (UnifiedDiff.Hunk hunk : candidate.hunks()) { + digests.add(HunkDigest.of(file, hunk)); + } + return List.copyOf(digests); + } + } + return List.of(); + } + + /** + * The digests {@code a}/{@code r}/{@code u} act on for {@code intent} + * right now (spec §9.6): {@code wholeFile} is {@code ⇧A}/{@code ⇧R} and + * always wins over {@code unit}; otherwise {@code unit} decides between + * the whole section and {@link #digestOfCurrentHunk}'s one hunk. + */ + List digestsForAction(Board board, ReviewIntent intent, SessionReviewView.SettleUnit unit, + boolean wholeFile, Optional selectionKey) { + if (wholeFile) { + return currentFileOf(board, intent, selectionKey) + .map(file -> digestsOfFile(board, file)) + .orElse(List.of()); + } + return unit == SessionReviewView.SettleUnit.HUNK + ? digestOfCurrentHunk(board, intent, selectionKey).map(List::of).orElse(List.of()) + : digestsOf(board, intent); + } + + /** + * The recorded base of a stale verdict in {@code intent}, for the + * verdict bar's banner -- the first one found whose base no longer + * matches {@code board}'s scope's current one. Callers only ask this + * once {@link Staleness#MOVED} is already established, so one is + * guaranteed to exist; the current base is the fallback only because a + * method that returns nothing here is worse than one that occasionally + * repeats a base that did not move. + */ + String oldBaseOf(Board board, ReviewIntent intent) { + String current = host.currentBase(board.scope()); + for (String digest : digestsOf(board, intent)) { + Optional verdict = host.verdict(board.scope(), digest); + if (verdict.isPresent() && verdict.get().staleAgainst(current)) { + return verdict.get().baseCommit(); + } + } + return current; + } + + /** How a section is named in another section's card: its number, circled. */ + static String sectionMark(int number) { + // U+2460 is (1); the run is twenty long, and beyond it a plain "#21" + // is better than a glyph half the fonts on a machine do not carry. + return number >= 1 && number <= 20 + ? String.valueOf((char) ('\u2460' + number - 1)) + : "#" + number; + } +} diff --git a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java index f867e434..2c0df167 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -4,21 +4,33 @@ import app.drydock.git.ReviewBase; import app.drydock.git.UnifiedDiff; import app.drydock.mcp.McpActivityLog; +import app.drydock.review.BaseMove; +import app.drydock.review.ChangeGraph; +import app.drydock.review.HunkDigest; +import app.drydock.review.IntentGrouping; +import app.drydock.review.IntentHunks; +import app.drydock.review.OutOfDiffFanIn; +import app.drydock.review.ReadingPath; import app.drydock.review.ReviewAnnotation; +import app.drydock.review.Provenance; +import app.drydock.review.RecheckDispatch; import app.drydock.review.ReviewIntent; import app.drydock.review.ReviewScope; import app.drydock.review.ReviewVerdict; +import app.drydock.review.Sections; import app.drydock.review.SessionReviewScopes; import app.drydock.review.Severity; import app.drydock.review.SubmitPlan; import javafx.application.Platform; +import javafx.beans.value.ChangeListener; import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.control.Button; import javafx.scene.control.Label; import javafx.scene.control.TextInputControl; import javafx.scene.control.Tooltip; +import javafx.scene.input.KeyCode; import javafx.scene.input.KeyEvent; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; @@ -26,12 +38,21 @@ import javafx.scene.layout.Region; import javafx.scene.layout.VBox; +import java.lang.System.Logger; +import java.lang.System.Logger.Level; import java.nio.file.Path; +import java.time.Instant; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; import java.util.function.Consumer; /** @@ -53,6 +74,8 @@ */ public final class SessionReviewView extends BorderPane { + private static final Logger LOG = System.getLogger(SessionReviewView.class.getName()); + /** What the view needs from the workspace. All calls happen on the FX thread. */ public interface Host { @@ -80,23 +103,175 @@ public interface Host { /** * The intents of {@code scope}, grouping {@code diff}: the reviewer's - * grouping when one was supplied, otherwise one intent per changed - * file of the diff handed in. + * grouping when one was supplied, otherwise the computed sections of + * {@code graph} when one has finished building, otherwise one intent + * per (kind, directory) cluster of the diff handed in. * *

The diff is a parameter rather than something the host fetches, * because the only correct diff here is the one the caller has * already established belongs to {@code scope}. A host that looked it * up would be free to look up the wrong one, which is exactly the * defect this shape removes.

+ * + *

{@code graph} is empty both before one has been requested and + * while it is still building -- {@link ChangeGraph#of} is blocking + * and runs off the FX thread, so this view hands through whatever it + * has on hand rather than waiting.

+ */ + List intents(ReviewScope scope, UnifiedDiff diff, Optional graph); + + /** + * How many times {@code scope}'s reviewer-supplied grouping has + * changed ({@code IntentGrouping.version}). {@code diff} and {@code + * graph} are values this view already compares by identity to keep + * {@link #intents()}'s own cache fresh; a reviewer's grouping is the + * one input to {@link #intents} that changes with NEITHER of those + * changing; this is what lets the cache survive across more than + * one call without polling {@link #intents} on every one just to + * find out nothing changed -- which is what running {@code + * Sections.of} on every navigation keypress amounted to. + */ + long groupingVersion(ReviewScope scope); + + /** + * Whether a reviewer has already supplied {@code scope}'s grouping. + * A reviewer's grouping always wins over the computed sections (see + * {@link #intents}), so building the {@link ChangeGraph} it would + * otherwise take to compute them is pure waste when one already has + * -- real parsing work, and a background completion whose only + * observable effect is a needless extra {@code refreshReviewState}. + */ + boolean hasReviewerGrouping(ReviewScope scope); + + /** + * The verdict recorded on one hunk, if any -- keyed by the hunk's + * content digest, never by an intent id. A section has no verdict of + * its own: sections overlap, and an agent may regroup them at any + * time, so a verdict keyed on a grouping would be orphaned by that + * regrouping (spec §9.2). What a section shows is what its hunks + * merge to, which is this view's job to derive. + */ + Optional verdict(ReviewScope scope, String hunkDigest); + + /** + * Records one verdict per hunk of {@code intent}; {@code decision} + * empty undoes them all. + * + *

{@code hunkDigests} is computed by the caller rather than by the + * host, for the reason {@link #intents} takes its diff as a parameter: + * only this view knows which diff the human is actually looking at, + * and a host free to re-derive them is free to derive them from a + * different one. {@code blocked} comes along for the same reason: + * the host refuses an {@code APPROVED} decision while it is true + * (spec §4.6), and only this view can say so -- it is the one place + * with the full current intents list a finding's named id has to be + * checked against, which {@link #belongsToIntent} needs to tell a + * finding that legitimately names a DIFFERENT, still-current intent + * from one whose named id no longer resolves to anything at all. A + * host computing its own approximation from {@code intent} alone + * previously disagreed with the verdict bar's own rendered "blocked" + * for exactly that case -- silently refusing a keypress the bar had + * just shown as clear.

+ */ + void setVerdict(ReviewScope scope, ReviewIntent intent, List hunkDigests, + Optional decision, boolean blocked); + + /** + * "Confirm still good" (spec §9.2): rewrites each of {@code + * hunkDigests}' existing verdict to record it against the scope's + * CURRENT base and head rather than the one it was judged against, + * through {@link ReviewVerdict#confirmedAgainst}. A digest with no + * recorded verdict is left alone -- there is nothing stale to + * confirm. Rewriting the base rather than clearing the verdict is + * the point: the decision survives, only the staleness does not. + */ + void confirmStillGood(ReviewScope scope, List hunkDigests); + + /** + * The commit {@code scope}'s base ref resolves to now, or {@link + * #UNRESOLVED_BASE} when it cannot be resolved. + * + *

A commit, never the ref name: {@link + * ReviewVerdict#staleAgainst} is {@code !baseCommit.equals(currentBase)}, + * so a verdict recorded against {@code "main"} and compared against + * {@code "main"} could never be stale and staleness would be an inert + * no-op. {@code "unresolved"} can equal no real sha, so a scope whose + * base cannot be resolved reads as stale until a human confirms it -- + * fail-safe with no second code path.

+ */ + String currentBase(ReviewScope scope); + + /** + * What moved between {@code recordedBase} and {@code scope}'s current + * base, so a base move that provably could not touch a section does + * not spend the reader's attention on it (see {@link BaseMove}). + * + *

Called on the FX thread, so it must never block: a host that + * cannot answer yet returns an {@link BaseMove.Delta#unresolvable} + * delta -- "could matter", the safe direction -- rather than a + * confident empty one.

+ */ + BaseMove.Delta baseMove(ReviewScope scope, String recordedBase); + + /** + * Whether an agent said, through {@code review_recheck}, that the move + * from {@code fromBase} to {@code toBase} undermines the approval on + * {@code hunkDigest} (spec §9.7). + * + *

Consulted only to ADD staleness. {@link BaseMove}'s intersection + * is file-level and lexical and names its own blind spot -- a base + * change that alters behaviour without touching a file this scope + * references -- and this is how that blind spot closes. The other + * direction does not exist: an agent's "unaffected" is advice, and a + * board that let it clear a verdict would leave a human's approval + * standing over code nobody re-read. So false and "never asked" are + * one answer here, deliberately.

+ * + *

Keyed by the base PAIR, so a later base move is a new question + * rather than an old answer carried forward.

+ */ + boolean assessedAffected(ReviewScope scope, String hunkDigest, String fromBase, String toBase); + + /** + * Asks the scope's agent which approvals the move from {@code + * fromBase} to {@code toBase} actually disturbed, so the assessment is + * usually already there when the reviewer returns rather than arriving + * after a wait exactly when they wanted to move on (spec §9.7). + * + *

False when the hand-off did not happen -- no bound session, or + * its tab is not open -- exactly like {@link #runReview} and {@link + * #askAgentToFix}. The caller must not record a dispatch it did not + * make: nobody is watching an automatic recheck, so a failure swallowed + * here is a scope that silently never gets one.

*/ - List intents(ReviewScope scope, UnifiedDiff diff); + boolean dispatchRecheck(ReviewScope scope, String fromBase, String toBase); - /** The verdict recorded on one intent, if any. */ - Optional verdict(ReviewScope scope, ReviewIntent intent); + /** + * Whether this scope's agent may be asked for a recheck WITHOUT a + * human having asked (spec §9.7: "inline harnesses simply do not get + * one"). + * + *

An automatic dispatch types a prompt into a live terminal with + * nobody watching. A harness that can run it in a subagent absorbs + * that; an inline one would have it land in the middle of whatever it + * was doing. The two providers that lack subagents also report no + * activity at all, so there is no idle signal to wait for -- the + * choice is dispatch-regardless or do not dispatch, and the spec + * chose.

+ */ + boolean supportsAutomaticRecheck(ReviewScope scope); - /** Records a verdict; {@code decision} empty undoes it. */ - void setVerdict(ReviewScope scope, ReviewIntent intent, - Optional decision); + /** + * Whether the agent has ALREADY answered about this exact base pair, + * whatever it said. + * + *

Not {@link #assessedAffected}, which folds "said unaffected" and + * "never asked" into one answer on purpose. Here the two must be told + * apart: this is the durable half of the dispatch guard, and it is + * what stops an app restart -- which empties the in-memory claim -- + * from re-asking a question whose answer is already on disk.

+ */ + boolean assessedMove(ReviewScope scope, String fromBase, String toBase); /** Resolve / Reopen one finding. */ void setResolved(ReviewScope scope, ReviewAnnotation finding, boolean resolved); @@ -136,8 +311,16 @@ void setVerdict(ReviewScope scope, ReviewIntent intent, /** Records the human's severity override. */ void overrideSeverity(ReviewScope scope, ReviewAnnotation finding, Severity severity); - /** Hands an intent's open findings to the scope's bound session. */ - void askAgentToFix(ReviewScope scope, ReviewIntent intent, List findings); + /** + * Hands an intent's open findings to the scope's bound session. + * False when there is no session to hand them to (or nothing to + * hand), so a caller can say so rather than appear to have asked -- + * the same contract, and for the same reason, as {@link + * #openInExplorer}: a control that reports nothing when it did + * nothing is the silent failure this branch has now had to fix + * three times. + */ + boolean askAgentToFix(ReviewScope scope, ReviewIntent intent, List findings); /** * Posts the review once every intent is settled. {@code index} @@ -162,13 +345,73 @@ void setVerdict(ReviewScope scope, ReviewIntent intent, boolean runReview(ReviewScope scope); } + /** + * The base a scope resolves to when its ref cannot be resolved at all -- + * a branch that is not in this checkout, or a git that would not run. + * + *

A literal string rather than an {@code Optional} or a sentinel with + * its own comparison rule: {@link ReviewVerdict#staleAgainst} already + * asks {@code !baseCommit.equals(currentBase)}, and no real sha can equal + * this, so an unresolvable base reads as stale through the code path that + * was already there. Fail-safe by construction.

+ */ + public static final String UNRESOLVED_BASE = "unresolved"; + + /** + * What {@code a} / {@code r} / {@code u} act on (spec §9.6). Reading is + * per hunk; settling usually is not, so one key needs several possible + * targets rather than one key needing one each. + */ + enum SettleUnit { + /** The rail has focus: every hunk of the current section, as before this task. */ + SECTION, + /** The diff column has focus: just the hunk it is anchored on. */ + HUNK, + /** {@code ⇧A} / {@code ⇧R}: every hunk of the current file, regardless of focus. */ + FILE, + /** + * PATH mode is showing (Task 18): exactly the row selected there, + * regardless of where real Scene focus is. Unlike {@code HUNK} -- + * which settles a SECTION's next unread hunk, never literally the + * one under the pointer (see {@code ReviewVerdictBar#unitWord}) -- + * this settles the literal hunk the rail is displaying, because a + * reader looking at one specific row and pressing {@code a} must not + * have something else entirely recorded. + */ + PATH_STEP + } + private final Host host; private final ReviewScopeSwitcher switcher = new ReviewScopeSwitcher(); private final ReviewDiffColumn diffColumn; private final ReviewIntentRail intentRail = new ReviewIntentRail(); + /** Everything a section says about itself, derived from its hunks. */ + private final SectionStates sections; + + /** + * Which base moves have already had their automatic recheck sent (spec + * §9.7). Lives here, not in the store: a dispatch in flight is invisible + * to {@code assessedAffected}, and the render pass that sends it runs many + * times per move. + */ + private final RecheckDispatch recheckDispatch = new RecheckDispatch(); private final ReviewFindingsMargin margin; private final ReviewVerdictBar verdictBar; + /** + * Re-renders the verdict bar's acting-unit statement on every Scene + * focus change (see {@link #settleUnit()}). Held as a field, rather + * than an inline lambda passed straight to {@code addListener}, purely + * so {@link #close()} can remove the SAME instance it was added with -- + * {@code ObservableValue.removeListener} matches by reference, and a + * second lambda expression is never {@code equals} to the first. + * Assigned in the constructor body (not here) because it closes over + * {@link #verdictBar}, itself assigned in the constructor body -- a + * field initializer referencing it here runs, per javac's definite- + * assignment analysis, before that assignment has happened. + */ + private final ChangeListener focusOwnerListener; + /** The MCP activity panel; absent when no server is running (tests, headless). */ private final Optional mcpPanel; @@ -188,6 +431,172 @@ void setVerdict(ReviewScope scope, ReviewIntent intent, */ private final Map outcomeByScope = new HashMap<>(); + /** + * Virtual threads for building a scope's {@link ChangeGraph} -- off the + * FX thread, because {@link ChangeGraph#of} parses every changed file + * and can trigger a first-time native grammar load. Separate from any + * git-lookup executor purely so a stack trace says which of the two is + * stuck. + */ + private static final Executor SECTION_GRAPH_EXECUTOR = + runnable -> Thread.ofVirtual().name("drydock-section-graph").start(runnable); + + /** + * Each scope's {@link ChangeGraph}, once built. Absent while none has + * been requested yet, or one is still building -- {@link #intents()} + * passes {@link Optional#empty()} through in that gap, and {@link + * IntentGrouping} falls back to the (kind, directory) clustering, so the + * rail is never empty while the graph is in flight. + */ + private final Map graphByScope = new HashMap<>(); + + /** + * Guards a superseded graph build from overwriting a newer one: bumped + * every time a fresh diff for a scope starts a new build, and checked + * before the result is published. Without it, a scope re-diffed twice in + * quick succession could have its second, current diff's graph + * overwritten by the first, slower build finishing last. + */ + private final Map graphGenerationByScope = new HashMap<>(); + + /** + * The diff instance each scope's current (or in-flight) graph was built + * from, so {@link #requestGraph} can tell "a genuinely new diff landed" + * from "the same cached {@code Loaded} outcome was re-published" -- a + * scope switch back to a cached diff re-publishes the SAME {@link + * UnifiedDiff} object through {@code onDiffResolved} (see {@link + * #outcomeByScope}'s own javadoc on exactly why), and re-parsing an + * unchanged diff through {@link ChangeGraph#of} on every such switch + * would waste the very work that cache exists to avoid. + */ + private final Map graphedDiffByScope = new HashMap<>(); + + /** + * Scopes with a {@link ChangeGraph} build currently in flight, so the + * rail can say the grouping on screen is provisional -- the (kind, + * directory) fallback, not necessarily the final computed one -- rather + * than silently swapping cards under a reviewer with no warning at all. + */ + private final Set graphBuilding = new HashSet<>(); + + /** + * Set by {@link #close()}. A graph build already running when a view + * closes is left to finish -- there is no cancelling a virtual thread + * mid-parse -- but its completion must not still touch this view's state + * or post to the FX thread afterwards: a closed view's {@link + * SessionReviewView} instances pile up across a test suite (a fresh one + * per test method), and an unguarded completion queues a {@code + * Platform.runLater} for every one of them that outlives its own test, + * competing for the FX thread with whatever runs next. + */ + private volatile boolean closed; + + /** + * {@link #intents()}'s last computed result, reused across as many + * calls -- and as many {@link #refreshReviewState()} passes -- as + * {@code scope}, {@code diff}, {@code graph} and {@link + * Host#groupingVersion} stay the same. Every navigation keypress + * ({@code [}, {@code ]}, {@code n}, {@code a}, {@code r}, {@code u}) + * ends in a full refresh, and {@link #findingsForMargin}, {@link + * #currentIntent()} (itself called from several places), {@link + * #renderVerdictBar} and the rail's own {@code setIntents} call all + * read {@link #intents()} independently within each one -- so without + * this cache, {@code Sections.of} ran on the FX thread multiple times + * PER KEYPRESS, measured at over a second of real work on this branch's + * own diff, none of which {@code Sections.of}'s own contract permits. + * + *

Those four fields are the ONLY inputs {@link IntentGrouping + * intentsFor} has: {@code diff} and {@code graph} are plain values + * compared by identity, and {@code groupingVersion} is the one thing + * that can change with neither of those changing -- a reviewer's own + * {@code set}/{@code clear}. Four unchanged fields is therefore exactly + * as fresh a claim as recomputing, for however many refreshes that + * holds, which is normally many: a reviewer's grouping changes far less + * often than the cursor moves.

+ */ + private IntentsCacheEntry intentsCache; + + /** One completed {@link #intents()} lookup, keyed by what it was computed from. */ + private record IntentsCacheEntry(String scopeId, UnifiedDiff diff, ChangeGraph graph, + long groupingVersion, List intents) { + } + + /** + * {@code p}: the rail's second mode, one row per hunk in reading order + * across section boundaries (spec §7.1). A mode of the rail, never a + * fourth column -- {@link RailLayout} is untouched by this task -- so + * this is the ONE bit that decides which of {@link + * ReviewIntentRail#setIntents} / {@link ReviewIntentRail#showPath} the + * next {@link #refreshReviewState} calls. + */ + private boolean pathMode; + + /** + * What a scope's fan-in is until its scan has actually run: {@code + * unavailable=true}, the honest input for a signal nothing has measured + * yet. {@link ReadingPath#of}'s own reason text says so ("outside callers + * unknown") rather than reading a scan that did not run as one that + * found nothing -- which is the whole distinction the fan-in affordance + * rests on (spec §4.3). + */ + private static final OutOfDiffFanIn.Result FAN_IN_NOT_SCANNED = + new OutOfDiffFanIn.Result(Map.of(), true); + + /** + * Each scope's out-of-diff fan-in scan, once it has finished. Absent + * until then, which {@link #fanInFor} reads as {@link + * #FAN_IN_NOT_SCANNED}. + * + *

Populated off the FX thread on {@link #SECTION_GRAPH_EXECUTOR}, + * from {@link #requestGraph}'s own completion: {@link + * OutOfDiffFanIn#scan} spawns a blocking {@code git grep} with a 30s + * timeout, and it needs the {@link ChangeGraph}'s changed declarations + * as its patterns, so it can neither run on the FX thread nor run + * before the graph exists.

+ */ + private final Map fanInByScope = new HashMap<>(); + + /** + * Guards a superseded fan-in scan from overwriting a newer one, exactly + * as {@link #graphGenerationByScope} does for the graph build -- the + * scan is the slower of the two, so the window it is stale in is wider. + */ + private final Map fanInGenerationByScope = new HashMap<>(); + + /** + * Diagnostics: the thread the last fan-in scan actually ran on. + * + *

Recorded rather than assumed. "It runs off the FX thread" is the one + * property of this scan a reader cannot see and a refactor can silently + * take away -- {@code Sections.of} on the FX thread already froze this + * board for ~2.7 seconds once -- so it is written down where a test can + * assert it. Volatile: written on a virtual thread, read on the FX one.

+ */ + private volatile String fanInScanThread; + + /** The row the verdict bar's {@code [} / {@code ]} / {@code n} move in PATH mode. */ + private int pathIndex; + + /** + * {@link #currentPath()}'s last computed result, reused across calls the + * same way {@link #intentsCache} is -- {@link ReadingPath#of} runs + * {@link Sections#of} first and is, like it, string work over an + * already-built graph rather than something to pay for on every + * keystroke. + */ + private PathCacheEntry pathCache; + + private static final ReadingPath.Path EMPTY_PATH = new ReadingPath.Path(List.of(), List.of()); + + /** + * One completed {@link #currentPath()} lookup, keyed by what it was + * computed from -- the fan-in result included, so the path recomputes + * once a scan lands rather than serving the pre-scan order forever. + */ + private record PathCacheEntry(String scopeId, UnifiedDiff diff, ChangeGraph graph, + OutOfDiffFanIn.Result fanIn, ReadingPath.Path path) { + } + /** The scopes this session offers, once {@link SessionReviewScopes} has measured them. */ private Optional scopes = Optional.empty(); @@ -205,17 +614,63 @@ void setVerdict(ReviewScope scope, ReviewIntent intent, /** The intent the verdict bar is settling; {@code [} / {@code ]} / {@code n} move it. */ private int intentIndex; + /** + * {@link #intents()}'s result as of the last {@link #refreshReviewState} + * pass, purely so the NEXT pass can tell whether the grouping changed + * underneath the same scope and re-anchor {@link #intentIndex} by + * content when it did -- see {@link #reanchorCursor}. + */ + private List lastIntents = List.of(); + + /** The scope {@link #lastIntents} belongs to; a scope switch must not reanchor against it. */ + private String lastIntentsScopeId; + + /** + * PATH mode's counterpart to {@link #lastIntents}: the steps the rail + * last rendered, so a path that RE-SORTS under the reader can be told + * from one that merely re-rendered -- see {@link #reanchorPathCursor}. + */ + private List lastPathSteps = List.of(); + + /** The scope {@link #lastPathSteps} belongs to; a scope switch must not reanchor against it. */ + private String lastPathScopeId; + /** * The id of the intent {@code a}/{@code r} last recorded a verdict on, - * so {@code u} can undo THAT one -- see {@link #undoVerdict}. Cleared - * once undone, so a second {@code u} with nothing left to undo is inert - * rather than reaching for an unrelated intent. Not touched by {@code - * [}/{@code ]}/{@code n}: moving the cursor around must not change what - * {@code u} targets, or "settle one, look at another, undo" would undo - * the wrong one. + * so {@code u} can snap the cursor back to it -- see {@link + * #undoVerdict}. Cleared once undone, so a second {@code u} with + * nothing left to undo is inert rather than reaching for an unrelated + * intent. Not touched by {@code [}/{@code ]}/{@code n}: moving the + * cursor around must not change what {@code u} targets, or "settle one, + * look at another, undo" would undo the wrong one. */ private Optional lastSettledIntentId = Optional.empty(); + /** + * The EXACT digests {@code a}/{@code r} last recorded a verdict on, so + * {@code u} clears exactly those and nothing more -- since {@code a}/ + * {@code r} may have settled one hunk, one section or one file + * depending on {@link #settleUnit()} at the time, undoing "the whole + * current intent" (as before this task) would over-clear a single-hunk + * approval or under-clear a whole-file one. + */ + private List lastSettledDigests = List.of(); + + /** + * Whether {@link #lastSettledDigests} was recorded by {@link + * #pathVerdictAction} rather than {@link #verdictAction}'s intents-mode + * branch -- {@code u} has to know which of the two to undo through, + * since a step has no {@code id} the way an intent does (a step's own + * identity is its hunk id, tracked in {@link #lastSettledPathHunkId} + * instead). Set at the moment a verdict actually took, not read from + * {@link #pathMode} at undo time: pressing {@code p} between settling + * and undoing must not change which of the two {@code u} reaches for. + */ + private boolean lastSettledWasPath; + + /** PATH mode's counterpart to {@link #lastSettledIntentId}: the exact step {@code u} snaps back to. */ + private Optional lastSettledPathHunkId = Optional.empty(); + /** Set by {@code m}/{@code f}; remembered independently of the responsive collapse. */ private boolean marginCollapsedByUser; @@ -268,9 +723,12 @@ void setVerdict(ReviewScope scope, ReviewIntent intent, public SessionReviewView(Host host, DiffService diffService, McpActivityLog activityLog) { this.mcpPanel = ReviewMcpActivityPanel.createIfAvailable(activityLog); this.host = host; + this.sections = new SectionStates(host); this.diffColumn = new ReviewDiffColumn(diffService, host::openInExplorer); this.margin = new ReviewFindingsMargin(new MarginHost()); this.verdictBar = new ReviewVerdictBar(new VerdictHost()); + this.focusOwnerListener = + (obs, oldOwner, newOwner) -> verdictBar.showActingUnit(settleUnit()); getStyleClass().addAll("review-destination", "session-review"); // Review must never hold the window open. Its computed minimum is the // sum of the rail's and the margin's own minimums plus the code @@ -291,8 +749,7 @@ public SessionReviewView(Host host, DiffService diffService, McpActivityLog acti margin.setOnToggleCollapse(() -> setMarginCollapsed(!margin.collapsed())); intentRail.setOnToggleCollapse(() -> setIntentsCollapsed(!intentRail.collapsed())); - intentRail.setVerdictLookup(intent -> - selectedScope().flatMap(scope -> host.verdict(scope, intent))); + intentRail.setSectionStateLookup(this::sectionState); intentRail.setOnSelected(intent -> { List current = intents(); int index = current.indexOf(intent); @@ -302,6 +759,21 @@ public SessionReviewView(Host host, DiffService diffService, McpActivityLog acti revealCurrentIntent(); } }); + intentRail.setOnPathSelected(step -> { + List steps = currentPath().steps(); + int index = steps.indexOf(step); + if (index >= 0) { + pathIndex = index; + refreshReviewState(); + revealCurrentPathStep(); + } + }); + // The fan-in count is an affordance, not a statistic (spec §7.4): + // "called from 7 places outside the change" is the one reason on the + // rail naming evidence the reader cannot see from where they are. + intentRail.setFanIn(step -> !fanInOccurrences(step.file()).isEmpty(), + (step, anchor) -> diffColumn.showFanIn(step.file(), + fanInOccurrences(step.file()), anchor, () -> askAboutFanIn(step))); margin.setOnFilterChanged(filter -> refreshReviewState()); diffColumn.setPinSource(new PinSource()); diffColumn.setCommentSink(annotation -> selectedScope().ifPresent(scope -> { @@ -321,15 +793,56 @@ public SessionReviewView(Host host, DiffService diffService, McpActivityLog acti // from an empty diff and never recovers. diffColumn.setOnDiffResolved((scopeId, outcome) -> { outcomeByScope.put(scopeId, outcome); + boolean selected = selectedScope().map(scope -> scope.id().equals(scopeId)).orElse(false); + if (outcome instanceof DiffOutcome.Loaded loaded) { + // Unconditional (Task 19): the diff column's link footers + // (spec §7.2) need this scope's graph regardless of the + // rail's own mode or grouping source, not only where a + // reviewer's grouping was itself computed from one or where + // PATH mode is showing. requestGraph is a no-op for a diff + // instance it has already graphed or is already building, so + // this costs nothing on a re-diff or a re-selection. The + // rail's OWN "refining grouping…" banner is gated + // separately in refreshReviewState -- a reviewer's already- + // final INTENTS grouping must not flash it while this build + // runs purely for links. + requestGraph(scopeId, loaded.diff()); + } else { + graphByScope.remove(scopeId); + } // Only the selected scope's arrival changes what is on screen; // a superseded one still records its outcome, so coming back to // it does not re-run git. - if (selectedScope().map(scope -> scope.id().equals(scopeId)).orElse(false)) { + if (selected) { refreshReviewState(); - revealCurrentIntent(); + revealCurrentSelection(); } }); + // See settleUnit()'s javadoc for why this reads real Scene focus + // rather than a hand-tracked region flag: a flag toggled from a + // MOUSE_PRESSED filter on the whole rail/column desyncs from a + // scrollbar drag, the rail's own collapse toggle, and keyboard-only + // navigation, none of which are a click on a card or into the diff. + // The label has to stay live across whatever moves real focus, not + // just the actions this view itself triggers, so it listens for + // that directly rather than piggybacking on refreshReviewState(). + // + // focusOwnerListener is held as a field, and this add/remove pair + // (repeated in close()) is deliberate: the Scene handed in here is + // app-lifetime (AppShell builds one for the whole application), so + // a listener added and never removed keeps every SessionReviewView + // ever opened -- diff column included -- strongly reachable for + // the process's life, and re-attaching without removing the old + // one first would stack a second listener under the same Scene. + sceneProperty().addListener((obs, oldScene, newScene) -> { + if (oldScene != null) { + oldScene.focusOwnerProperty().removeListener(focusOwnerListener); + } + if (newScene != null) { + newScene.focusOwnerProperty().addListener(focusOwnerListener); + } + }); widthProperty().addListener((obs, old, width) -> applyResponsiveLayout(width.doubleValue())); addEventFilter(KeyEvent.KEY_PRESSED, this::onKeyPressed); setFocusTraversable(true); @@ -464,6 +977,22 @@ public Optional selectedScope() { return scopes.map(available -> available.forChoice(choice)); } + /** + * Either of this session's two scopes by id, whichever it is -- unlike + * {@link #selectedScope}, not necessarily the one the chips show. {@code + * onDiffResolved} only carries a scope id (a diff can resolve for the + * scope NOT currently selected), and deciding whether to build a graph + * for it needs the real {@link ReviewScope} to ask the host about. + */ + private Optional scopeById(String scopeId) { + return scopes.flatMap(available -> { + if (available.local().id().equals(scopeId)) { + return Optional.of(available.local()); + } + return available.pullRequest().filter(pr -> pr.id().equals(scopeId)); + }); + } + /** Which chip is showing, after the fallback {@link #showScopes} applies. */ public SessionReviewScopes.Choice selectedChoice() { return choice; @@ -498,12 +1027,17 @@ private void renderSelectedScope() { headerTitle.setText(headerTitleFor(scope)); headerContext.setText(headerContextFor(scope)); intentIndex = 0; + pathIndex = 0; + forgetPathCursorHistory(); // Fallback intent ids are NOT scope-namespaced ("auto:change:src" is // just (kind, directory)), so two different scopes with a similar // layout can mint the identical id -- leaving this set across a // scope switch could make u undo, and jump into, a same-named // intent in the WRONG scope. lastSettledIntentId = Optional.empty(); + lastSettledDigests = List.of(); + lastSettledPathHunkId = Optional.empty(); + lastSettledWasPath = false; // The cursor is reset BEFORE the body is built, which the destination // did the other way round: a cached diff publishes Loaded // synchronously from inside bodyFor, and the diff-resolved handler @@ -516,7 +1050,7 @@ private void renderSelectedScope() { // to reveal and the setOnDiffResolved handler does it when it lands. // Revealing here too covers the case where it already has -- coming // back to a scope whose diff is still cached. - revealCurrentIntent(); + revealCurrentSelection(); } /** @@ -623,29 +1157,139 @@ public void refreshCounts() { * from a cached value silently discards the other writer's work. */ public void refreshReviewState() { + // #intentsCache is NOT invalidated here: every input intentsFor has + // -- scope, diff, graph, the reviewer's groupingVersion -- is + // already covered by the cache's own key, so a refresh triggered by + // something else entirely (a finding written, a verdict recorded) + // correctly reuses it rather than re-running Sections.of to + // rediscover the same answer. Optional scope = selectedScope(); updateRunReviewButton(); updateCountsLabel(); if (scope.isEmpty()) { margin.setFindings(List.of()); - verdictBar.update(null, Optional.empty(), false, 0, 0); + verdictBar.update(null, Optional.empty(), false); + verdictBar.showProgress(0, 0); // No scope selected means no rail: leaving the previous scope's // cards up here is how the rail came to list a departed item's // files (see the whole-branch review this fixes). - intentRail.setIntents(List.of(), null, ReviewIntentRail.Empty.NONE); + intentRail.setIntents(List.of(), null, ReviewIntentRail.Empty.NONE, + Provenance.MEASURED); + intentRail.setGroupingPending(false); mcpPanel.ifPresent(panel -> panel.setScope(null)); + lastIntents = List.of(); + lastIntentsScopeId = null; return; } + String scopeId = scope.get().id(); + List currentIntents = intents(); + // Re-anchor the cursor BEFORE anything below reads it: a grouping + // swap for the SAME scope (the computed graph landing over the + // fallback shown while it built, or a reviewer's own regroup) must + // not leave intentIndex pointing at whatever now happens to sit at + // the same position -- verdictAction reads currentIntent() fresh at + // keypress time, so a swap between a read and a keypress would + // otherwise record an approval against hunks never actually read. + if (scopeId.equals(lastIntentsScopeId) && !currentIntents.equals(lastIntents)) { + reanchorCursor(lastIntents, currentIntents); + } + lastIntents = currentIntents; + lastIntentsScopeId = scopeId; + + // Asks the agent about approvals this scope's base move disturbed. + // Guarded per (scope, fromBase, toBase), so the many renders inside + // one move send one recheck (see SectionStates#requestRechecks). + board().ifPresent(current -> sections.requestRechecks(current, recheckDispatch)); + margin.invalidate(null); margin.setFindings(findingsForMargin(scope.get())); diffColumn.refreshPins(); - intentRail.setIntents(intents(), currentIntent().map(ReviewIntent::id).orElse(null), - emptyReason()); + diffColumn.setLinks(linksByHunk()); + if (pathMode) { + List steps = currentPath().steps(); + // The same re-anchoring reanchorCursor does for INTENTS, for the + // same reason and with more at stake: pathIndex is a POSITION, + // and the out-of-diff fan-in scan is the reading path's first + // rank term, so a scan landing mid-read re-sorts these steps + // under the reader. Clamping alone would leave the cursor on + // whatever hunk now occupies that position -- and since + // settleUnit() is PATH_STEP unconditionally in this mode, the + // reader's next `a` would approve a hunk they were never shown. + if (scopeId.equals(lastPathScopeId) && !steps.equals(lastPathSteps)) { + pathIndex = reanchorPathCursor(steps); + } + lastPathSteps = steps; + lastPathScopeId = scopeId; + if (!steps.isEmpty()) { + pathIndex = Math.clamp(pathIndex, 0, steps.size() - 1); + } + String selectedHunkId = steps.isEmpty() ? null : steps.get(pathIndex).hunkId(); + intentRail.showPath(steps, selectedHunkId, emptyReason()); + } else { + // Spec §8: reads and the agent's array order are both the + // agent's claim; only a grouping drydock computed itself is + // measured. hasReviewerGrouping is exactly that distinction. + intentRail.setIntents(currentIntents, currentIntent().map(ReviewIntent::id).orElse(null), + emptyReason(), + host.hasReviewerGrouping(scope.get()) + ? Provenance.CLAIMED + : Provenance.MEASURED); + } + // The graph now builds unconditionally (Task 19, for the diff + // column's link footers), but the rail's OWN "refining grouping…" + // banner is about the RAIL's content, not the graph's existence: a + // reviewer's INTENTS grouping is already final and does not change + // when this build lands, so the banner stays gated on the same two + // cases requestGraph used to be gated on before this task widened + // its OWN trigger -- PATH mode (which reads the graph directly) and + // no reviewer grouping (whose INTENTS fallback is what the graph + // completing actually refines). + intentRail.setGroupingPending(graphBuilding.contains(scopeId) + && (pathMode || !host.hasReviewerGrouping(scope.get()))); mcpPanel.filter(Node::isVisible) .ifPresent(panel -> panel.setScope(scope.get())); renderVerdictBar(scope.get()); } + /** + * Re-anchors {@link #intentIndex} across a grouping change for the same + * scope: to the same id when it still exists (nothing about the + * selected intent actually changed), otherwise to whichever new intent + * overlaps it in the most hunks (the grouping changed identity, not the + * code being read). Left alone -- clamped to the new list's bounds at + * most -- only when nothing in the new grouping shares any hunk with + * what was selected, which a scope switch already guards this from + * being asked to do at all (see the call site). + */ + private void reanchorCursor(List previous, List current) { + if (previous.isEmpty() || current.isEmpty()) { + return; + } + ReviewIntent previouslySelected = previous.get(Math.clamp(intentIndex, 0, previous.size() - 1)); + for (int i = 0; i < current.size(); i++) { + if (current.get(i).id().equals(previouslySelected.id())) { + intentIndex = i; + return; + } + } + Set previousHunks = new HashSet<>(previouslySelected.hunkIds()); + int bestIndex = -1; + int bestOverlap = 0; + for (int i = 0; i < current.size(); i++) { + int overlap = 0; + for (String hunkId : current.get(i).hunkIds()) { + if (previousHunks.contains(hunkId)) { + overlap++; + } + } + if (overlap > bestOverlap) { + bestOverlap = overlap; + bestIndex = i; + } + } + intentIndex = bestIndex >= 0 ? bestIndex : Math.clamp(intentIndex, 0, current.size() - 1); + } + /** * What the top bar states about the board: how much code is in it. The * destination said "N items · M repos" here, which a single checkout has @@ -678,31 +1322,67 @@ private List findingsForMargin(ReviewScope scope) { return all.stream().filter(finding -> belongsToCurrentIntent(finding)).toList(); } + /** Whether a finding belongs under the intent now selected. See {@link #belongsToIntent}. */ + private boolean belongsToCurrentIntent(ReviewAnnotation finding) { + return belongsToIntent(finding, currentIntent().orElse(null)); + } + /** - * Whether a finding belongs under the intent now selected. + * Whether {@code finding} belongs under {@code intent}. * *

Matched by id when the finding names an intent the current grouping * actually contains, and by file otherwise. That second path is the * important one: a finding can name an intent that no longer exists -- - * a reviewer re-grouped, or the finding was stored under an older - * grouping and read back. Matching on the id alone made such a finding - * belong to no intent at all, so it silently disappeared from every - * margin instead of being shown somewhere. A finding is a thing a human - * or an agent went to the trouble of writing down; it must not be - * possible for the UI to lose one by regrouping around it.

+ * a reviewer re-grouped, or the computed graph landed over the fallback + * grouping the finding was recorded against. Matching on the id alone + * made such a finding belong to no intent at all, so it silently + * disappeared from every margin instead of being shown somewhere. A + * finding is a thing a human or an agent went to the trouble of writing + * down; it must not be possible for the UI to lose one by regrouping + * around it.

+ * + *

{@code intent} is a parameter rather than always {@link + * #currentIntent()} because {@link #blockingFindingOpen} needs the SAME + * rule stated for an arbitrary intent -- a finding naming a DIFFERENT + * intent that still exists must not count against this one just because + * it happens to touch one of this intent's files, which is exactly the + * distinction a stale, no-longer-resolvable id cannot make for itself. + * Reusing this one method is what keeps the verdict bar's own rendered + * "blocked" and the write path's refusal from disagreeing.

+ * + *

This is a deliberate relaxation from the write-path filter this + * method replaced, which ended in {@code .orElse(true)}: an unnamed + * finding used to block approval of EVERY intent, no matter which files + * it actually touched. Here an unnamed finding only blocks the intents + * whose files it touches, same as a named-but-stale one -- consistent + * with what the verdict bar already showed, but it does mean an unnamed + * blocking finding no longer blocks approval of an intent none of whose + * files it touches.

*/ - private boolean belongsToCurrentIntent(ReviewAnnotation finding) { - ReviewIntent current = currentIntent().orElse(null); - if (current == null) { + private boolean belongsToIntent(ReviewAnnotation finding, ReviewIntent intent) { + if (intent == null) { return true; } String named = finding.intentId().orElse(null); - if (named != null && intents().stream().anyMatch(intent -> intent.id().equals(named))) { - return named.equals(current.id()); + if (named != null && intents().stream().anyMatch(candidate -> candidate.id().equals(named))) { + return named.equals(intent.id()); } // Unnamed, or naming an intent this grouping does not have: fall back // to where the finding actually is. - return current.touches(finding.file()); + return intent.touches(finding.file()); + } + + /** + * Whether a still-open finding blocks approving {@code intent} (spec + * §4.6) -- the same rule {@link #belongsToIntent} states for the + * verdict bar's own rendered "blocked", reused here so the write path + * (every {@code host.setVerdict} call site) can never refuse a keypress + * the bar just showed as clear, or the reverse. + */ + private boolean blockingFindingOpen(ReviewScope scope, ReviewIntent intent) { + return host.findings(scope).stream() + .filter(finding -> belongsToIntent(finding, intent)) + .anyMatch(ReviewAnnotation::blocksApproval); } /** @@ -724,10 +1404,296 @@ private List intents() { if (scope.isEmpty()) { return List.of(); } - if (selectedOutcome().orElse(null) instanceof DiffOutcome.Loaded loaded) { - return host.intents(scope.get(), loaded.diff()); + if (!(selectedOutcome().orElse(null) instanceof DiffOutcome.Loaded loaded)) { + return List.of(); + } + String scopeId = scope.get().id(); + UnifiedDiff diff = loaded.diff(); + ChangeGraph graph = graphByScope.get(scopeId); + long groupingVersion = host.groupingVersion(scope.get()); + IntentsCacheEntry cached = intentsCache; + if (cached != null && cached.scopeId().equals(scopeId) && cached.diff() == diff + && cached.graph() == graph && cached.groupingVersion() == groupingVersion) { + return cached.intents(); } - return List.of(); + List computed = host.intents(scope.get(), diff, Optional.ofNullable(graph)); + intentsCache = new IntentsCacheEntry(scopeId, diff, graph, groupingVersion, computed); + return computed; + } + + /** + * The selected scope's reading path (spec §6): {@link #EMPTY_PATH} until + * its {@link ChangeGraph} exists, whether that is because none was + * requested yet, one is still building off the FX thread, or the scope + * itself has no diff -- {@link ReadingPath#of} takes a graph, not an + * {@code Optional} of one, and there is nothing honest to compute a + * reading order FROM before one exists. + * + *

Correction 2 of this task, in code: this calls {@link Sections#of} + * exactly once, purely to hand its result to {@link ReadingPath#of} as + * the grouping to reorder -- the result of that one call is never itself + * rendered. Every reader of PATH mode (the rail, and {@link + * #revealCurrentPathStep}) walks {@link ReadingPath.Path#steps()}, whose + * {@link ReadingPath.Step#sectionNumber} already indexes {@link + * ReadingPath.Path#sections()} -- the grouping's own order is never on + * screen anywhere in this mode.

+ */ + private ReadingPath.Path currentPath() { + Optional scope = selectedScope(); + if (scope.isEmpty()) { + return EMPTY_PATH; + } + if (!(selectedOutcome().orElse(null) instanceof DiffOutcome.Loaded loaded)) { + return EMPTY_PATH; + } + String scopeId = scope.get().id(); + UnifiedDiff diff = loaded.diff(); + ChangeGraph graph = graphByScope.get(scopeId); + if (graph == null) { + return EMPTY_PATH; + } + OutOfDiffFanIn.Result fanIn = fanInFor(scopeId); + PathCacheEntry cached = pathCache; + if (cached != null && cached.scopeId().equals(scopeId) && cached.diff() == diff + && cached.graph() == graph && cached.fanIn() == fanIn) { + return cached.path(); + } + ReadingPath.Path computed = + ReadingPath.of(diff, graph, Sections.of(diff, graph), fanIn); + pathCache = new PathCacheEntry(scopeId, diff, graph, fanIn, computed); + return computed; + } + + /** + * {@link #currentPath()}'s links, keyed by {@link ReviewIntent#hunkId} -- + * what the diff column renders as a footer beneath each hunk (spec + * §7.2), independent of whether the rail itself is in PATH mode. A step + * with no links is left out of the map entirely rather than mapped to an + * empty list, so {@link ReviewDiffColumn#setLinks} sees exactly the + * hunks that have something to say and none that do not. + */ + private Map> linksByHunk() { + Map> byHunk = new LinkedHashMap<>(); + for (ReadingPath.Step step : currentPath().steps()) { + if (!step.links().isEmpty()) { + byHunk.put(step.hunkId(), step.links()); + } + } + return byHunk; + } + + /** + * Kicks off building {@code diff}'s {@link ChangeGraph} on {@link + * #SECTION_GRAPH_EXECUTOR}, off the FX thread. Until it finishes, {@code + * scopeId} has no entry in {@link #graphByScope}, so {@link #intents()} + * passes {@link Optional#empty()} through and the rail shows the (kind, + * directory) clustering rather than nothing. + * + *

A no-op when {@code diff} is the SAME instance already graphed (or + * being graphed) for this scope -- every scope flip back to a cached + * {@code Loaded} outcome, and every untracked-files toggle, republishes + * that diff through {@code onDiffResolved} again, and re-parsing an + * unchanged diff on every one of those would re-open the window a + * superseded build could publish a stale graph into, for no new + * information.

+ */ + private void requestGraph(String scopeId, UnifiedDiff diff) { + if (graphedDiffByScope.get(scopeId) == diff) { + return; + } + graphedDiffByScope.put(scopeId, diff); + int generation = graphGenerationByScope.merge(scopeId, 1, Integer::sum); + graphByScope.remove(scopeId); + // A new diff invalidates the old scan as surely as it does the old + // graph: fan-in is measured against THIS diff's changed + // declarations, and serving the previous one's counts would put a + // clickable "called from 7 places" on a file that no longer declares + // any of them. + fanInByScope.remove(scopeId); + graphBuilding.add(scopeId); + CompletableFuture.supplyAsync(() -> ChangeGraph.of(diff), SECTION_GRAPH_EXECUTOR) + .whenComplete((graph, failure) -> { + // Closed already: do not even queue FX work for it. A + // closed view still building a graph is common under a + // test suite -- a fresh view per test method -- and left + // unguarded, every one of them posts to the FX thread + // whenever its parse happens to finish, well after its + // own test moved on. + if (closed) { + return; + } + Platform.runLater(() -> { + boolean current = Objects.equals(graphGenerationByScope.get(scopeId), generation); + if (!current) { + // A newer diff for this scope started a second + // build before this one finished; this callback + // is stale, and the newer build's own callback + // owns clearing "building" and refreshing. + return; + } + // The CURRENT generation clears "building" and + // refreshes either way, success or failure: a stale + // "refining grouping..." banner is exactly the + // regression a build that settles without a refresh + // produces, and it would otherwise sit there until + // some unrelated store change happened to refresh. + graphBuilding.remove(scopeId); + if (failure == null) { + graphByScope.put(scopeId, graph); + requestFanIn(scopeId, diff, graph); + } else { + // The (kind, directory) fallback is the honest + // answer, not a broken rail -- but a failed + // build must not be permanent: graphedDiffByScope + // recorded this diff BEFORE the parse ran, so + // without clearing it here, requestGraph's own + // "already graphed" guard would treat every + // later republish of this same diff instance as + // nothing new and never retry. + graphedDiffByScope.remove(scopeId); + LOG.log(Level.WARNING, "Could not build a section graph for scope " + + scopeId, failure); + } + if (!closed && selectedScope().map(scope -> scope.id().equals(scopeId)) + .orElse(false)) { + refreshReviewState(); + // PATH mode's own reveal is a no-op with no graph + // (revealCurrentPathStep falls back to "whole + // scope" -- see its javadoc), and nothing else + // re-narrows the diff column once this landed: + // without this, entering PATH mode BEFORE a graph + // exists leaves the column showing the whole diff + // forever, even once real steps appear in the + // rail moments later. + revealCurrentSelection(); + } + }); + }); + } + + /** + * Runs {@code scopeId}'s out-of-diff fan-in scan (spec §4.3) on {@link + * #SECTION_GRAPH_EXECUTOR}, off the FX thread. + * + *

Off the FX thread is not a preference: {@link OutOfDiffFanIn#scan} + * spawns a {@code git grep} over the whole worktree and waits up to 30 + * seconds for it. {@code Sections.of} on the FX thread already froze + * this board for over a second on this branch's own diff; a subprocess + * would be far worse.

+ * + *

Kicked off from {@link #requestGraph}'s completion rather than + * beside it, because the scan's patterns ARE the graph's changed + * declarations -- there is nothing to grep for before one exists. It + * inherits that build's cache for free as a result: one scan per (scope, + * diff), since one graph is built per (scope, diff).

+ */ + private void requestFanIn(String scopeId, UnifiedDiff diff, ChangeGraph graph) { + Optional target = scopeById(scopeId); + if (target.isEmpty()) { + return; + } + ReviewScope scope = target.get(); + int generation = fanInGenerationByScope.merge(scopeId, 1, Integer::sum); + CompletableFuture + .supplyAsync(() -> { + fanInScanThread = Thread.currentThread().getName(); + return OutOfDiffFanIn.forScope(scope, graph, diff); + }, SECTION_GRAPH_EXECUTOR) + .whenComplete((result, failure) -> { + // Closed already: do not even queue FX work for it, for + // the reason requestGraph's own guard exists. + if (closed) { + return; + } + Platform.runLater(() -> { + if (closed + || !Objects.equals(fanInGenerationByScope.get(scopeId), generation)) { + // A newer diff started a second scan before this + // one finished; that scan's completion owns the + // answer. + return; + } + if (failure != null) { + // Nothing is recorded, so fanInFor keeps + // reporting "not scanned" -- absent, never zero. + LOG.log(Level.WARNING, "Could not scan out-of-diff fan-in for scope " + + scopeId, failure); + return; + } + // A scan that confirms what the board is already + // showing does not disturb the reader. The common + // case is a scope with nothing to grep (no worktree, + // or a checkout git cannot read): the answer is the + // same "unavailable, nothing measured" the board + // started with, and re-rendering the rail and + // re-narrowing the diff column to say so would move + // the ground under whoever is mid-review. + OutOfDiffFanIn.Result previous = fanInFor(scopeId); + if (previous.unavailable() == result.unavailable() + && previous.bySymbol().equals(result.bySymbol())) { + return; + } + fanInByScope.put(scopeId, result); + if (selectedScope().map(current -> current.id().equals(scopeId)) + .orElse(false)) { + refreshReviewState(); + // The scan is the reading path's FIRST rank term, + // so a landing scan can reorder the rail under + // the reader: the selected index then names a + // different step, and the diff column is narrowed + // to the old one until something re-reveals it. + revealCurrentSelection(); + } + }); + }); + } + + /** + * {@code scopeId}'s fan-in scan, or {@link #FAN_IN_NOT_SCANNED} while + * none has finished. Never a bare empty {@link OutOfDiffFanIn.Result}: + * "the scan has not run" and "nothing outside the change uses this" are + * different facts, and every surface downstream of this draws that + * distinction. + */ + private OutOfDiffFanIn.Result fanInFor(String scopeId) { + return fanInByScope.getOrDefault(scopeId, FAN_IN_NOT_SCANNED); + } + + /** + * Every out-of-diff use of what {@code file} declares, by symbol. + * + *

Iterated over {@link ChangeGraph#changedDeclarations()} -- a sorted + * set -- rather than over {@code bySymbol()}, whose iteration order is + * the scan's to choose and therefore not something a rendered list may + * rest on. {@link ReadingPath} documents the same rule for the same + * reason; a popover that listed the same callers in a different order on + * a second run would be a determinism defect (spec §9.5), not a + * cosmetic one.

+ * + *

Empty for an unavailable scan, so a count that was never measured + * cannot render as one that came out zero.

+ */ + private Map> fanInOccurrences(String file) { + Optional scope = selectedScope(); + if (scope.isEmpty()) { + return Map.of(); + } + ChangeGraph graph = graphByScope.get(scope.get().id()); + OutOfDiffFanIn.Result fanIn = fanInFor(scope.get().id()); + if (graph == null || fanIn.unavailable()) { + return Map.of(); + } + Map> bySymbol = new LinkedHashMap<>(); + for (String symbol : graph.changedDeclarations()) { + if (!graph.fileDeclaring(symbol).filter(file::equals).isPresent()) { + continue; + } + List occurrences = fanIn.bySymbol().get(symbol); + if (occurrences != null && !occurrences.isEmpty()) { + bySymbol.put(symbol, List.copyOf(occurrences)); + } + } + return bySymbol; } /** @@ -762,53 +1728,202 @@ private Optional currentIntent() { return Optional.of(intents.get(Math.clamp(intentIndex, 0, intents.size() - 1))); } - private void renderVerdictBar(ReviewScope scope) { - List intents = intents(); - Optional current = currentIntent(); - if (current.isEmpty()) { - verdictBar.update(null, Optional.empty(), false, 0, 0); - return; - } - // Collapsed intents do not count toward progress: the point of the - // collapse is that there is nothing to read. - List counted = intents.stream() - .filter(ReviewIntent::countsTowardProgress) - .toList(); - long settled = counted.stream() - .filter(intent -> host.verdict(scope, intent).isPresent()) - .count(); - boolean blocked = host.findings(scope).stream() - .filter(this::belongsToCurrentIntent) - .anyMatch(ReviewAnnotation::blocksApproval); - verdictBar.update(current.get(), host.verdict(scope, current.get()), blocked, - (int) settled, counted.size()); + /** + * The selected scope's diff, once it has loaded. Empty covers both "still + * diffing" and "there is no scope" -- neither of which is a diff with no + * hunks in it. + */ + private Optional loadedDiff() { + return selectedOutcome().orElse(null) instanceof DiffOutcome.Loaded loaded + ? Optional.of(loaded.diff()) + : Optional.empty(); } /** - * Points the diff column at the current intent. - * - *

The column narrows to that intent's hunks rather than merely - * scrolling to them. Scrolling was what this did before, and on a - * 45-file diff it was indistinguishable from doing nothing: the reader - * clicked intent 12 and got the same wall of code, so the rail read as - * decoration. The column's own {@code whole scope} chip is the way - * back out (spec §4.4).

+ * What the board is showing, for {@link SectionStates}. Empty whenever + * there is nothing to derive a section state from -- no scope, or a diff + * that has not landed -- which the callers below each answer for + * themselves rather than guessing at a default here. */ - private void revealCurrentIntent() { - ReviewIntent intent = currentIntent().orElse(null); - diffColumn.setIntent(intent); - // Still scrolled, for the case the reader has taken the escape hatch: - // the whole scope is on screen and the intent has to be found in it. - if (intent != null) { - intent.anchor().ifPresent(anchor -> - diffColumn.revealHunk(anchor.file(), anchor.hunkIndex())); - } + private Optional board() { + return selectedScope().flatMap(scope -> loadedDiff() + .map(diff -> new SectionStates.Board(scope, diff, intents(), + Optional.ofNullable(graphByScope.get(scope.id()))))); } - /** {@code [} / {@code ]}: moves the intent the verdict bar is settling. */ - private void moveIntent(int delta) { - List intents = intents(); - if (intents.isEmpty()) { + /** The content digests of the hunks {@code intent} covers; none without a diff. */ + private List digestsOf(ReviewIntent intent) { + return board().map(b -> sections.digestsOf(b, intent)).orElse(List.of()); + } + + /** + * The digests {@code a}/{@code r}/{@code u} act on for {@code intent} + * over {@code unit} -- see {@link SectionStates#digestsForAction}. None + * without a diff to derive them from. + * + *

{@code unit} is a parameter, never {@link #settleUnit()} read + * afresh in here: the keyboard path computes it once, at key-press time, + * and a mouse click on the verdict bar's own Approve/Request-changes + * button captures it at PRESS time (see {@code ReviewVerdictBar}) -- + * pressing a focusable button moves Scene focus off the diff column + * before the button's action fires, and re-reading {@code settleUnit()} + * here would silently answer with whatever focus became by release, + * not what it was when the reader decided to press.

+ */ + private List digestsForAction(ReviewIntent intent, SettleUnit unit, boolean wholeFile) { + return board().map(b -> sections.digestsForAction(b, intent, unit, wholeFile, + diffColumn.currentLineSelection())) + .orElse(List.of()); + } + + /** What {@code intent}'s hunks merge to; nothing without a diff to merge over. */ + private Optional decisionOf(ReviewIntent intent) { + return board().flatMap(b -> sections.decisionOf(b, intent)); + } + + /** One section's rendered state (spec §9.1). */ + private SectionStates.SectionState sectionState(ReviewIntent intent) { + return board().map(b -> sections.stateOf(b, intent)) + .orElseGet(SectionStates.SectionState::unknown); + } + + /** The sections progress is measured over and Submit demands a verdict on. */ + private List countedSections() { + return board().map(sections::counted).orElse(List.of()); + } + + /** + * What {@code a} / {@code r} / {@code u} act on right now (spec §9.6): + * {@code HUNK} when the diff column has real focus, {@code SECTION} + * otherwise -- the same default the keys have always had, so a reader + * who has never clicked into the diff column sees no change. + * + *

Reads the Scene's actual focus owner and walks its parent chain, + * rather than a hand-tracked flag toggled from a {@code MOUSE_PRESSED} + * filter on the whole rail or column: that flag desyncs the moment + * something else moves real focus without going through this view's own + * filters -- dragging the diff's scrollbar, clicking the rail's own + * collapse toggle, or Tab-key navigation, none of which are "the reader + * clicked a card or into the diff." A live Scene read has none of those + * gaps, and is equally immune to the bug an earlier attempt hit with + * {@code Node.isFocusWithin()}: that bug was a stuck ref-count (see + * {@code ReviewIntentRail#rebuild}'s card replacement and JavaFX's + * {@code Direction.NEXT} focus-cleanup traversal, in the project's + * JavaFX-traps memory) that read {@code true} while the REAL focus + * owner's own parent chain never touched the diff column at all -- a + * fresh read of {@code getFocusOwner()} every time never accumulates + * that kind of staleness.

+ */ + SettleUnit settleUnit() { + // PATH mode wins outright, regardless of focus: the whole point of + // the mode is that the reader is looking at one specific hunk, and + // "focus happens to be elsewhere" must not silently widen what a/r/u + // touch back out to a whole section the reader never opened -- see + // the CRITICAL fix this constant carries (Task 18 follow-up). + if (pathMode) { + return SettleUnit.PATH_STEP; + } + return isDescendantOf(getScene() == null ? null : getScene().getFocusOwner(), diffColumn) + ? SettleUnit.HUNK + : SettleUnit.SECTION; + } + + private static boolean isDescendantOf(Node node, Node ancestor) { + for (Node n = node; n != null; n = n.getParent()) { + if (n == ancestor) { + return true; + } + } + return false; + } + + private void renderVerdictBar(ReviewScope scope) { + Optional board = board(); + if (pathMode) { + renderVerdictBarForPathStep(scope, board); + return; + } + Optional current = currentIntent(); + if (current.isEmpty() || board.isEmpty()) { + verdictBar.update(null, Optional.empty(), false); + verdictBar.showProgress(0, 0); + verdictBar.showStale(Optional.empty()); + verdictBar.showActingUnit(settleUnit()); + return; + } + boolean blocked = blockingFindingOpen(scope, current.get()); + SectionStates.SectionState state = sectionState(current.get()); + verdictBar.update(current.get(), state.decision(), blocked); + // Progress is the UNION of the counted sections' hunks, counted once. + verdictBar.showProgress(sections.settledHunkCount(board.get()), + sections.distinctDigests(board.get()).size()); + verdictBar.showStale(state.staleness() == SectionStates.Staleness.MOVED + ? Optional.of(new ReviewVerdictBar.StaleInfo( + sections.oldBaseOf(board.get(), current.get()), host.currentBase(scope))) + : Optional.empty()); + verdictBar.showActingUnit(settleUnit()); + } + + /** + * PATH mode's own verdict-bar render: the SELECTED ROW's own state, not + * the (now invisible) intents cursor's -- a screenshot proved the bar + * used to read "2 · Profiler" with a completely different row selected, + * and clicking Undo cleared two hunks nowhere near the one on screen. + * Reuses {@link SectionStates} against a throwaway single-hunk {@link + * ReviewIntent} ({@link #pathStepAsIntent}) rather than deriving + * anything new: asking "what does this one-hunk grouping's state look + * like" is exactly the question {@code SectionStates.stateOf} already + * answers correctly for any {@link ReviewIntent}, real or synthetic. + * Progress stays the whole-review count either way -- it was never the + * current intent's own count, so PATH mode changes nothing about it. + */ + private void renderVerdictBarForPathStep(ReviewScope scope, Optional board) { + Optional step = currentPathStep(); + if (step.isEmpty() || board.isEmpty()) { + verdictBar.update(null, Optional.empty(), false); + verdictBar.showProgress(0, 0); + verdictBar.showStale(Optional.empty()); + verdictBar.showActingUnit(settleUnit()); + return; + } + ReviewIntent synthetic = pathStepAsIntent(step.get()); + boolean blocked = blockingFindingOpenForPathStep(scope, step.get(), false); + SectionStates.SectionState state = sectionState(synthetic); + verdictBar.update(synthetic, state.decision(), blocked); + verdictBar.showProgress(sections.settledHunkCount(board.get()), + sections.distinctDigests(board.get()).size()); + verdictBar.showStale(state.staleness() == SectionStates.Staleness.MOVED + ? Optional.of(new ReviewVerdictBar.StaleInfo( + sections.oldBaseOf(board.get(), synthetic), host.currentBase(scope))) + : Optional.empty()); + verdictBar.showActingUnit(settleUnit()); + } + + /** + * Points the diff column at the current intent. + * + *

The column narrows to that intent's hunks rather than merely + * scrolling to them. Scrolling was what this did before, and on a + * 45-file diff it was indistinguishable from doing nothing: the reader + * clicked intent 12 and got the same wall of code, so the rail read as + * decoration. The column's own {@code whole scope} chip is the way + * back out (spec §4.4).

+ */ + private void revealCurrentIntent() { + ReviewIntent intent = currentIntent().orElse(null); + diffColumn.setIntent(intent); + // Still scrolled, for the case the reader has taken the escape hatch: + // the whole scope is on screen and the intent has to be found in it. + if (intent != null) { + intent.anchor().ifPresent(anchor -> + diffColumn.revealHunk(anchor.file(), anchor.hunkIndex())); + } + } + + /** {@code [} / {@code ]}: moves the intent the verdict bar is settling. */ + private void moveIntent(int delta) { + List intents = intents(); + if (intents.isEmpty()) { return; } intentIndex = (int) Math.clamp((long) intentIndex + delta, 0, intents.size() - 1); @@ -823,10 +1938,14 @@ private void nextUnsettledIntent() { if (scope.isEmpty() || intents.isEmpty()) { return; } + // Only a section that can actually be settled: a collapsed one has + // nothing to read, and one whose hunk ids no longer resolve has + // nothing to settle, so parking the cursor on either is a dead end. + List countable = countedSections(); for (int offset = 1; offset <= intents.size(); offset++) { int candidate = (intentIndex + offset) % intents.size(); ReviewIntent intent = intents.get(candidate); - if (intent.countsTowardProgress() && host.verdict(scope.get(), intent).isEmpty()) { + if (countable.contains(intent) && decisionOf(intent).isEmpty()) { intentIndex = candidate; refreshReviewState(); revealCurrentIntent(); @@ -835,6 +1954,363 @@ private void nextUnsettledIntent() { } } + // ---- PATH mode ------------------------------------------------------------ + + /** Which of the rail's two modes is showing -- test seam for the {@code p} parity test. */ + ReviewIntentRail.Mode railMode() { + return intentRail.mode(); + } + + /** + * {@code p}: flips the rail between {@code INTENTS} and {@code PATH} + * (spec §7.1). The mode flips immediately either way -- {@link + * #refreshReviewState} renders PATH mode with however many steps {@link + * #currentPath()} can answer with right now, which is {@code List.of()} + * until a {@link ChangeGraph} exists. + * + *

Entering PATH mode is what makes this task ask for a graph a + * reviewer's own grouping would otherwise never need: {@link + * #requestGraph} is a no-op when one is already in flight or already + * built for this diff, so a scope with no reviewer grouping (which + * already triggered a build on diff-resolved) pays nothing extra here, + * and one that DOES have a reviewer's grouping -- which skips that + * automatic build entirely, see {@code Host#hasReviewerGrouping} -- gets + * its graph built for the first time, lazily, only once a human actually + * asks to read in this order.

+ */ + private void togglePathMode() { + pathMode = !pathMode; + if (pathMode) { + pathIndex = 0; + forgetPathCursorHistory(); + selectedScope().ifPresent(scope -> loadedDiff().ifPresent(diff -> + requestGraph(scope.id(), diff))); + } + refreshReviewState(); + revealCurrentSelection(); + } + + /** {@code [} / {@code ]}: moves whichever cursor the rail is currently showing. */ + private void moveSelection(int delta) { + if (pathMode) { + movePathStep(delta); + } else { + moveIntent(delta); + } + } + + /** {@code n}: jumps to the next unsettled hunk, in whichever order the rail is showing. */ + private void nextUnsettled() { + if (pathMode) { + nextUnsettledPathStep(); + } else { + nextUnsettledIntent(); + } + } + + /** Reveals whatever the rail's current mode has selected. */ + private void revealCurrentSelection() { + if (pathMode) { + revealCurrentPathStep(); + } else { + revealCurrentIntent(); + } + } + + /** + * Points the diff column at the current PATH row -- the same narrowing + * {@link #revealCurrentIntent} does for an intent, over a single hunk + * instead of a whole section. Built as a one-hunk {@link ReviewIntent} + * purely to reuse {@link ReviewDiffColumn#setIntent}'s existing filter + * and anchor machinery -- {@code containsHunk} and {@code anchor()} both + * already do exactly what a single {@link ReadingPath.Step} needs, and + * duplicating them for a second selectable type would be the same + * behaviour twice. + * + *

Falls back to the whole scope ({@code setIntent(null)}) while {@link + * #currentPath()} has no steps yet -- entering PATH mode before its + * {@link ChangeGraph} exists is the common case, not a corner one, so + * this must be called again once the graph lands (see {@link + * #requestGraph}'s completion callback) or the column would stay on + * "whole scope" forever even after the rail fills in with real rows.

+ */ + private void revealCurrentPathStep() { + List steps = currentPath().steps(); + if (steps.isEmpty()) { + diffColumn.setIntent(null); + return; + } + ReadingPath.Step step = steps.get(Math.clamp(pathIndex, 0, steps.size() - 1)); + ReviewIntent synthetic = pathStepAsIntent(step); + diffColumn.setIntent(synthetic); + synthetic.anchor().ifPresent(anchor -> diffColumn.revealHunk(anchor.file(), anchor.hunkIndex())); + } + + /** {@code [} / {@code ]} in PATH mode: moves the row the rail is showing. */ + private void movePathStep(int delta) { + List steps = currentPath().steps(); + if (steps.isEmpty()) { + return; + } + pathIndex = (int) Math.clamp((long) pathIndex + delta, 0, steps.size() - 1); + refreshReviewState(); + revealCurrentPathStep(); + } + + /** + * {@code n} in PATH mode: the next row whose hunk has no verdict yet -- + * "next unsettled" stated over hunks, which is what it has always meant + * (spec's own correction on this task: a property of hunks, not of + * whichever grouping the rail happens to be showing). + */ + private void nextUnsettledPathStep() { + Optional scope = selectedScope(); + Optional diff = loadedDiff(); + List steps = currentPath().steps(); + if (scope.isEmpty() || diff.isEmpty() || steps.isEmpty()) { + return; + } + for (int offset = 1; offset <= steps.size(); offset++) { + int candidate = (pathIndex + offset) % steps.size(); + Optional digest = digestOfPathStep(diff.get(), steps.get(candidate)); + if (digest.isPresent() && host.verdict(scope.get(), digest.get()).isEmpty()) { + pathIndex = candidate; + refreshReviewState(); + revealCurrentPathStep(); + return; + } + } + } + + /** + * Drops what {@link #reanchorPathCursor} re-anchors against, wherever the + * cursor is being deliberately put back to the top. + * + *

Without this, the re-anchor fought the reset. {@link + * #refreshReviewState} writes {@link #lastPathSteps} only inside its + * {@code pathMode} branch, so a path that changed while the reader was + * OUT of PATH mode -- any re-diff does it, since {@link #requestGraph} is + * kicked from diff resolution regardless of mode, and so does a fan-in + * scan landing -- left that memory stale. Pressing {@code p} then set + * {@code pathIndex = 0} and the very next refresh moved it straight back + * to wherever the remembered hunk had gone, so the cursor sat on row 2 + * while row 1 was labelled START HERE. + * + *

This is the same defect the re-anchor exists to prevent, + * one gesture over -- a cursor whose position stops matching + * what the rail says. Which is the point: re-anchoring is right when the + * ground moves UNDER a reader who is standing still, and wrong when the + * reader has just asked to start again. The two cases are told apart by + * who moved, not by what changed, so every deliberate reset says so + * here rather than each one being remembered separately.

+ */ + private void forgetPathCursorHistory() { + lastPathSteps = List.of(); + lastPathScopeId = null; + } + + /** + * Where the reader's hunk sits in a path that has just been recomputed. + * + *

Called only when the step list actually CHANGED (see the caller), + * so a plain {@code [}/{@code ]} move -- which writes {@link #pathIndex} + * and then refreshes against an unchanged list -- is never dragged back + * to where it came from.

+ * + *

Identity is the hunk id, never the position. A hunk that is no + * longer in the path at all (a newly-arrived diff dropped it) leaves the + * index alone for the caller's clamp to own: there is nowhere honest to + * put a cursor whose hunk has gone.

+ */ + private int reanchorPathCursor(List steps) { + if (lastPathSteps.isEmpty() || steps.isEmpty()) { + return pathIndex; + } + String hunkId = lastPathSteps.get(Math.clamp(pathIndex, 0, lastPathSteps.size() - 1)).hunkId(); + for (int index = 0; index < steps.size(); index++) { + if (steps.get(index).hunkId().equals(hunkId)) { + return index; + } + } + return pathIndex; + } + + /** {@code step}'s hunk id, as the single-hunk {@link ReviewIntent} the diff column filters on. */ + private static ReviewIntent pathStepAsIntent(ReadingPath.Step step) { + return new ReviewIntent("path:" + step.hunkId(), step.sectionNumber(), step.file(), + ReviewIntent.Kind.CHANGE, ReviewIntent.Risk.NONE, step.reason(), + // No reads: a path step is drydock's own single-hunk view of a + // section it already ordered, not an intent an agent declared. + List.of(step.hunkId()), Optional.empty(), false, List.of()); + } + + /** The content digest of {@code step}'s one hunk in {@code diff}, if it still resolves. */ + private static Optional digestOfPathStep(UnifiedDiff diff, ReadingPath.Step step) { + List digests = IntentHunks.digestsOf(pathStepAsIntent(step), diff); + return digests.isEmpty() ? Optional.empty() : Optional.of(digests.get(0)); + } + + /** + * "Ask the agent" from the fan-in popover: posts the question as a real + * review comment on {@code step}'s file and hands it to the scope's bound + * session, through the two seams that already exist for exactly those + * two things ({@link Host#addComment}, {@link Host#askAgentToFix}). + * + *

Not a new key. {@code a} is the approve gesture on this board, and + * a popover that stole it would be Task 18's "acted on something the + * reader could not see" defect again; this is a button in the popover + * and nothing else.

+ * + *

The question names the symbols and the file they are declared in -- + * the fan-in list is lexical and cannot say whether a caller breaks, so + * what this surface can honestly do is point the party that can answer + * at the right file rather than leaving the reader to retype it.

+ */ + private boolean askAboutFanIn(ReadingPath.Step step) { + Optional scope = selectedScope(); + Map> bySymbol = fanInOccurrences(step.file()); + Optional lineKey = lineKeyOfPathStep(step); + if (scope.isEmpty() || bySymbol.isEmpty() || lineKey.isEmpty()) { + return false; + } + int total = bySymbol.values().stream().mapToInt(List::size).sum(); + String question = "This change alters " + String.join(", ", bySymbol.keySet()) + + " in " + step.file() + ", and " + total + + (total == 1 ? " place" : " places") + " outside the change reference " + + (bySymbol.size() == 1 ? "it" : "them") + + ". Do any of those callers break, and which ones should I read?"; + ReviewAnnotation asked = ReviewAnnotation.human(scope.get().id(), step.file(), + lineKey.get(), lineKey.get(), + new ReviewAnnotation.Message("You", Instant.now(), question)); + // Stamped with the intent that owns the file, exactly as the gutter + // composer's comments are -- a comment outside the grouping is one + // the margin has to fall back to matching by file. + Optional intentId = intents().stream() + .filter(intent -> intent.touches(step.file())) + .findFirst() + .map(ReviewIntent::id); + ReviewAnnotation stamped = asked.withIntentId(intentId); + host.addComment(scope.get(), stamped); + boolean handedOff = host.askAgentToFix(scope.get(), pathStepAsIntent(step), List.of(stamped)); + refreshReviewState(); + diffColumn.refreshPins(); + // Returned, not swallowed: with no bound session the comment is + // filed and NOTHING is sent, and a popover that closed on that would + // leave the reviewer waiting for an answer nobody was asked for. + return handedOff; + } + + /** + * The line key {@link #askAboutFanIn}'s comment is anchored to: the first + * line of {@code step}'s own hunk. Walked with the same {@link + * ReviewIntent#containsHunk} test {@link IntentHunks} uses, so the + * comment lands on the hunk the row is about rather than on the file's + * first one. + */ + private Optional lineKeyOfPathStep(ReadingPath.Step step) { + ReviewIntent synthetic = pathStepAsIntent(step); + return loadedDiff().flatMap(diff -> { + for (UnifiedDiff.FileDiff file : diff.files()) { + if (!file.path().equals(step.file())) { + continue; + } + for (int index = 0; index < file.hunks().size(); index++) { + UnifiedDiff.Hunk hunk = file.hunks().get(index); + if (synthetic.containsHunk(file.path(), index) && !hunk.lines().isEmpty()) { + return Optional.of(hunk.lines().get(0).lineKey()); + } + } + } + return Optional.empty(); + }); + } + + /** The row PATH mode is currently showing, if any -- empty exactly when {@link #currentPath()} has no steps. */ + private Optional currentPathStep() { + List steps = currentPath().steps(); + if (steps.isEmpty()) { + return Optional.empty(); + } + return Optional.of(steps.get(Math.clamp(pathIndex, 0, steps.size() - 1))); + } + + /** + * The REAL intents (sections overlap, so possibly several) that + * actually cover {@code step}'s one hunk -- what {@link + * #blockingFindingOpenForPathStep} and {@link #openFindingsForPathStep} + * both resolve a step through, since neither a blocking finding nor an + * agent hand-off can be asked about a throwaway synthetic id ({@link + * #pathStepAsIntent}) a finding could never actually name. + */ + private List intentsCoveringPathStep(ReadingPath.Step step) { + Optional anchor = pathStepAsIntent(step).anchor(); + if (anchor.isEmpty()) { + return List.of(); + } + return intents().stream() + .filter(intent -> intent.containsHunk(anchor.get().file(), anchor.get().hunkIndex())) + .toList(); + } + + /** + * The still-open findings {@code step} hands to the agent (spec's own + * "ask the agent to fix" gesture) -- resolved through {@code step}'s + * REAL covering intents so a finding naming one of them is included the + * same way {@link #belongsToCurrentIntent} would from that intent's own + * card, with an unnamed/unresolvable finding falling back to the file + * when no real intent claims this hunk at all. + */ + private List openFindingsForPathStep(ReviewScope scope, ReadingPath.Step step) { + List covering = intentsCoveringPathStep(step); + return host.findings(scope).stream() + .filter(finding -> !finding.resolved()) + .filter(finding -> covering.isEmpty() + ? finding.file().equals(step.file()) + : covering.stream().anyMatch(intent -> belongsToIntent(finding, intent))) + .toList(); + } + + /** + * Test-only: the row {@code [} / {@code ]} / {@code n} last selected in + * PATH mode. Routed through {@link ReviewDiagFxThread} like every other + * {@code diag*}-shaped accessor: {@link #pathIndex} is written only on + * the FX thread, by the same keypress handling a test drives via a + * TestFX robot. + */ + int selectedPathStepForTest() { + return ReviewDiagFxThread.call(() -> pathIndex); + } + + /** + * Test-only: PATH mode's rendered row texts, in rendered order. Routed + * through {@link ReviewDiagFxThread} for the same reason every other + * {@code diag*} accessor is: it reads the rail's {@code ObservableList} + * of rows, which the FX thread rebuilds wholesale on every render. + */ + /** + * Diagnostic-only: enters PATH mode if it is not already showing, then + * opens the first fan-in popover. The visual pass over that popover has + * no other way in -- it is a separate {@code Popup} window, and Robot + * input never reaches a diag run. + */ + public String diagOpenFanIn() { + return ReviewDiagFxThread.call(() -> { + if (!pathMode) { + togglePathMode(); + } + return intentRail.diagOpenFanIn(); + }); + } + + /** See {@link #fanInScanThread} -- the thread the last fan-in scan ran on. */ + String diagFanInScanThread() { + return fanInScanThread; + } + + List pathRowTextsForTest() { + return ReviewDiagFxThread.call(intentRail::diagPathRowTexts); + } + /** * The {@code ◆n} pins beside the code and their two-way linkage to the * margin (spec §4.4). A pin whose finding is filtered out dims rather @@ -909,34 +2385,106 @@ public void setPostToPr(ReviewAnnotation finding, boolean post) { /** The verdict bar's window onto the host, with the scope filled in. */ private final class VerdictHost implements ReviewVerdictBar.Host { @Override - public void approve(ReviewIntent intent) { - selectedScope().ifPresent(scope -> - host.setVerdict(scope, intent, Optional.of(ReviewVerdict.Decision.APPROVED))); + public void approve(ReviewIntent intent, SettleUnit unit) { + // The verdict bar's own Approve button, not just the keyboard: + // AGENTS.md requires a shortcut to have a working button + // equivalent, and vice versa, so a click here must settle + // exactly what `a` does -- the selected PATH row, never + // whatever `intent`/`unit` the bar's own (intents-cursor-driven) + // render happened to capture. + if (pathMode) { + pathVerdictAction(ReviewVerdict.Decision.APPROVED, false); + return; + } + selectedScope().ifPresent(scope -> host.setVerdict(scope, intent, + digestsForAction(intent, unit, false), Optional.of(ReviewVerdict.Decision.APPROVED), + blockingFindingOpen(scope, intent))); } @Override - public void requestChanges(ReviewIntent intent) { - selectedScope().ifPresent(scope -> - host.setVerdict(scope, intent, Optional.of(ReviewVerdict.Decision.CHANGES))); + public void requestChanges(ReviewIntent intent, SettleUnit unit) { + if (pathMode) { + pathVerdictAction(ReviewVerdict.Decision.CHANGES, false); + return; + } + selectedScope().ifPresent(scope -> host.setVerdict(scope, intent, + digestsForAction(intent, unit, false), Optional.of(ReviewVerdict.Decision.CHANGES), + blockingFindingOpen(scope, intent))); } @Override - public void askAgentToFix(ReviewIntent intent) { - selectedScope().ifPresent(scope -> host.askAgentToFix(scope, intent, - host.findings(scope).stream() - .filter(finding -> !finding.resolved()) - .filter(SessionReviewView.this::belongsToCurrentIntent) - .toList())); + public boolean askAgentToFix(ReviewIntent intent) { + // Routed through the SELECTED ROW in PATH mode, not the intent + // the bar happened to be handed (see the class-level javadoc on + // renderVerdictBarForPathStep for why that intent no longer + // reflects what is on screen). + // + // The answer is RETURNED, not swallowed: with no session bound + // (or nothing open to send) this hands over nothing at all, and + // a button that then looks exactly as though it worked is the + // silent failure ruling 1 legislated against -- already fixed + // once on the fan-in popover, and this is the same defect one + // surface over. + if (pathMode) { + return currentPathStep().flatMap(step -> selectedScope().map(scope -> + host.askAgentToFix(scope, pathStepAsIntent(step), + openFindingsForPathStep(scope, step)))) + .orElse(false); + } + return selectedScope().map(scope -> host.askAgentToFix(scope, intent, + host.findings(scope).stream() + .filter(finding -> !finding.resolved()) + .filter(SessionReviewView.this::belongsToCurrentIntent) + .toList())) + .orElse(false); } @Override public void undo(ReviewIntent intent) { - selectedScope().ifPresent(scope -> host.setVerdict(scope, intent, Optional.empty())); + // Re-review, too (spec §9.2): a stale section's banner button and + // the plain undo button both just clear what is recorded. An + // undo is never refused, so the flag here is inert -- passed + // for the sole reason that host.setVerdict has one parameter, + // not two overloads to keep in sync. + // + // PATH mode clears exactly the SELECTED ROW's one hunk, never + // digestsOf(intent) over the (invisible) intents cursor's whole + // section -- a screenshot proved that click cleared two hunks + // nowhere near the row on screen and left the visible one alone. + if (pathMode) { + currentPathStep().ifPresent(step -> selectedScope().ifPresent(scope -> + loadedDiff().flatMap(diff -> digestOfPathStep(diff, step)).ifPresent(digest -> + host.setVerdict(scope, pathStepAsIntent(step), List.of(digest), + Optional.empty(), false)))); + return; + } + selectedScope().ifPresent(scope -> + host.setVerdict(scope, intent, digestsOf(intent), Optional.empty(), false)); + } + + @Override + public void confirmStillGood(ReviewIntent intent) { + if (pathMode) { + currentPathStep().ifPresent(step -> selectedScope().ifPresent(scope -> + loadedDiff().flatMap(diff -> digestOfPathStep(diff, step)).ifPresent(digest -> { + host.confirmStillGood(scope, List.of(digest)); + refreshReviewState(); + }))); + return; + } + selectedScope().ifPresent(scope -> { + host.confirmStillGood(scope, digestsOf(intent)); + refreshReviewState(); + }); } @Override public void nextUnsettled() { - nextUnsettledIntent(); + // Not nextUnsettledIntent() directly: this overrides an + // interface method of the SAME name, so an unqualified call + // here would recurse into itself rather than reaching the + // outer class's dispatcher. + SessionReviewView.this.nextUnsettled(); } @Override @@ -946,15 +2494,53 @@ public void submit() { @Override public void previousIntent() { - moveIntent(-1); + moveSelection(-1); } @Override public void nextIntent() { - moveIntent(1); + moveSelection(1); } } + /** + * Why a Submit click did nothing, as a value rather than four literals + * scattered through {@link #submitReview}. + * + *

Split in two because the footer at the code column's floor has room + * for roughly forty characters: {@code reason} is what has to FIT there, + * {@code detail} is what the ellipsis would otherwise have taken and now + * lives on hover. Three of the four were over that budget, and the one + * test that measured it drove only the fourth -- which is how the other + * three shipped elided. {@link #SUBMIT_REFUSALS} exists so a test can + * loop the real strings instead of holding its own copies -- and the + * loop that matters runs in the REAL view, since the bar is 35px + * narrower there than the window it sits in.

+ */ + record SubmitRefusal(String reason, String detail) { + } + + static final SubmitRefusal DIFF_FAILED = new SubmitRefusal( + "the diff failed to load", + "This scope's diff could not be read, so there is nothing to post comments against."); + + static final SubmitRefusal DIFF_LOADING = new SubmitRefusal( + "the diff is still loading", + "Try again in a moment: this scope's diff has not landed yet."); + + static final SubmitRefusal NEEDS_VERDICT = new SubmitRefusal( + "a verdict is missing; jumped to it", + "Approve it, or request changes on it, before submitting the review."); + + static final SubmitRefusal STALE_BASE = new SubmitRefusal( + "some approvals are stale", + "Some approvals were given against a base that has since moved. Confirm they still " + + "hold, or re-review them, before submitting."); + + /** Every refusal {@link #submitReview} can raise -- see {@link SubmitRefusal}. */ + static final List SUBMIT_REFUSALS = + List.of(DIFF_FAILED, DIFF_LOADING, NEEDS_VERDICT, STALE_BASE); + /** * Submit (spec §4.6): with anything unsettled this jumps to the first * such intent rather than posting a partial review; once everything is @@ -993,27 +2579,34 @@ private void submitReview() { if (!diffColumn.displayedScopeId().map(id -> id.equals(scope.get().id())).orElse(false)) { boolean failed = selectedOutcome().orElse(null) instanceof DiffOutcome.Failed; if (!(failed && scope.get().pr().isEmpty())) { - verdictBar.showSubmitRefused(failed - ? "the diff failed to load; nothing to submit" - : "the diff is still loading; try again in a moment"); + SubmitRefusal refusal = failed ? DIFF_FAILED : DIFF_LOADING; + verdictBar.showSubmitRefused(refusal.reason(), refusal.detail()); return; } } - List counted = intents().stream() - .filter(ReviewIntent::countsTowardProgress) - .toList(); + List counted = countedSections(); List decisions = new ArrayList<>(); for (int i = 0; i < counted.size(); i++) { - Optional verdict = host.verdict(scope.get(), counted.get(i)); - if (verdict.isEmpty()) { + Optional decision = decisionOf(counted.get(i)); + if (decision.isEmpty()) { + intentIndex = intents().indexOf(counted.get(i)); + refreshReviewState(); + revealCurrentIntent(); + verdictBar.showSubmitRefused(NEEDS_VERDICT.reason(), NEEDS_VERDICT.detail()); + return; + } + // A stale verdict does not count toward "everything settled" + // (spec §9.2): it was given against a base that has since moved, + // so posting it is a decision the reader has not actually made + // about the code as it stands now. + if (sectionState(counted.get(i)).staleness() == SectionStates.Staleness.MOVED) { intentIndex = intents().indexOf(counted.get(i)); refreshReviewState(); revealCurrentIntent(); - verdictBar.showSubmitRefused( - "an intent still needs a verdict (approve or request changes); jumped to it"); + verdictBar.showSubmitRefused(STALE_BASE.reason(), STALE_BASE.detail()); return; } - decisions.add(verdict.get().decision()); + decisions.add(decision.get()); } host.submit(scope.get(), buildDiffIndex(diffColumn.displayedDiff()), decisions); } @@ -1159,31 +2752,153 @@ private void setFocusMode(boolean on) { } /** - * {@code a} / {@code r}: records a verdict and, once it actually took - * (the host still refuses APPROVED over a blocking finding -- see - * {@code MainWorkspace}'s {@code Host#setVerdict} -- so recording is not - * guaranteed), advances to the next unsettled intent via the same walk - * {@code n} uses. Also remembers this intent as the one {@code u} should - * undo (see {@link #undoVerdict}) -- recorded here, after the advance - * decision above, so it always names the intent a verdict was just - * placed ON, never wherever the cursor lands next. + * {@code a} / {@code r}: records a verdict over {@link #settleUnit()}'s + * digests -- one hunk, one file, or the whole section -- and, once it + * actually took (the host still refuses APPROVED over a blocking + * finding -- see {@code MainWorkspace}'s {@code Host#setVerdict} -- so + * recording is not guaranteed), remembers those exact digests as what + * {@code u} should undo (see {@link #undoVerdict}). Whether the WHOLE + * section is now settled is asked separately -- a single hunk of a + * multi-hunk section applying must still let {@code u} undo it, even + * though the section itself has not merged to a decision yet -- and only + * that separate question decides whether to advance to the next + * unsettled intent, via the same walk {@code n} uses. + * + * @param wholeFile {@code ⇧A}/{@code ⇧R}: every hunk of the current file, + * regardless of what has focus */ - private void verdictAction(ReviewVerdict.Decision decision) { + private void verdictAction(ReviewVerdict.Decision decision, boolean wholeFile) { + if (pathMode) { + // PATH mode must settle what it shows, never whatever the + // intents-mode cursor happens to be sitting on -- the CRITICAL + // fix this branch carries. digestsForAction/SectionStates are + // deliberately not reached here: they derive digests from an + // INTENT, and the whole point is that a PATH row is not one. + pathVerdictAction(decision, wholeFile); + return; + } Optional scope = selectedScope(); Optional intent = currentIntent(); - if (scope.isPresent() && intent.isPresent()) { - host.setVerdict(scope.get(), intent.get(), Optional.of(decision)); - if (host.verdict(scope.get(), intent.get()).map(ReviewVerdict::decision) - .filter(decision::equals).isPresent()) { - lastSettledIntentId = Optional.of(intent.get().id()); - nextUnsettledIntent(); + if (scope.isEmpty() || intent.isEmpty()) { + return; + } + List digests = digestsForAction(intent.get(), settleUnit(), wholeFile); + if (digests.isEmpty()) { + return; + } + host.setVerdict(scope.get(), intent.get(), digests, Optional.of(decision), + blockingFindingOpen(scope.get(), intent.get())); + boolean applied = digests.stream().allMatch(digest -> host.verdict(scope.get(), digest) + .filter(v -> v.decision() == decision).isPresent()); + if (!applied) { + return; + } + lastSettledWasPath = false; + lastSettledIntentId = Optional.of(intent.get().id()); + lastSettledDigests = digests; + if (decisionOf(intent.get()).filter(decision::equals).isPresent()) { + nextUnsettledIntent(); + } + } + + /** + * PATH mode's {@code a}/{@code r} (and the verdict bar's own buttons, + * routed here the same way -- see {@link VerdictHost}): settles exactly + * the selected row's one hunk, or every hunk of its file for {@code + * wholeFile} ({@code ⇧A}/{@code ⇧R}). {@code host.setVerdict} takes an + * intent purely as a label/blocking-check key (verdicts themselves are + * keyed {@code (scopeId, hunkDigest)}, never by intent), so a throwaway + * single-hunk {@link ReviewIntent} is exactly as valid a key as a real + * one -- see {@link #pathStepAsIntent}. + */ + private void pathVerdictAction(ReviewVerdict.Decision decision, boolean wholeFile) { + Optional scope = selectedScope(); + Optional diff = loadedDiff(); + List steps = currentPath().steps(); + if (scope.isEmpty() || diff.isEmpty() || steps.isEmpty()) { + return; + } + ReadingPath.Step step = steps.get(Math.clamp(pathIndex, 0, steps.size() - 1)); + ReviewIntent synthetic = pathStepAsIntent(step); + List digests = wholeFile + ? digestsOfFileInDiff(diff.get(), step.file()) + : digestOfPathStep(diff.get(), step).map(List::of).orElse(List.of()); + if (digests.isEmpty()) { + return; + } + host.setVerdict(scope.get(), synthetic, digests, Optional.of(decision), + blockingFindingOpenForPathStep(scope.get(), step, wholeFile)); + boolean applied = digests.stream().allMatch(digest -> host.verdict(scope.get(), digest) + .filter(v -> v.decision() == decision).isPresent()); + if (!applied) { + return; + } + lastSettledWasPath = true; + lastSettledPathHunkId = Optional.of(step.hunkId()); + lastSettledDigests = digests; + // Every digest just written now reads as `decision` (that is what + // `applied` just confirmed), so this row is as settled as it is + // ever going to be from this one keypress -- advance the same way + // verdictAction's intents-mode branch does. + nextUnsettledPathStep(); + } + + /** + * Whether a still-open finding blocks approving PATH mode's current + * settle target -- {@code step}'s own hunk, or, for {@code wholeFile}, + * every hunk of its file (spec §4.6). + * + *

PATH mode has no real intent of its own to hand {@link + * #blockingFindingOpen}: {@link #pathStepAsIntent}'s synthetic {@code + * "path:" + hunkId} can never equal a finding's named {@code intentId}, + * so asking about it directly answered "not blocked" for every + * agent-attributed finding -- the common case, and the whole reason + * {@code review_finding} carries an id at all. This asks the SAME + * question {@link #belongsToIntent} already answers for INTENTS mode, + * but resolved through whichever REAL section(s) actually cover the + * hunk(s) about to be settled, so a finding naming one of them still + * refuses exactly as it would from that section's own card.

+ */ + private boolean blockingFindingOpenForPathStep(ReviewScope scope, ReadingPath.Step step, + boolean wholeFile) { + Optional anchor = pathStepAsIntent(step).anchor(); + if (anchor.isEmpty()) { + return false; + } + String file = anchor.get().file(); + List covering = wholeFile + ? intents().stream().filter(intent -> intent.touches(file)).toList() + : intentsCoveringPathStep(step); + if (!covering.isEmpty()) { + return covering.stream().anyMatch(intent -> blockingFindingOpen(scope, intent)); + } + // No real intent claims this hunk/file at all (a grouping that has + // drifted, or an empty rail) -- fall back to whether any finding on + // the file blocks, the same fallback belongsToIntent itself uses for + // a finding naming nothing resolvable. + return host.findings(scope).stream() + .filter(finding -> finding.file().equals(file)) + .anyMatch(ReviewAnnotation::blocksApproval); + } + + /** Every hunk digest of {@code file}, across the whole {@code diff} -- what {@code ⇧A}/{@code ⇧R} settle in PATH mode. */ + private static List digestsOfFileInDiff(UnifiedDiff diff, String file) { + for (UnifiedDiff.FileDiff candidate : diff.files()) { + if (candidate.path().equals(file)) { + List digests = new ArrayList<>(); + for (UnifiedDiff.Hunk hunk : candidate.hunks()) { + digests.add(HunkDigest.of(file, hunk)); + } + return digests; } } + return List.of(); } /** - * {@code u}: undoes the verdict {@code a}/{@code r} last recorded -- - * NOT whatever intent the cursor currently sits on. A human who presses + * {@code u}: undoes exactly the digests {@code a}/{@code r} last + * recorded -- NOT the whole intent the cursor currently sits on, and NOT + * whatever {@link #settleUnit()} says right now. A human who presses * {@code r}, realises they misread the diff, and presses {@code u} * expects the verdict they just placed to disappear; since {@code r} * itself advances the cursor (see {@link #verdictAction}), undoing @@ -1195,8 +2910,12 @@ private void verdictAction(ReviewVerdict.Decision decision) { * than reaching for an unrelated intent's verdict. */ private void undoVerdict() { + if (lastSettledWasPath) { + undoPathVerdict(); + return; + } Optional scope = selectedScope(); - if (scope.isEmpty() || lastSettledIntentId.isEmpty()) { + if (scope.isEmpty() || lastSettledIntentId.isEmpty() || lastSettledDigests.isEmpty()) { return; } List current = intents(); @@ -1207,19 +2926,58 @@ private void undoVerdict() { break; } } + List digests = lastSettledDigests; lastSettledIntentId = Optional.empty(); + lastSettledDigests = List.of(); if (index < 0) { // The grouping changed under us (a reviewer re-ran, say) and the // intent this would have undone no longer exists -- nothing // sane to undo or jump to. return; } - host.setVerdict(scope.get(), current.get(index), Optional.empty()); + // An undo is never refused (see the VerdictHost#undo javadoc); false + // is inert here, not a claim that nothing is blocking. + host.setVerdict(scope.get(), current.get(index), digests, Optional.empty(), false); intentIndex = index; refreshReviewState(); revealCurrentIntent(); } + /** + * PATH mode's {@code u}: the counterpart to {@link #undoVerdict}'s + * intents-mode body, keyed by {@link #lastSettledPathHunkId} rather than + * an intent id -- a step has no id of its own, only its (stable) hunk + * id. + */ + private void undoPathVerdict() { + Optional scope = selectedScope(); + if (scope.isEmpty() || lastSettledPathHunkId.isEmpty() || lastSettledDigests.isEmpty()) { + return; + } + List steps = currentPath().steps(); + int index = -1; + for (int i = 0; i < steps.size(); i++) { + if (steps.get(i).hunkId().equals(lastSettledPathHunkId.get())) { + index = i; + break; + } + } + List digests = lastSettledDigests; + ReadingPath.Step target = index >= 0 ? steps.get(index) : null; + lastSettledPathHunkId = Optional.empty(); + lastSettledDigests = List.of(); + lastSettledWasPath = false; + if (index < 0) { + // The path changed under us (a re-diff landed a new graph) and + // the step this would have undone no longer exists. + return; + } + host.setVerdict(scope.get(), pathStepAsIntent(target), digests, Optional.empty(), false); + pathIndex = index; + refreshReviewState(); + revealCurrentPathStep(); + } + /** * Asks the selected scope's agent for a review. Refuses the same case * {@code host.runReview} refuses -- a scope with no session has no agent @@ -1319,11 +3077,23 @@ public boolean handleShortcut(KeyEvent event) { case M -> { setMarginCollapsed(!margin.collapsed()); yield true; } case I -> { setIntentsCollapsed(!intentRail.collapsed()); yield true; } case BACK_SLASH -> { toggleMcpPanel(); yield true; } - case OPEN_BRACKET -> { moveIntent(-1); yield true; } - case CLOSE_BRACKET -> { moveIntent(1); yield true; } - case N -> { nextUnsettledIntent(); yield true; } - case A -> { verdictAction(ReviewVerdict.Decision.APPROVED); yield true; } - case R -> { verdictAction(ReviewVerdict.Decision.CHANGES); yield true; } + case P -> { togglePathMode(); yield true; } + // [ and ] step whatever the rail is currently listing (spec + // §7.1): sections in INTENTS mode, hunks in PATH mode -- one key + // rather than a parallel set for the second mode. + case OPEN_BRACKET -> { moveSelection(-1); yield true; } + case CLOSE_BRACKET -> { moveSelection(1); yield true; } + // n keeps meaning "next unsettled", a property of hunks + // regardless of which grouping the rail is showing. + case N -> { nextUnsettled(); yield true; } + case A -> { + verdictAction(ReviewVerdict.Decision.APPROVED, event.isShiftDown()); + yield true; + } + case R -> { + verdictAction(ReviewVerdict.Decision.CHANGES, event.isShiftDown()); + yield true; + } case U -> { undoVerdict(); yield true; } case ENTER -> { submitReview(); yield true; } // Shift+F is the whole-review filter; plain f is focus mode. @@ -1392,12 +3162,26 @@ public void onShown() { * means a view closed mid-animation never runs a timeline against a * detached node.

* + *

{@link #focusOwnerListener} is the same shape of leak as the MCP + * panel: it is added to the app-lifetime Scene's {@code + * focusOwnerProperty}, so an un-removed one keeps this view reachable + * for the process's life AND re-renders its verdict bar on every focus + * change anywhere in the app, for every session's board ever closed. + * The {@link #sceneProperty()} listener already removes it on a genuine + * re-parent, but this Scene is never actually swapped in practice (one + * Scene for the whole app -- see {@code AppShell}), so this explicit + * removal is the one that actually runs.

+ * *

Call before dropping the last reference to this view -- see {@code * OpenSessionTab.disposeNativeResources}.

*/ public void close() { + closed = true; mcpPanel.ifPresent(ReviewMcpActivityPanel::detach); intentRail.stopWidthAnimation(); + if (getScene() != null) { + getScene().focusOwnerProperty().removeListener(focusOwnerListener); + } } // ---- diagnostics -------------------------------------------------------- @@ -1423,6 +3207,36 @@ Optional diagSelectedChipText() { * selection path a click takes -- including the guard that makes pressing * the already-selected chip do nothing. */ + /** + * Diagnostic-only: delivers one key through the SAME filter real presses + * take ({@link #onKeyPressed}), so a driver can settle a hunk without a + * pointer. {@code app.drydock.diag.explorerScript}'s {@code reviewkey} + * verb has documented this since Task 18 and never had an implementation + * -- the verb fell through to the script's default branch, which prints + * "mark", so a run that approved nothing looked exactly like one that + * worked. + */ + /** + * Diagnostic-only: opens the gutter comment composer on the first changed + * line. {@link ReviewDiffColumn#diagOpenComposer} has existed, complete + * and FX-thread-safe, with no caller at all -- the {@code comment} verb it + * was written for was documented in {@code DrydockApplication} and never + * wired, so a script asking for it hit the script's default branch and + * printed "mark". This is the missing hop. + * + *

It exists because the composer is opened by a click on a 34px label + * inside a virtualized cell, which the harness cannot aim at -- so without + * this there is no way to drive, or photograph, a gutter comment.

+ */ + public String diagOpenComposer() { + return diffColumn.diagOpenComposer(); + } + + public void diagReviewKey(KeyCode code) { + fireEvent(new KeyEvent(KeyEvent.KEY_PRESSED, "", "", code, + false, false, false, false)); + } + void diagSelectChoice(SessionReviewScopes.Choice choice) { ReviewDiagFxThread.call(() -> { switcher.diagSelectChoice(choice); @@ -1452,6 +3266,121 @@ void diagShowDiff(ReviewScope forScope, UnifiedDiff diff) { diffColumn.showDiff(forScope, diff); } + /** + * Diagnostic-only: the derived state of the {@code index}-th section. + * Routed through {@link ReviewDiagFxThread} like every other {@code diag*} + * accessor -- it reads the store and the rail's own grouping, both of + * which the FX thread mutates. + */ + SectionStates.SectionState diagSectionState(int index) { + return ReviewDiagFxThread.call(() -> { + List current = intents(); + return index >= 0 && index < current.size() + ? sectionState(current.get(index)) + : SectionStates.SectionState.unknown(); + }); + } + + /** + * Diagnostic-only: whether {@code scopeId} has a {@link ChangeGraph} + * build in flight on {@link #SECTION_GRAPH_EXECUTOR}. A fixture that + * shows a diff and moves straight into clicking the view races that + * background build's completion -- {@link #refreshReviewState()} runs + * from its {@code Platform.runLater} callback regardless of success or + * failure, rebuilds the rail's cards, and can hand focus somewhere the + * click never put it (see {@code diagFocusSnapshot}'s javadoc). Letting + * a fixture wait on this before a test method starts closes that race + * instead of leaving every test built on it to hit it by chance. + */ + boolean diagGraphBuildPending(String scopeId) { + return ReviewDiagFxThread.call(() -> graphBuilding.contains(scopeId)); + } + + /** + * Diagnostic-only: whether the Scene's real focus owner is inside {@link + * #diffColumn} right now -- what {@link #settleUnit()} bases {@code + * HUNK} on. A fixture's click-driven {@code clickOn} is a real TestFX + * robot press: Monocle turns it into an FX {@code MouseEvent} on its own + * schedule, off the calling thread, so a single {@code + * waitForFxEvents()} after the click can return before that event has + * even been posted -- it only waits for whatever was ALREADY queued. + * Polling this (see {@code ReviewViewFixture#focusDiffColumn}) waits for + * the actual postcondition instead of guessing how many drains cover the + * gap. + */ + boolean diagFocusInDiffColumn() { + return ReviewDiagFxThread.call( + () -> isDescendantOf(getScene() == null ? null : getScene().getFocusOwner(), diffColumn)); + } + + /** + * Diagnostic-only: what {@link #settleUnit()} would read right now, and + * the Scene focus state it derives that from -- for a test to log when a + * settle action lands on the wrong unit. Exists because {@code + * withTheDiffColumnFocusedApproveSettlesOneHunk} settles the whole + * section (as if {@link #settleUnit()} read {@code SECTION}) on CI + * runners but not in any local run, isolated or full-suite; this pins + * down whether the Scene's focus owner ever actually lands inside {@link + * #diffColumn} on a run where it happens, instead of guessing from the + * assertion failure alone. + */ + String diagFocusSnapshot() { + return ReviewDiagFxThread.call(() -> { + Node owner = getScene() == null ? null : getScene().getFocusOwner(); + return "settleUnit=" + settleUnit() + + " focusOwner=" + diagDescribe(owner) + + " inDiffColumn=" + isDescendantOf(owner, diffColumn) + + " chain=" + diagAncestorChain(owner); + }); + } + + /** + * Diagnostic-only: one node described as {@code + * SimpleClassName[id][.styleClass...]("text if Labeled")} -- {@code + * getClass().getSimpleName()} alone (what {@code diagFocusSnapshot} used + * to report) says only "a Button", not which one. + */ + private static String diagDescribe(Node node) { + if (node == null) { + return "none"; + } + StringBuilder sb = new StringBuilder(node.getClass().getSimpleName()); + if (node.getId() != null) { + sb.append('#').append(node.getId()); + } + node.getStyleClass().forEach(c -> sb.append('.').append(c)); + if (node instanceof javafx.scene.control.Labeled labeled) { + sb.append("(\"").append(labeled.getText()).append("\")"); + } + return sb.toString(); + } + + /** Diagnostic-only: {@code node}'s ancestor chain, described via {@link #diagDescribe}. */ + private static String diagAncestorChain(Node node) { + StringBuilder sb = new StringBuilder(); + for (Node n = node == null ? null : node.getParent(); n != null; n = n.getParent()) { + sb.append(" < ").append(diagDescribe(n)); + } + return sb.toString(); + } + + /** Diagnostic-only: the current rail's intent ids, in rendered order. */ + List diagIntentIds() { + return ReviewDiagFxThread.call(() -> intents().stream().map(ReviewIntent::id).toList()); + } + + /** + * Diagnostic-only: {@link #intents()}'s own return value, unmapped -- + * so a test can compare it BY REFERENCE across two calls to tell a + * cache hit (the same {@link List} instance) from a recomputation (a + * new, if equal-content, one). {@link #diagIntentIds} maps to a fresh + * {@code List} on every call regardless, so it cannot make that + * distinction. + */ + List diagIntents() { + return ReviewDiagFxThread.call(this::intents); + } + /** * Diagnostic-only: the findings margin's cards, read in the order they * are rendered, by the text their body actually shows -- the same text diff --git a/app/src/main/resources/app/drydock/ui/app.css b/app/src/main/resources/app/drydock/ui/app.css index d5563883..a210b054 100644 --- a/app/src/main/resources/app/drydock/ui/app.css +++ b/app/src/main/resources/app/drydock/ui/app.css @@ -2572,12 +2572,14 @@ -fx-border-radius: 8px 8px 0 0; } .review-diff-cell.card-body > .review-code-row, -.review-diff-cell.card-body > .review-collapsed-run { +.review-diff-cell.card-body > .review-collapsed-run, +.review-diff-cell.card-body > .review-link-row { -fx-border-color: transparent -drydock-border transparent -drydock-border; -fx-border-width: 0 1 0 1; } .review-diff-cell.card-bottom > .review-code-row, -.review-diff-cell.card-bottom > .review-collapsed-run { +.review-diff-cell.card-bottom > .review-collapsed-run, +.review-diff-cell.card-bottom > .review-link-row { -fx-border-color: transparent -drydock-border -drydock-border -drydock-border; -fx-border-width: 0 1 1 1; -fx-background-radius: 0 0 8px 8px; @@ -2747,6 +2749,31 @@ -fx-border-width: 0 0 0 2; } +/* A hunk's link footer (Task 19, spec §7.2): what it has to do with a hunk + * elsewhere in the diff. Built the same way .review-collapsed-run is -- + * Button.setText with its OWN -fx-text-fill here -- rather than the + * .review-path-row fix (child Labels), because a footer is one line with no + * internal parts that need different emphasis. Left unfixed it would be + * Task 18's defect again: modena's light-button default text colour against + * this column's dark background, 1.13:1 measured there. */ +.review-link-row { + -fx-background-color: -drydock-code-bg; + -fx-background-radius: 0; + -fx-text-fill: -drydock-text-dim; + -fx-font-size: 10.5px; + -fx-alignment: center-left; + -fx-cursor: hand; +} +.review-link-row:hover { + -fx-text-fill: -drydock-text; + -fx-background-color: -drydock-hover; +} +.review-link-row:focused { + -fx-text-fill: -drydock-text; + -fx-border-color: -drydock-accent; + -fx-border-width: 0 0 0 2; +} + /* Density (spec 4.8) -- code font size and row height. The px literals are what UiFontScale scales, so density stays a relative choice on top of the user's absolute interface size. @@ -2764,7 +2791,8 @@ -fx-font-size: 12.5px; } .density-cozy .review-code-row, -.density-cozy .review-collapsed-run { +.density-cozy .review-collapsed-run, +.density-cozy .review-link-row { -fx-min-height: 20px; -fx-padding: 0 10 0 10; } @@ -2774,7 +2802,8 @@ -fx-font-size: 11.5px; } .density-compact .review-code-row, -.density-compact .review-collapsed-run { +.density-compact .review-collapsed-run, +.density-compact .review-link-row { -fx-min-height: 16px; -fx-padding: 0 10 0 10; } @@ -2784,7 +2813,8 @@ -fx-font-size: 11px; } .density-dense .review-code-row, -.density-dense .review-collapsed-run { +.density-dense .review-collapsed-run, +.density-dense .review-link-row { -fx-min-height: 14px; -fx-padding: 0 10 0 10; } @@ -3106,6 +3136,15 @@ -fx-border-color: -drydock-blocking; -fx-text-fill: -drydock-text-faint; } +/* The same, for a non-primary action that refused: "Ask the agent to fix it" + when there is no session to hand anything to. Its own rule rather than a + relaxed selector, so the primary's transparent-background treatment above + is not silently applied to every action button that ever gains the + pseudo-class. */ +.review-verdict-action:refused { + -fx-border-color: -drydock-blocking; + -fx-text-fill: -drydock-text-faint; +} .review-verdict-refusal { -fx-text-fill: -drydock-blocking; -fx-font-size: 11px; @@ -3159,6 +3198,14 @@ -fx-padding: 0 6 8 6; -fx-spacing: 4; } +/* The provisional-grouping banner: its own wrapped row, never sharing space + * with the header's N/M counter -- see ReviewIntentRail#pendingBanner. */ +.review-intent-pending { + -fx-text-fill: -drydock-text-faint; + -fx-font-size: 11px; + -fx-font-style: italic; + -fx-padding: 0 10 6 10; +} .review-intent-card { -fx-background-color: transparent; -fx-background-radius: 6px; @@ -3168,6 +3215,27 @@ -fx-alignment: center-left; -fx-cursor: hand; } +/* A claimed ordering is the agent's assertion, not drydock's measurement + * (spec §6.5). Only the claimed case is modified -- marking every row would + * say nothing. + * + * Dashed AND lifted off -drydock-border. Dashing alone was the first attempt + * and was not legible: -drydock-border is rgba(255,255,255,0.08), about + * 1.26:1 against this rail, so removing half of it is half the ink of a + * hairline nobody can see -- Task 18's 1.13:1 defect in a new place. The + * colour is -drydock-text-faint, which is NEUTRAL: the point of not using a + * hue is that four risk encodings already compete for colour here, and a + * neutral grey is not a fifth. */ +/* The stale chip when an AGENT asserted the move disturbed this hunk (spec + * §9.7). A Label, not a bordered card, so the card rule below cannot carry it + * -- and the word "agent:" in the text is the part that survives any theme. */ +.review-intent-stale.provenance-claimed { + -fx-font-style: italic; +} +.review-intent-card.provenance-claimed { + -fx-border-color: -drydock-text-faint; + -fx-border-style: segments(3, 3) line-cap round; +} .review-intent-card.collapsed { -fx-padding: 7 0 7 0; -fx-alignment: center; @@ -3181,6 +3249,14 @@ /* Settled intents dim; the verdict below them says why. */ .review-intent-card.settled { -fx-opacity: 0.5; } .review-intent-card.settled:selected { -fx-opacity: 1; } +/* Stale: settled, but against a base that has since moved under it. It must + * not read as done, so it keeps a full-strength border and its own accent. */ +.review-intent-card.stale { -fx-border-color: -drydock-question; -fx-opacity: 1; } +/* Adrift: the section's hunk ids name nothing in the current diff. Dimmed + * like a settled card, because there is nothing here to read either -- but + * it is not settled, so it keeps its own muted border rather than the + * verdict colours. */ +.review-intent-card.adrift { -fx-opacity: 0.55; -fx-border-color: -drydock-border; } .review-intent-number { -fx-text-fill: -drydock-text-faint; @@ -3241,6 +3317,41 @@ -fx-font-style: italic; } +/* PATH mode's rows (ReviewIntentRail#buildPathRow, Task 18 follow-up): + * mirrors .review-intent-number/.review-intent-title exactly, fill for + * fill, unselected and selected both. A row built from Button.setText alone + * has no -fx-text-fill of its own here -- .review-intent-card sets border + * and background only -- so it fell back to modena's default BUTTON text + * colour (tuned for a light button face) against this rail's dark + * background: 1.13:1 contrast on the SELECTED row, measured on a real + * screenshot, worse than the 1.70:1 the unselected rows still failed at. + * Every part of a row lives on its own Label now, so every part gets its + * own explicit fill instead of inheriting one meant for something else. */ +.review-path-badge { + -fx-text-fill: -drydock-text-faint; + -fx-font-size: 10.5px; + -fx-font-weight: 700; + -fx-font-family: "JetBrains Mono", "Menlo", monospace; +} +.review-intent-card:selected .review-path-badge { -fx-text-fill: -drydock-accent; } +.review-path-file { + -fx-text-fill: -drydock-text-dim; + -fx-font-size: 12px; + -fx-font-weight: 600; +} +.review-intent-card:selected .review-path-file { -fx-text-fill: -drydock-text; } +.review-path-reason { + -fx-text-fill: -drydock-text-faint; + -fx-font-size: 10.5px; +} +.review-intent-card:selected .review-path-reason { -fx-text-fill: -drydock-text-dim; } +.review-path-links { + -fx-text-fill: -drydock-text-faint; + -fx-font-size: 9.5px; + -fx-font-style: italic; +} +.review-intent-card:selected .review-path-links { -fx-text-fill: -drydock-text-dim; } + /* The risk heat bar: one bar, keyed to the intent's own risk. */ .review-intent-heat { -fx-min-height: 3px; @@ -3260,6 +3371,26 @@ .review-intent-settled.decision-auto-approved { -fx-text-fill: -drydock-resolved; } .review-intent-settled.decision-changes { -fx-text-fill: -drydock-question; } +/* Part-settled, settled by a section that shares the hunk, and stale: three + * states a card had no way to say before hunks became the unit of approval. */ +.review-intent-hunk-progress { + -fx-font-size: 9.5px; + -fx-text-fill: -drydock-text-faint; +} +.review-intent-settled-elsewhere { + -fx-font-size: 9.5px; + -fx-text-fill: -drydock-resolved; +} +.review-intent-stale { + -fx-font-size: 9.5px; + -fx-text-fill: -drydock-question; +} +.review-intent-adrift { + -fx-font-size: 9.5px; + -fx-font-style: italic; + -fx-text-fill: -drydock-text-faint; +} + /* Collapsed: a status dot, never a clipped label. */ .review-intent-dot { -fx-min-width: 5px; -fx-max-width: 5px; @@ -3465,6 +3596,50 @@ .review-lens-line:hover { -fx-background-color: -drydock-hover; } .review-lens-line:focused { -fx-border-color: -drydock-accent; } +/* The out-of-diff fan-in popover: the lens's frame on a third source. */ +.review-fanin-symbol { + -fx-text-fill: -drydock-code-fn; + -fx-font-size: 11px; + -fx-font-family: "JetBrains Mono", "Menlo", monospace; + -fx-padding: 4 0 0 0; +} +.review-fanin-ask { + -fx-background-color: transparent; + -fx-background-radius: 6px; + -fx-border-color: -drydock-border-strong; + -fx-border-radius: 6px; + -fx-text-fill: -drydock-text-dim; + -fx-font-size: 10.5px; + -fx-alignment: center-left; + -fx-cursor: hand; +} +.review-fanin-ask:hover { -fx-background-color: -drydock-hover; } +.review-fanin-ask:focused { -fx-border-color: -drydock-accent; } +.review-fanin-notice { + -fx-text-fill: -drydock-dirty; + -fx-font-size: 10px; +} + +/* + * The rail's fan-in count, as a control. Transparent and borderless: it + * wraps the reason Label already inside a card and must not read as a + * second card. Its TEXT lives on that child Label (.review-path-reason, + * which carries its own -fx-text-fill selected and not) -- a plain + * Button.setText here is the 1.13:1 contrast defect ReviewIntentRail + * documents. + */ +.review-fanin-count { + -fx-background-color: transparent; + -fx-background-radius: 4px; + -fx-border-color: transparent; + -fx-border-radius: 4px; + -fx-padding: 1 3 1 3; + -fx-alignment: top-left; + -fx-cursor: hand; +} +.review-fanin-count:hover { -fx-background-color: -drydock-hover; } +.review-fanin-count:focused { -fx-border-color: -drydock-accent; } + .review-mcp-panel { -fx-background-color: -drydock-code-bg; -fx-border-color: -drydock-border transparent transparent transparent; diff --git a/app/src/test/java/app/drydock/DiagVerbsAreWiredTest.java b/app/src/test/java/app/drydock/DiagVerbsAreWiredTest.java new file mode 100644 index 00000000..1cf0f388 --- /dev/null +++ b/app/src/test/java/app/drydock/DiagVerbsAreWiredTest.java @@ -0,0 +1,156 @@ +package app.drydock; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Every diag verb the {@code DrydockApplication} comments advertise has a + * {@code case} that implements it. + * + *

Two did not, for a long time. {@code reviewkey} was documented from Task + * 18 and never wired; {@code comment} was documented, and its implementation + * ({@code ReviewDiffColumn.diagOpenComposer}) was fully written and had zero + * callers. Both fell through to a {@code default} that printed + * {@code "[diag] mark "}, so a driver script asking for one printed a + * plausible beacon and did nothing -- a screenshot run that approved nothing + * looked exactly like one that worked.

+ * + *

The default branches now name an unrecognised verb, which makes the NEXT + * one loud at runtime. This makes it loud at build time instead, for the case + * nobody runs: a verb can be documented and unwired for two months without + * anyone typing it.

+ * + *

Deliberately one-directional. The comments document a SUBSET -- the + * explorer block lists six of roughly two dozen cases -- so an unlisted case + * is fine and a listed verb with no case is not.

+ */ +class DiagVerbsAreWiredTest { + + /** + * A table row: {@code // verb:arg description}, where the description + * is separated by at least two spaces. Continuation lines indent far + * deeper and prose puts one space after the word, so the alignment is + * what tells a verb from a sentence. + */ + private static final Pattern TABLE_ROW = + Pattern.compile("^\\s*//\\s{3}([a-z][a-zA-Z]*)(?::\\S*)?\\s{2,}\\S"); + + /** Prose: {@code open (switch to ...), type (insert ...), shot (...)}. */ + private static final Pattern PROSE_ENTRY = + Pattern.compile("\\b([a-z][a-zA-Z]*) \\("); + + /** + * A whole case label, which may carry SEVERAL literals: the settings + * dispatcher has {@code case "uislider", "tslider" ->}. Matching a bare + * {@code case "x"} reported tslider as unwired when it is not -- the first + * thing this test found was a bug in itself. + */ + private static final Pattern CASE_LABEL = + Pattern.compile("case\\s+((?:\"[^\"]*\"\\s*,\\s*)*\"[^\"]*\")\\s*->"); + + private static final Pattern CASE_LITERAL = Pattern.compile("\"([^\"]*)\""); + + @Test + void everyDocumentedDiagVerbHasACase() throws IOException { + String source = Files.readString(drydockApplication()); + Set documented = documentedVerbs(source); + + assertFalse(documented.isEmpty(), + "parsed no verbs at all -- the comment format changed and this test went blind"); + + Set wired = caseLabels(source); + List unwired = documented.stream().filter(verb -> !wired.contains(verb)).toList(); + + assertEquals(List.of(), unwired, + "documented diag verbs with no case in DrydockApplication -- a script asking for " + + "one does nothing and says so only at runtime. Wire it or stop " + + "documenting it. Parsed " + documented.size() + " verbs: " + documented); + } + + /** + * The guard that made the two silent verbs findable at all. Without it an + * unknown verb is indistinguishable from {@code mark}, which is how both + * defects survived: the beacon looked right. + */ + @Test + void anUnknownVerbIsReportedRatherThanTreatedAsAMark() throws IOException { + String source = Files.readString(drydockApplication()); + + assertTrue(source.contains("UNKNOWN explorerScript verb"), + "explorerScript's default must name the verb it did not recognise"); + assertTrue(source.contains("UNKNOWN tabScript verb"), + "tabScript's default must name the verb it did not recognise"); + assertTrue(source.contains("unknown settings verb"), + "settingsScript's default already did this; it must keep doing it"); + assertTrue(source.contains("case \"mark\""), + "mark must be a real case: it used to BE the default, so a default that " + + "reports unknown verbs would otherwise break every driver's markers"); + } + + /** Every literal that appears in a {@code case ... ->} label. */ + private static Set caseLabels(String source) { + Set labels = new LinkedHashSet<>(); + Matcher label = CASE_LABEL.matcher(source); + while (label.find()) { + Matcher literal = CASE_LITERAL.matcher(label.group(1)); + while (literal.find()) { + labels.add(literal.group(1)); + } + } + return labels; + } + + private static Set documentedVerbs(String source) { + Set verbs = new LinkedHashSet<>(); + for (String line : source.lines().toList()) { + Matcher row = TABLE_ROW.matcher(line); + if (row.find()) { + verbs.add(row.group(1)); + } + } + // The explorer block is prose rather than a table: everything between + // "Verbs:" and the sentence that closes the paragraph. + int end = source.indexOf("Each step's delay is measured from startup"); + int start = end < 0 ? -1 : source.lastIndexOf("Verbs:", end); + if (start >= 0 && end > start) { + // Unwrap first: the block is line-wrapped, so "type" can end one + // line and "(insert ...)" begin the next, and a contiguous + // "type (" never appears. Missing a verb here fails SILENTLY -- + // the test simply never checks it -- which is the same shape as + // the defect it exists to catch. + String prosePart = source.substring(start, end) + .replaceAll("(?m)^\\s*//", " ") + .replaceAll("\\s+", " "); + Matcher prose = PROSE_ENTRY.matcher(prosePart); + while (prose.find()) { + verbs.add(prose.group(1)); + } + } + return verbs; + } + + /** The test's working directory is the {@code app} module. */ + private static Path drydockApplication() { + Path relative = Path.of("src/main/java/app/drydock/DrydockApplication.java"); + if (Files.exists(relative)) { + return relative; + } + Path fromRoot = Path.of("app").resolve(relative); + assertTrue(Files.exists(fromRoot), "cannot find DrydockApplication.java from " + + Path.of("").toAbsolutePath()); + return fromRoot; + } +} diff --git a/app/src/test/java/app/drydock/git/GitStatusServiceTest.java b/app/src/test/java/app/drydock/git/GitStatusServiceTest.java index 53bc2489..52c19925 100644 --- a/app/src/test/java/app/drydock/git/GitStatusServiceTest.java +++ b/app/src/test/java/app/drydock/git/GitStatusServiceTest.java @@ -554,6 +554,55 @@ void fetchAllSucceedsAgainstALocalRemote(@TempDir Path tmp) throws Exception { .anyMatch(branch -> branch.name().equals("origin/added-later"))); } + // ---- resolving a ref to a commit ------------------------------------ + + /** + * A verdict is stamped with a COMMIT, never with the branch name a scope + * carries: recorded against {@code "main"} and compared against + * {@code "main"}, it could never be stale. + */ + @Test + void aBranchNameResolvesToItsCommit(@TempDir Path repo) throws Exception { + initRepo(repo, "main"); + writeFile(repo, "a.txt", "one"); + runGit(repo, "add", "."); + commit(repo, "first"); + + String resolved = service.commitForRefBlocking(repo, "main").orElseThrow(); + + assertEquals(service.headCommitBlocking(repo).orElseThrow(), resolved); + assertEquals(40, resolved.length(), "a full sha, not an abbreviation: " + resolved); + } + + /** Empty, not an exception and not the ref name -- the caller stores "unresolved". */ + @Test + void anUnknownRefResolvesToNothing(@TempDir Path repo) throws Exception { + initRepo(repo, "main"); + writeFile(repo, "a.txt", "one"); + runGit(repo, "add", "."); + commit(repo, "first"); + + assertTrue(service.commitForRefBlocking(repo, "no-such-branch").isEmpty()); + } + + /** + * A base is a string this service is handed, not one it chose, and a + * string beginning with {@code -} is an option to git unless + * {@code --end-of-options} says otherwise -- {@code git rev-parse + * --verify --git-dir} answers with the repository's git directory rather + * than refusing. It must resolve to nothing, never to whatever a flag + * would have printed. + */ + @Test + void aRefBeginningWithADashIsReadAsARefNeverAsAnOption(@TempDir Path repo) throws Exception { + initRepo(repo, "main"); + writeFile(repo, "a.txt", "one"); + runGit(repo, "add", "."); + commit(repo, "first"); + + assertTrue(service.commitForRefBlocking(repo, "--git-dir").isEmpty()); + } + private GitStatus getStatus(Path repo) throws ExecutionException, InterruptedException { CompletableFuture future = service.getStatus(repo); return future.get(); diff --git a/app/src/test/java/app/drydock/mcp/FakeMcpSessionContext.java b/app/src/test/java/app/drydock/mcp/FakeMcpSessionContext.java index db984e75..44bc8ca5 100644 --- a/app/src/test/java/app/drydock/mcp/FakeMcpSessionContext.java +++ b/app/src/test/java/app/drydock/mcp/FakeMcpSessionContext.java @@ -2,9 +2,12 @@ import app.drydock.domain.HandoffBrief; import app.drydock.domain.ManagedSessionId; +import app.drydock.git.UnifiedDiff; import app.drydock.mcp.McpSessionContext.RenameKind; import app.drydock.mcp.McpSessionContext.RenameOutcome; +import app.drydock.review.FallbackIntents; import app.drydock.review.ReviewAnnotation; +import app.drydock.review.ReviewIntent; import java.nio.file.Path; import java.time.Instant; @@ -94,10 +97,20 @@ public List annotations(ManagedSessionId caller) { final Map reviewScopes = new HashMap<>(); /** The diff {@link #reviewDiff} returns. */ - app.drydock.git.UnifiedDiff reviewDiff = new app.drydock.git.UnifiedDiff(List.of()); + UnifiedDiff reviewDiff = new UnifiedDiff(List.of()); + + /** + * When set, {@link #reviewDiff} throws this instead of returning {@link + * #reviewDiff}. Separate from {@link #failure} so a test can fail the + * diff path without also failing worktree creation and session start -- + * the real {@code WorkspaceMcpSessionContext.reviewDiff} throws for a + * PR with no local checkout, or a git failure, and this is how a test + * models that without touching either of those. + */ + McpToolException reviewDiffFailure; /** The last intent grouping {@link #putIntents} received. */ - final Map> intents = new HashMap<>(); + final Map> intents = new HashMap<>(); final List verdicts = new ArrayList<>(); final Set submitted = new LinkedHashSet<>(); @@ -119,15 +132,24 @@ public Optional reviewScope(String scopeId, Mana } @Override - public app.drydock.git.UnifiedDiff reviewDiff(app.drydock.review.ReviewScope scope) { + public UnifiedDiff reviewDiff(app.drydock.review.ReviewScope scope) throws McpToolException { + if (reviewDiffFailure != null) { + throw reviewDiffFailure; + } return reviewDiff; } @Override - public void putIntents(String scopeId, List newIntents) { + public void putIntents(String scopeId, List newIntents) { intents.put(scopeId, List.copyOf(newIntents)); } + @Override + public List intentsOf(String scopeId, UnifiedDiff diff) { + List supplied = intents.get(scopeId); + return supplied != null ? supplied : FallbackIntents.group(diff); + } + @Override public void upsertFindings(List findings) { for (ReviewAnnotation finding : findings) { @@ -146,6 +168,27 @@ public List verdictsOf(String scopeId) { return verdicts.stream().filter(verdict -> verdict.scopeId().equals(scopeId)).toList(); } + /** + * What {@code scope.base()} RESOLVES to; a commit, not the ref name. + * Empty models a base git cannot resolve, which {@code review_recheck} + * refuses on rather than recording an assessment about a move nobody can + * name. + */ + Optional currentReviewBase = Optional.of("base-2"); + + /** Every assessment {@link #putAssessments} received, in arrival order. */ + final List assessments = new ArrayList<>(); + + @Override + public Optional currentReviewBase(app.drydock.review.ReviewScope scope) { + return currentReviewBase; + } + + @Override + public void putAssessments(List newAssessments) { + assessments.addAll(newAssessments); + } + @Override public boolean reviewSubmitted(String scopeId) { return submitted.contains(scopeId); diff --git a/app/src/test/java/app/drydock/mcp/McpRouterFixture.java b/app/src/test/java/app/drydock/mcp/McpRouterFixture.java new file mode 100644 index 00000000..497343ad --- /dev/null +++ b/app/src/test/java/app/drydock/mcp/McpRouterFixture.java @@ -0,0 +1,169 @@ +package app.drydock.mcp; + +import app.drydock.domain.ManagedSessionId; +import app.drydock.git.UnifiedDiff; +import app.drydock.mcp.McpSessionRegistry.Spawn; +import app.drydock.review.ChangeGraph; +import app.drydock.review.ReviewScope; +import app.drydock.state.json.JsonValue; +import app.drydock.state.json.JsonValue.JsonObject; +import app.drydock.state.json.JsonValue.JsonString; +import app.drydock.state.json.JsonWriter; +import org.junit.jupiter.api.BeforeEach; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.concurrent.atomic.AtomicInteger; + +import static app.drydock.mcp.JsonPeek.field; + +/** + * Shared {@code review_scope} plumbing for tests that only need one bound + * scope and a real, parseable diff -- modelled on {@link + * McpToolRouterReviewTest}'s setup, but with actual source text rather than + * placeholder lines, since a computed grouping (Task 12's {@code Sections}) + * needs something {@code ChangeGraph} can parse to produce a hub symbol. + * + *

The router here is wired through the package-private test-seam + * constructor with a counting {@code graphBuilder}, so a test can assert + * {@link ChangeGraph} was (or was not) actually built -- not merely that a + * {@code "sections"} key is absent, which conflates "never built" with + * "built and discarded".

+ */ +class McpRouterFixture { + + private static final String SCOPE = "rs_sections"; + + private final ManagedSessionId caller = ManagedSessionId.newId(); + private final AtomicInteger graphBuilds = new AtomicInteger(); + private McpSessionRegistry registry; + FakeMcpSessionContext context; + McpToolRouter router; + + @BeforeEach + void setUpFixture() { + context = new FakeMcpSessionContext(); + context.repositoryRoot = Optional.of(Path.of("/repos/drydock")); + context.worktreePath = Optional.of(Path.of("/repos/drydock")); + registry = new McpSessionRegistry(); + registry.mint(caller, Spawn.ALLOWED); + router = new McpToolRouter(context, registry, diff -> { + graphBuilds.incrementAndGet(); + return ChangeGraph.of(diff); + }); + + context.grant(caller, SCOPE); + bindScopeTo(Path.of("/wt/feat")); + context.reviewDiff = parseableDiff(); + } + + /** + * Points the bound scope's worktree at {@code worktree}. The default is + * a path that does not exist, so the out-of-diff fan-in scan behind + * {@code sections} reports "unavailable" and costs nothing; a test that + * wants a REAL scan hands in a real repository. + */ + void bindScopeTo(Path worktree) { + context.reviewScopes.put(SCOPE, new ReviewScope(SCOPE, ReviewScope.Kind.WORKTREE, + Path.of("/repos/drydock"), Optional.of(worktree), "master", "feat", + Optional.empty(), Optional.empty(), Optional.empty())); + } + + String scopeId() { + return SCOPE; + } + + /** How many times {@link ChangeGraph#of} actually ran, real work and all -- not merely what the wire shows. */ + int graphBuilds() { + return graphBuilds.get(); + } + + /** Calls {@code review_scope} with the default byte budget, returning the raw JSON response as a string. */ + String callReviewScope(String scopeId, String include) { + return JsonWriter.write(callReviewScopeValue(scopeId, include, null, McpToolRouter.DEFAULT_SCOPE_BYTES)); + } + + /** As above, but resuming from a prior page's cursor -- the default budget still applies. */ + String callReviewScope(String scopeId, String include, String cursor) { + return JsonWriter.write(callReviewScopeValue(scopeId, include, cursor, McpToolRouter.DEFAULT_SCOPE_BYTES)); + } + + /** Full control, for a test that needs a small budget to force a genuine second page. */ + JsonValue callReviewScopeValue(String scopeId, String include, String cursor, int maxBytes) { + JsonObject args = JsonObject.empty() + .put("scopeId", new JsonString(scopeId)) + .put("maxBytes", new JsonString(String.valueOf(maxBytes))); + if (include != null) { + args.put("include", new JsonString(include)); + } + if (cursor != null) { + args.put("cursor", new JsonString(cursor)); + } + try { + return router.call(callerId(), "review_scope", args); + } catch (McpToolException e) { + throw new AssertionError(e); + } + } + + /** The cursor a {@code review_scope} response carries, or null for a complete read. */ + static String cursorOf(JsonValue response) { + return field(response, "cursor") instanceof JsonString cursor ? cursor.value() : null; + } + + /** + * Rewires the router so building a section's {@link ChangeGraph} throws, + * as a real parse edge case in {@code SymbolScan} would -- for a test + * pinning that a {@code sections} failure degrades the whole call rather + * than failing it. + */ + void makeGraphBuildingFail() { + router = new McpToolRouter(context, registry, diff -> { + throw new IllegalStateException("synthetic parse failure"); + }); + } + + ManagedSessionId callerId() { + return caller; + } + + /** + * Two real, cross-referencing Java files, so {@code ChangeGraph} has both + * a hub symbol to title a section after AND a shared-foundation edge + * (spec §5.6's overlap) -- {@code Widget} is pulled into {@code + * WidgetUser}'s own section, which is what makes the sections payload + * scale with sections-times-shared-files rather than plain file count. + * Two files also means two hunks, so a small {@code maxBytes} can force + * a genuine second page. + */ + private static UnifiedDiff parseableDiff() { + UnifiedDiff.FileDiff widget = file("src/Widget.java", + "public class Widget {", + " void run() {", + " System.out.println(\"hi\");", + " }", + "}"); + UnifiedDiff.FileDiff widgetUser = file("src/WidgetUser.java", + "public class WidgetUser {", + " void use() {", + " Widget w = new Widget();", + " w.run();", + " }", + "}"); + return new UnifiedDiff(List.of(widget, widgetUser)); + } + + private static UnifiedDiff.FileDiff file(String path, String... added) { + List lines = new ArrayList<>(); + int n = 1; + for (String text : added) { + lines.add(new UnifiedDiff.Line(UnifiedDiff.Line.Kind.ADD, + OptionalInt.empty(), OptionalInt.of(n++), text)); + } + return new UnifiedDiff.FileDiff(path, "A", added.length, 0, false, false, + List.of(new UnifiedDiff.Hunk("@@", lines))); + } +} diff --git a/app/src/test/java/app/drydock/mcp/McpToolRouterReadTest.java b/app/src/test/java/app/drydock/mcp/McpToolRouterReadTest.java index cb668a20..9c8b0b6e 100644 --- a/app/src/test/java/app/drydock/mcp/McpToolRouterReadTest.java +++ b/app/src/test/java/app/drydock/mcp/McpToolRouterReadTest.java @@ -65,9 +65,9 @@ void toolDescriptorsCoverEverySupportedTool() { .toList(); assertEquals(List.of("review_comments", "review_reply", "review_scope", "review_intents", - "review_finding", "review_answer", "review_state", "worktree_create", - "session_start", "session_rename", "session_handoff", "repos_list", - "sessions_list"), names); + "review_finding", "review_answer", "review_state", "review_recheck", + "worktree_create", "session_start", "session_rename", "session_handoff", + "repos_list", "sessions_list"), names); } @Test @@ -96,6 +96,7 @@ void toolDescriptorsDeclareTheirRequiredArguments() { Map.entry("review_finding", List.of("scopeId", "findings")), Map.entry("review_answer", List.of("scopeId", "findingId", "body")), Map.entry("review_state", List.of("scopeId")), + Map.entry("review_recheck", List.of("scopeId", "assessments")), Map.entry("worktree_create", List.of("branch")), Map.entry("session_start", List.of("worktree_path")), Map.entry("session_rename", List.of("title")), diff --git a/app/src/test/java/app/drydock/mcp/McpToolRouterRecheckTest.java b/app/src/test/java/app/drydock/mcp/McpToolRouterRecheckTest.java new file mode 100644 index 00000000..7875cafc --- /dev/null +++ b/app/src/test/java/app/drydock/mcp/McpToolRouterRecheckTest.java @@ -0,0 +1,420 @@ +package app.drydock.mcp; + +import app.drydock.git.UnifiedDiff; +import app.drydock.review.HunkDigest; +import app.drydock.review.RecheckAssessment; +import app.drydock.review.ReviewIntent; +import app.drydock.review.ReviewVerdict; +import app.drydock.state.json.JsonParser; +import app.drydock.state.json.JsonValue; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import static app.drydock.mcp.JsonPeek.num; +import static app.drydock.mcp.JsonPeek.str; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * {@code review_recheck} on the REAL tool path (spec §9.7). + * + *

Everything here goes in as the wire's positional {@code hunkId} and has + * to come out as the content digest a verdict is keyed by. That translation + * is the whole reason this file exists alongside {@code + * RecheckAsymmetryTest}: the store's own tests hand it a digest directly and + * exercise none of it, and a hunkId stored verbatim would sit in the + * annotations file matching nothing the board ever asks for -- a recheck the + * human believes happened and did not.

+ * + *

The asymmetry is pinned here too, on the wire: an {@code affected:true} + * lands as a mark, an {@code affected:false} lands as a record that marks + * nothing, and neither one touches the verdict.

+ */ +class McpToolRouterRecheckTest extends McpRouterFixture { + + private static final String WIDGET_HUNK = ReviewIntent.hunkId("src/Widget.java", 0); + private static final String USER_HUNK = ReviewIntent.hunkId("src/WidgetUser.java", 0); + + /** The content digest of the fixture diff's first hunk -- what a verdict is keyed by. */ + private String widgetDigest() { + UnifiedDiff.FileDiff file = context.reviewDiff.files().get(0); + return HunkDigest.of(file.path(), file.hunks().get(0)); + } + + private String userDigest() { + UnifiedDiff.FileDiff file = context.reviewDiff.files().get(1); + return HunkDigest.of(file.path(), file.hunks().get(0)); + } + + /** An approval on {@code digest}, judged against {@code base}. */ + private void approve(String digest, String base) { + context.verdicts.add(new ReviewVerdict(scopeId(), digest, ReviewVerdict.Decision.APPROVED, + Optional.empty(), Instant.EPOCH, base, "head-1")); + } + + private JsonValue recheck(String assessments) throws McpToolException { + return router.call(callerId(), "review_recheck", JsonParser.parse(""" + {"scopeId":"%s","assessments":%s} + """.formatted(scopeId(), assessments))); + } + + // ---- ruling 1: the wire says hunkId, the store is keyed by hunkDigest ---- + + /** + * The one translation the plan's own store-level tests could not reach: + * {@code h_src/Widget.java_0} is POSITIONAL, {@link HunkDigest} is + * content-addressed and excludes line numbers, and what gets stored has + * to be the second one. + */ + @Test + void theWireHunkIdIsStoredAsTheContentDigestAVerdictIsKeyedBy() throws Exception { + approve(widgetDigest(), "base-1"); + + recheck(""" + [{"hunkId":"%s","affected":true,"why":"resolve() now returns nullptr"}] + """.formatted(WIDGET_HUNK)); + + assertEquals(1, context.assessments.size()); + RecheckAssessment stored = context.assessments.get(0); + assertEquals(widgetDigest(), stored.hunkDigest()); + assertNotEquals(WIDGET_HUNK, stored.hunkDigest(), + "storing the positional id would match no verdict the board ever asks about"); + assertEquals(scopeId(), stored.scopeId()); + assertTrue(stored.affected()); + assertEquals("resolve() now returns nullptr", stored.why()); + } + + /** + * Two hunks in the batch, so a handler that resolved everything to the + * FIRST file's digest -- the shape a one-hunk fixture cannot tell from a + * correct one -- is caught. + */ + @Test + void eachHunkIdResolvesToItsOwnHunkRatherThanTheFirst() throws Exception { + approve(widgetDigest(), "base-1"); + approve(userDigest(), "base-1"); + + recheck(""" + [{"hunkId":"%s","affected":true,"why":"a"}, + {"hunkId":"%s","affected":true,"why":"b"}] + """.formatted(WIDGET_HUNK, USER_HUNK)); + + assertEquals(List.of(widgetDigest(), userDigest()), + context.assessments.stream().map(RecheckAssessment::hunkDigest).toList()); + assertNotEquals(widgetDigest(), userDigest(), "the fixture must have two distinct digests"); + } + + /** + * An unresolvable {@code hunkId} rejects the BATCH, naming the offending + * id -- a file the diff does not have, an index past that file's hunk + * count, and text that is not a hunk id at all. Skipping it silently is + * the failure ruling 1 exists to prevent: absent and broken must not look + * the same. + */ + @ParameterizedTest + @ValueSource(strings = { + "h_src/Gone.java_0", // a file this diff does not have + "h_src/Widget.java_7", // an index past that file's hunk count + "h_src/Widget.java_-1", // a negative index + "not-a-hunk-id", // not shaped like one at all + }) + void aHunkIdNamingNothingInTheDiffRejectsTheWholeBatch(String bad) { + approve(widgetDigest(), "base-1"); + + McpToolException thrown = assertThrows(McpToolException.class, () -> recheck(""" + [{"hunkId":"%s","affected":true,"why":"a"}, + {"hunkId":"%s","affected":true,"why":"b"}] + """.formatted(WIDGET_HUNK, bad))); + + assertTrue(thrown.getMessage().contains(bad), thrown.getMessage()); + assertTrue(context.assessments.isEmpty(), + "a batch with one bad entry must write nothing, not half a recheck"); + } + + /** + * A hunk with no verdict has no {@code fromBase}, so there is no base + * move to key an assessment by and nothing decided to undermine. + * Refused, naming the id, rather than stored under a fabricated pair. + */ + @Test + void aHunkCarryingNoVerdictRejectsTheBatch() { + McpToolException thrown = assertThrows(McpToolException.class, () -> recheck(""" + [{"hunkId":"%s","affected":true,"why":"a"}] + """.formatted(WIDGET_HUNK))); + + assertTrue(thrown.getMessage().contains(WIDGET_HUNK), thrown.getMessage()); + assertTrue(context.assessments.isEmpty()); + } + + // ---- the base pair drydock derives -------------------------------------- + + /** + * {@code fromBase} is the base the hunk's OWN verdict was recorded + * against and {@code toBase} is the scope's base now, so the key written + * here is the key the board reads with. Two hunks approved against + * DIFFERENT bases, so a handler taking one base for the whole batch is + * caught. + */ + @Test + void theBasePairComesFromEachHunksOwnVerdictAndTheScopesCurrentBase() throws Exception { + approve(widgetDigest(), "base-0"); + approve(userDigest(), "base-1"); + context.currentReviewBase = Optional.of("base-9"); + + recheck(""" + [{"hunkId":"%s","affected":true,"why":"a"}, + {"hunkId":"%s","affected":true,"why":"b"}] + """.formatted(WIDGET_HUNK, USER_HUNK)); + + assertEquals(List.of("base-0", "base-1"), + context.assessments.stream().map(RecheckAssessment::fromBase).toList()); + assertEquals(List.of("base-9", "base-9"), + context.assessments.stream().map(RecheckAssessment::toBase).toList()); + } + + /** + * A base that does not resolve to a commit is not a base move anyone can + * name, so the call is refused rather than recording a recheck against + * a placeholder. + */ + @Test + void aBaseThatDoesNotResolveRefusesTheCall() { + approve(widgetDigest(), "base-1"); + context.currentReviewBase = Optional.empty(); + + McpToolException thrown = assertThrows(McpToolException.class, () -> recheck(""" + [{"hunkId":"%s","affected":true,"why":"a"}] + """.formatted(WIDGET_HUNK))); + + assertTrue(thrown.getMessage().contains(scopeId()), thrown.getMessage()); + assertTrue(context.assessments.isEmpty()); + } + + // ---- the asymmetry, on the wire ----------------------------------------- + + /** + * {@code affected:false} is recorded -- it is what the agent said -- but + * it marks nothing, and the response says so. The failure this guards is + * an agent's "unaffected" quietly un-staling a verdict, which is a + * human's approval standing over code nobody re-read. + */ + @Test + void anUnaffectedAssessmentIsRecordedAndMarksNothing() throws Exception { + approve(widgetDigest(), "base-1"); + + JsonValue response = recheck(""" + [{"hunkId":"%s","affected":false,"why":"unrelated subsystem"}] + """.formatted(WIDGET_HUNK)); + + assertEquals(1, num(response, "assessments")); + assertEquals(0, num(response, "markedStale")); + assertFalse(context.assessments.get(0).affected()); + // The verdict is untouched: still approved, still recorded against + // the base it was judged on, so still stale against the new one. + ReviewVerdict verdict = context.verdictsOf(scopeId()).get(0); + assertEquals(ReviewVerdict.Decision.APPROVED, verdict.decision()); + assertEquals("base-1", verdict.baseCommit()); + assertTrue(verdict.staleAgainst("base-2")); + } + + /** An omitted {@code affected} is the inert direction, never a mark nobody asserted. */ + @Test + void anOmittedAffectedMarksNothing() throws Exception { + approve(widgetDigest(), "base-1"); + + JsonValue response = recheck(""" + [{"hunkId":"%s","why":"said nothing about it"}] + """.formatted(WIDGET_HUNK)); + + assertEquals(0, num(response, "markedStale")); + assertFalse(context.assessments.get(0).affected()); + } + + @Test + void anAffectedAssessmentIsReportedAsAMark() throws Exception { + approve(widgetDigest(), "base-1"); + + JsonValue response = recheck(""" + [{"hunkId":"%s","affected":true,"why":"resolve() now returns nullptr"}] + """.formatted(WIDGET_HUNK)); + + assertEquals(scopeId(), str(response, "scopeId")); + assertEquals(1, num(response, "assessments")); + assertEquals(1, num(response, "markedStale")); + } + + // ---- a mark must carry its reason --------------------------------------- + + /** + * A staleness signal asserted with no reason is the reflexive click the + * whole asymmetry exists to avoid -- and it is what a renderer could only + * draw as a blank warning. Refused whether the field is missing outright + * or present and empty: both leave the human with a hunk to re-read and + * nothing saying why. + */ + @ParameterizedTest + @ValueSource(strings = { + "", // no why at all + ",\"why\":\"\"", // present and empty + ",\"why\":\" \"", // present and blank + }) + void anAffectedMarkWithNoReasonRejectsTheBatch(String why) { + approve(widgetDigest(), "base-1"); + + McpToolException thrown = assertThrows(McpToolException.class, () -> recheck(""" + [{"hunkId":"%s","affected":true%s}] + """.formatted(WIDGET_HUNK, why))); + + assertTrue(thrown.getMessage().contains(WIDGET_HUNK), thrown.getMessage()); + assertTrue(context.assessments.isEmpty()); + } + + /** + * An {@code affected:false} may omit it. Saying "I looked and it does not + * matter" changes nothing a human has to act on, so there is nothing for a + * reason to justify. + */ + @Test + void anUnaffectedAssessmentMayOmitItsWhy() throws Exception { + approve(widgetDigest(), "base-1"); + + recheck(""" + [{"hunkId":"%s","affected":false}] + """.formatted(WIDGET_HUNK)); + + assertEquals("", context.assessments.get(0).why()); + } + + // ---- absent and broken must not look the same --------------------------- + + /** + * The rule this whole surface is drawn around, enforced for {@code reads} + * one task ago and now here. Every shape below would otherwise decode as + * {@code false} -- "the agent looked and found nothing" -- which is the + * one answer this tool must never manufacture. {@code "true"} from a + * stringifying client is the likeliest of them, and this codebase already + * accommodates such a client in {@code optionalIntArg}. + */ + @ParameterizedTest + @ValueSource(strings = { + "\"true\"", // a stringifying client + "1", // a truthy number + "\"yes\"", + "{\"value\":true}", + "[true]", + }) + void aNonBooleanAffectedRejectsTheBatch(String malformed) { + approve(widgetDigest(), "base-1"); + + McpToolException thrown = assertThrows(McpToolException.class, () -> recheck(""" + [{"hunkId":"%s","affected":%s,"why":"resolve() now returns nullptr"}] + """.formatted(WIDGET_HUNK, malformed))); + + assertTrue(thrown.getMessage().contains(WIDGET_HUNK), thrown.getMessage()); + // Named as a TYPE problem: with affected:true a lenient decode would + // land on the mark-needs-a-reason refusal instead, and a test asserting + // only "it threw" could not tell the two apart. + assertTrue(thrown.getMessage().contains("not a boolean"), thrown.getMessage()); + assertTrue(context.assessments.isEmpty()); + } + + /** + * A why of the wrong type is broken too, not an empty reason. + * + *

Sent with {@code affected:false} deliberately. Under {@code + * affected:true} the mark-needs-a-reason refusal fires on the empty string + * a lenient decode produces, so the batch is rejected either way and the + * test cannot tell a type check from a blank check -- it would pass with + * the type check deleted. With {@code affected:false} nothing else + * refuses, so only the type check can.

+ */ + @ParameterizedTest + @ValueSource(strings = { + "{\"text\":\"the base change is in an unrelated subsystem\"}", + "7", + "[\"the base change is in an unrelated subsystem\"]", + }) + void aNonStringWhyRejectsTheBatch(String malformed) { + approve(widgetDigest(), "base-1"); + + McpToolException thrown = assertThrows(McpToolException.class, () -> recheck(""" + [{"hunkId":"%s","affected":false,"why":%s}] + """.formatted(WIDGET_HUNK, malformed))); + + assertTrue(thrown.getMessage().contains(WIDGET_HUNK), thrown.getMessage()); + assertTrue(thrown.getMessage().contains("not a string"), thrown.getMessage()); + assertTrue(context.assessments.isEmpty()); + } + + /** + * An explicit {@code null} stays ABSENT rather than broken -- it is how + * several clients spell an omitted optional field, and refusing over it + * would reject a recheck that declared nothing wrong. + */ + @Test + void anExplicitNullAffectedAndWhyAreAbsentNotBroken() throws Exception { + approve(widgetDigest(), "base-1"); + + JsonValue response = recheck(""" + [{"hunkId":"%s","affected":null,"why":null}] + """.formatted(WIDGET_HUNK)); + + assertEquals(0, num(response, "markedStale")); + assertFalse(context.assessments.get(0).affected()); + assertEquals("", context.assessments.get(0).why()); + } + + // ---- ruling 2: why is agent text that gets rendered ---------------------- + + /** + * {@code why} is free text from an agent, stored, and shown to a human as + * the reason a hunk was marked affected -- the same treatment {@code + * intent.title}, {@code finding.body} and {@code evidence.code} already + * get. A control character can reach a terminal through "Ask the agent to + * fix it", so it is refused at the boundary. + */ + @Test + void aWhyCarryingAControlCharacterRejectsTheBatch() { + approve(widgetDigest(), "base-1"); + + McpToolException thrown = assertThrows(McpToolException.class, () -> recheck(""" + [{"hunkId":"%s","affected":true,"why":"before\\u001bafter"}] + """.formatted(WIDGET_HUNK))); + + assertTrue(thrown.getMessage().contains("assessment.why"), thrown.getMessage()); + assertTrue(context.assessments.isEmpty()); + } + + // ---- shape -------------------------------------------------------------- + + @Test + void assessmentsMustBeAnArray() { + approve(widgetDigest(), "base-1"); + + assertThrows(McpToolException.class, () -> router.call(callerId(), "review_recheck", + JsonParser.parse(""" + {"scopeId":"%s","assessments":"h_src/Widget.java_0"} + """.formatted(scopeId())))); + assertTrue(context.assessments.isEmpty()); + } + + @Test + void theToolIsRegisteredWithItsRequiredArguments() { + JsonValue tool = router.toolDescriptors().stream() + .filter(descriptor -> "review_recheck".equals(str(descriptor, "name"))) + .findFirst() + .orElseThrow(() -> new AssertionError("review_recheck is not registered")); + + assertEquals(List.of("scopeId", "assessments"), JsonPeek.requiredNames(tool)); + // The one thing an agent must not misread about this tool. + assertTrue(str(tool, "description").contains("never clears"), str(tool, "description")); + } +} diff --git a/app/src/test/java/app/drydock/mcp/McpToolRouterReviewTest.java b/app/src/test/java/app/drydock/mcp/McpToolRouterReviewTest.java index 778c3962..8d7d8380 100644 --- a/app/src/test/java/app/drydock/mcp/McpToolRouterReviewTest.java +++ b/app/src/test/java/app/drydock/mcp/McpToolRouterReviewTest.java @@ -5,6 +5,7 @@ import app.drydock.mcp.McpSessionRegistry.Spawn; import app.drydock.review.AnnotationStatus; import app.drydock.review.Confidence; +import app.drydock.review.HunkDigest; import app.drydock.review.ReviewAnnotation; import app.drydock.review.ReviewIntent; import app.drydock.review.ReviewScope; @@ -365,8 +366,9 @@ void answeringAnUnknownFindingIsRejected() { @Test void reviewStateReportsVerdictsFindingsAndSubmission() throws Exception { context.annotations.add(finding("f1", Severity.BLOCKING)); - context.verdicts.add(new ReviewVerdict(SCOPE, "i1", ReviewVerdict.Decision.CHANGES, - Optional.of("needs a test"), Instant.EPOCH)); + router.call(caller, "review_intents", intentsArgs(intentJson("i1", "Change", "MED", 0))); + context.verdicts.add(new ReviewVerdict(SCOPE, digestOfHunk(0), ReviewVerdict.Decision.CHANGES, + Optional.of("needs a test"), Instant.EPOCH, "base-1", "head-1")); context.submitted.add(SCOPE); JsonValue result = router.call(caller, "review_state", args("scopeId", SCOPE)); @@ -378,6 +380,52 @@ void reviewStateReportsVerdictsFindingsAndSubmission() throws Exception { assertTrue(((JsonBoolean) field(result, "submitted")).value()); } + /** + * Pins the id-space of {@code review_state}'s intents: the wire {@code + * id} is the intent's own id, joined to its hunks' verdicts -- never + * whatever key a verdict happens to be stored under. A verdict stored + * under a digest that belongs to no registered intent must never surface + * as an "intent" id; the old code (reporting {@code + * verdict.hunkDigest()} straight through) would have let it through. + */ + @Test + void reviewStateReportsTheIntentIdNotTheVerdictsStorageKey() throws Exception { + router.call(caller, "review_intents", intentsArgs(intentJson("i1", "Change", "MED", 0))); + context.verdicts.add(new ReviewVerdict(SCOPE, digestOfHunk(0), ReviewVerdict.Decision.APPROVED, + Optional.empty(), Instant.EPOCH, "base-1", "head-1")); + context.verdicts.add(new ReviewVerdict(SCOPE, "orphan-digest-not-an-intent-id", + ReviewVerdict.Decision.APPROVED, Optional.empty(), Instant.EPOCH, "base-1", "head-1")); + + JsonValue result = router.call(caller, "review_state", args("scopeId", SCOPE)); + + List ids = ((JsonArray) field(result, "intents")).elements().stream() + .map(intent -> str(intent, "id")).toList(); + assertEquals(List.of("i1"), ids, "only a registered intent's own id may appear here"); + } + + /** + * A scope whose diff cannot be produced -- a PR with no local checkout, + * or a git failure -- must not fail {@code review_state} outright: + * findings and submission status do not depend on a diff, only the + * per-intent verdict list does. That list is omitted entirely rather + * than reported empty, because an empty array reads as "nothing is + * settled" -- a false claim -- while an absent key correctly says + * "cannot be known right now" (the sidebar's {@code ◨n} badge follows + * the same absent-vs-zero rule for the same reason). + */ + @Test + void reviewStateOmitsIntentsWhenTheDiffFailsButKeepsFindingsAndSubmission() throws Exception { + context.annotations.add(finding("f1", Severity.BLOCKING)); + context.submitted.add(SCOPE); + context.reviewDiffFailure = new McpToolException("pull request #7 is not checked out"); + + JsonValue result = router.call(caller, "review_state", args("scopeId", SCOPE)); + + assertFalse(((JsonObject) result).has("intents"), "an unproducible diff must omit intents, not empty it"); + assertEquals("f1", str(((JsonArray) field(result, "findings")).elements().get(0), "id")); + assertTrue(((JsonBoolean) field(result, "submitted")).value()); + } + /** So a follow-up run fixes the right things and does not re-flag settled ones. */ @Test void reviewStateShowsAResolvedFindingAsResolved() throws Exception { @@ -425,6 +473,35 @@ private static JsonObject intentJson(String id, String title, String risk) { return obj; } + /** + * As above, but naming the hunks the intent covers. {@code review_state} + * derives an intent's verdict from its hunks now, so an intent that names + * none covers the whole diff and needs every one of its twelve hunks + * settled before it reports anything. + */ + private static JsonObject intentJson(String id, String title, String risk, int... hunks) { + JsonObject obj = intentJson(id, title, risk); + List ids = new ArrayList<>(); + for (int hunk : hunks) { + ids.add(new JsonString(ReviewIntent.hunkId("src/Main.java", hunk))); + } + obj.put("hunkIds", new JsonArray(ids)); + return obj; + } + + /** The digest the {@code index}-th hunk of {@link #diff}'s only file is keyed by. */ + private static String digestOfHunk(int index) { + return HunkDigest.of("src/Main.java", diff(12).files().get(0).hunks().get(index)); + } + + /** {@code review_intents} args registering one intent, for tests that need review_state to know it. */ + private static JsonObject intentsArgs(JsonObject intent) { + JsonObject args = JsonObject.empty(); + args.put("scopeId", new JsonString(SCOPE)); + args.put("intents", new JsonArray(List.of(intent))); + return args; + } + private static JsonObject findingJson(String id, String severity, String body) { JsonObject anchor = JsonObject.empty(); anchor.put("file", new JsonString("src/Main.java")); diff --git a/app/src/test/java/app/drydock/mcp/McpToolRouterSectionsTest.java b/app/src/test/java/app/drydock/mcp/McpToolRouterSectionsTest.java new file mode 100644 index 00000000..1cd4f749 --- /dev/null +++ b/app/src/test/java/app/drydock/mcp/McpToolRouterSectionsTest.java @@ -0,0 +1,284 @@ +package app.drydock.mcp; + +import app.drydock.git.UnifiedDiff; +import app.drydock.state.json.JsonValue; +import app.drydock.state.json.JsonValue.JsonArray; +import app.drydock.state.json.JsonValue.JsonObject; +import app.drydock.state.json.JsonWriter; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.OptionalInt; + +import static app.drydock.mcp.JsonPeek.bool; +import static app.drydock.mcp.JsonPeek.field; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The agent has to be able to see the grouping it is being asked to name + * (spec §5.5). An agent that cannot regroups from scratch and loses the + * header conventions and the dependency order -- arriving back at prose + * titles over structurally worse sections. + */ +class McpToolRouterSectionsTest extends McpRouterFixture { + + @Test + void reviewScopeOmitsSectionsUnlessAsked() { + String response = callReviewScope(scopeId(), null); + + assertFalse(response.contains("\"sections\"")); + assertEquals(0, graphBuilds(), "an unrequested call must never build the graph, not just omit it on the wire"); + } + + @Test + void reviewScopeIncludesSectionsWhenAsked() { + String response = callReviewScope(scopeId(), "sections"); + + assertTrue(response.contains("\"sections\"")); + assertTrue(response.contains("\"hunkIds\"")); + assertEquals(1, graphBuilds()); + } + + /** An unknown include is ignored, not an error: it is an optional read. */ + @Test + void anUnknownIncludeIsIgnored() { + String response = callReviewScope(scopeId(), "nonsense"); + + assertFalse(response.contains("\"sections\"")); + assertEquals(0, graphBuilds()); + } + + /** + * A multi-page read must not re-parse the same diff once per page: the + * grouping cannot have changed between pages of the same read, so it is + * offered only on the cursor-absent first page, and the graph is built + * at most once for the whole read even if the agent still asks on every + * page. + */ + @Test + void sectionsAreOmittedOnALaterPageAndTheGraphIsNotRebuilt() { + JsonValue first = callReviewScopeValue(scopeId(), "sections", null, 400); + String cursor = cursorOf(first); + assertNotNull(cursor, "the tiny budget must force a second page"); + assertEquals(1, graphBuilds()); + + String second = callReviewScope(scopeId(), "sections", cursor); + + assertFalse(second.contains("\"sections\"")); + assertEquals(1, graphBuilds(), "a later page must not rebuild the graph for a payload that cannot have changed"); + } + + /** + * Sections overlap by design (spec §5.6): a shared foundation file + * appears in every section that needs it, so the payload scales as + * sections x shared files, not by file count -- it must be charged + * against the same budget hunks pays from, not added on top of it + * unaccounted. + */ + @Test + void sectionsAreChargedAgainstTheByteBudget() { + JsonValue withSections = callReviewScopeValue(scopeId(), "sections", null, 2000); + JsonValue withoutSections = callReviewScopeValue(scopeId(), null, null, 2000); + + int hunksWithSections = ((JsonArray) field(withSections, "hunks")).elements().size(); + int hunksWithoutSections = ((JsonArray) field(withoutSections, "hunks")).elements().size(); + + assertTrue(hunksWithSections < hunksWithoutSections, + "the same budget must yield fewer hunks once sections are charged against it: " + + hunksWithSections + " vs " + hunksWithoutSections); + } + + /** + * When the grouping alone is bigger than the whole budget, it is + * reported anyway -- truncating it mid-array would hand the agent a + * lie -- but the overage must be visible, not silent. + */ + @Test + void aSectionsPayloadBiggerThanTheBudgetIsEmittedWithTheOverageFlagged() { + // One shared foundation file plus eight users of it means eight + // sections each repeating that foundation -- big enough on its own + // to outgrow even the smallest maxBytes the router allows (the + // caller-supplied value is clamped to at least 1_000). + context.reviewDiff = manySectionsSharingAFoundationDiff(8); + + JsonValue result = callReviewScopeValue(scopeId(), "sections", null, 1); + + assertTrue(JsonWriter.write(result).contains("\"sections\""), "the grouping must never be dropped"); + assertTrue(bool(result, "sectionsOverBudget")); + } + + /** + * A parse-edge-case failure while building the grouping must cost only + * that one optional extra, never the whole call: the agent still gets + * hunks, scope and files even though its opt-in extra could not be + * computed. + */ + @Test + void aSectionsBuildFailureDegradesGracefully() { + makeGraphBuildingFail(); + + JsonValue result = callReviewScopeValue(scopeId(), "sections", null, McpToolRouter.DEFAULT_SCOPE_BYTES); + + assertFalse(((JsonObject) result).has("sections"), "a failed build must be omitted, not fail the call"); + assertTrue(((JsonArray) field(result, "hunks")).elements().size() > 0, "hunks must still be reported"); + assertNotNull(field(result, "scope")); + assertNotNull(field(result, "files")); + } + + // ---- the fan-in scan behind the ordering --------------------------------- + + /** + * Fix round 1, item 3. The board and this payload must agree on which + * card is ① -- that is the whole reason {@code computeSections} reorders + * through {@code ReadingPath} rather than handing out {@code Sections}' + * own order. Out-of-diff fan-in is that ordering's FIRST rank term, so a + * router that does not scan disagrees with a board that does, and an + * agent then names sections a human sees in a different order. + * + *

Pinned through a REAL repository, because the previous wiring had + * seven mutations against it and not one of them landed here: with the + * scan reverted to the old "unavailable" placeholder this whole file + * stayed green. {@code Zeta} sorts after {@code Alpha} and neither + * references the other, so nothing but the scan can put it first.

+ */ + @Test + void sectionsAreOrderedByTheRealOutOfDiffFanIn(@TempDir Path dir) throws Exception { + bindScopeTo(repoWhereZetaIsCalledFromOutside(dir)); + context.reviewDiff = twoIndependentFilesDiff(); + + JsonValue result = callReviewScopeValue(scopeId(), "sections", null, + McpToolRouter.DEFAULT_SCOPE_BYTES); + + List sections = ((JsonArray) field(result, "sections")).elements(); + assertTrue(sections.size() >= 2, "the two pairs must not collapse into one section"); + assertTrue(hunkIdsOf(sections.get(0)).contains("h_src/Zeta.java_0"), + "the file called from outside the change is read FIRST; sections came out as " + + JsonWriter.write(new JsonArray(sections))); + } + + /** + * Fix round 1, item 4 (an amended ruling). Without a cache every {@code + * review_scope} call that asks for sections rebuilds the whole {@code + * ChangeGraph} AND spawns a fresh full-worktree {@code git grep} -- so an + * agent polling during a review runs one 30s-bounded grep per poll, + * concurrently with the board's own. + * + *

The graph-build count is the proxy for the whole computation: the + * scan is inside the same cached block, so a second call that rebuilds + * nothing greps nothing either.

+ */ + @Test + void aRepeatedSectionsReadIsServedFromTheCache() { + callReviewScope(scopeId(), "sections"); + callReviewScope(scopeId(), "sections"); + + assertEquals(1, graphBuilds(), + "a second read of the SAME diff must not recompute the grouping (nor re-grep for it)"); + } + + /** A genuinely new diff is a genuinely new answer; the cache is keyed, not blind. */ + @Test + void aNewDiffIsRecomputedRatherThanServedStale() { + callReviewScope(scopeId(), "sections"); + context.reviewDiff = twoIndependentFilesDiff(); + + String second = callReviewScope(scopeId(), "sections"); + + assertEquals(2, graphBuilds(), "a different diff must be regrouped"); + assertTrue(second.contains("src/Zeta.java"), "and the answer must describe THAT diff: " + second); + } + + // ---- fixtures ----------------------------------------------------------- + + private static List hunkIdsOf(JsonValue section) { + return ((JsonArray) field(section, "hunkIds")).elements().stream() + .map(id -> ((JsonValue.JsonString) id).value()) + .toList(); + } + + /** + * Two independent PAIRS -- {@code Alpha} with its user, {@code Zeta} with + * its -- so {@code Sections} has real edges to work from and produces two + * sections rather than falling back to one (kind, directory) cluster. + * Nothing connects the two pairs, and neither head has any in-diff + * advantage over the other, so the fan-in scan is the ONLY thing that can + * decide which is read first: without it {@code ReadingPath}'s tie-breaks + * end at the path, which puts {@code Alpha} there. + */ + private static UnifiedDiff twoIndependentFilesDiff() { + return new UnifiedDiff(List.of( + oneFile("src/Alpha.java", "class AlphaOnly { }"), + oneFile("src/AlphaUser.java", "class AlphaUser { void a() { new AlphaOnly(); } }"), + oneFile("src/Zeta.java", "class ZetaSym { }"), + oneFile("src/ZetaUser.java", "class ZetaUser { void z() { new ZetaSym(); } }"))); + } + + /** A committed repository whose only out-of-diff file calls {@code ZetaSym}. */ + private static Path repoWhereZetaIsCalledFromOutside(Path parent) throws Exception { + Path repo = Files.createDirectories(parent.resolve("repo")); + Files.createDirectories(repo.resolve("src")); + Files.writeString(repo.resolve("src/Alpha.java"), "class AlphaOnly { }\n"); + Files.writeString(repo.resolve("src/Zeta.java"), "class ZetaSym { }\n"); + Files.writeString(repo.resolve("src/Outside.java"), "void a() { new ZetaSym(); }\n"); + runGit(repo, "init", "-b", "main"); + runGit(repo, "config", "user.name", "Test"); + runGit(repo, "config", "user.email", "test@example.com"); + runGit(repo, "add", "-A"); + runGit(repo, "commit", "-m", "seed"); + return repo; + } + + private static void runGit(Path repo, String... args) throws Exception { + List command = new ArrayList<>(List.of("git")); + command.addAll(List.of(args)); + Process process = new ProcessBuilder(command).directory(repo.toFile()) + .redirectErrorStream(true).start(); + String output = new String(process.getInputStream().readAllBytes()); + if (process.waitFor() != 0) { + throw new IllegalStateException("git " + String.join(" ", args) + ": " + output); + } + } + + /** + * One shared foundation file plus {@code count} independent files that + * each reference it -- the shared-file overlap spec §5.6 describes: + * {@code Shared} is not one file among many, it is the foundation + * REPEATED in every one of the {@code count} sections that needs it, so + * the payload scales with {@code count}, not with the file count (which + * is only {@code count + 1}). + */ + private static UnifiedDiff manySectionsSharingAFoundationDiff(int count) { + List files = new ArrayList<>(); + files.add(oneFile("src/Shared.java", + "public class Shared {", + " static int value() { return 1; }", + "}")); + for (int i = 0; i < count; i++) { + String name = "User" + i; + files.add(oneFile("src/" + name + ".java", + "public class " + name + " {", + " void use() {", + " int v = Shared.value();", + " }", + "}")); + } + return new UnifiedDiff(files); + } + + private static UnifiedDiff.FileDiff oneFile(String path, String... added) { + List lines = new ArrayList<>(); + int n = 1; + for (String text : added) { + lines.add(new UnifiedDiff.Line(UnifiedDiff.Line.Kind.ADD, OptionalInt.empty(), OptionalInt.of(n++), text)); + } + return new UnifiedDiff.FileDiff(path, "A", added.length, 0, false, false, + List.of(new UnifiedDiff.Hunk("@@", lines))); + } +} diff --git a/app/src/test/java/app/drydock/mcp/ReviewIntentReadsTest.java b/app/src/test/java/app/drydock/mcp/ReviewIntentReadsTest.java new file mode 100644 index 00000000..dc88baa7 --- /dev/null +++ b/app/src/test/java/app/drydock/mcp/ReviewIntentReadsTest.java @@ -0,0 +1,275 @@ +package app.drydock.mcp; + +import app.drydock.git.UnifiedDiff; +import app.drydock.review.IntentGrouping; +import app.drydock.review.ReviewIntent; +import app.drydock.state.json.JsonParser; +import app.drydock.state.json.JsonValue; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The agent asserts, drydock renders the assertion and never verifies it -- + * the ReviewIntent.Collapse precedent (spec §8). With reads present the + * rail's order is the agent's declared dependency order; without it, the + * agent's array order stands. + */ +class ReviewIntentReadsTest { + + @Test + void readsOrdersTheRailFoundationFirst() { + IntentGrouping grouping = new IntentGrouping(); + grouping.set("scope-1", List.of( + intent("uses-it", "Crash-protected resolve()", List.of("the-guard")), + intent("the-guard", "JmpCtxScope guard", List.of()))); + + assertEquals(List.of("JmpCtxScope guard", "Crash-protected resolve()"), + titles(grouping)); + } + + /** + * Three intents, so the order cannot be right by accident: a two-node + * graph comes out the same under most rules, and a chain also pins that + * a transitive dependent lands after BOTH of the things beneath it + * rather than merely after the one it names. + */ + @Test + void readsOrdersAWholeChainAndNotJustTheOnePairItNames() { + IntentGrouping grouping = new IntentGrouping(); + grouping.set("scope-1", List.of( + intent("uses-it", "Crash-protected resolve()", List.of("the-guard")), + intent("on-top", "Resolver cache", List.of("uses-it")), + intent("the-guard", "JmpCtxScope guard", List.of()))); + + assertEquals(List.of("JmpCtxScope guard", "Crash-protected resolve()", "Resolver cache"), + titles(grouping)); + } + + @Test + void withoutReadsTheAgentsArrayOrderStands() { + IntentGrouping grouping = new IntentGrouping(); + grouping.set("scope-1", List.of( + intent("b", "Second", List.of()), intent("a", "First", List.of()))); + + assertEquals(List.of("Second", "First"), titles(grouping)); + } + + /** + * With SOME intents declaring reads the graph path runs for all of them, + * so the ones that declared nothing must still come out in the order the + * agent listed them -- the array order is the tie-break, not a fallback + * that only applies when nothing at all declares anything. + */ + @Test + void intentsThatDeclareNothingKeepTheirArrayOrderAmongThemselves() { + IntentGrouping grouping = new IntentGrouping(); + grouping.set("scope-1", List.of( + intent("z", "Zulu", List.of()), + intent("y", "Yankee", List.of()), + intent("x", "X-ray", List.of("w")), + intent("w", "Whiskey", List.of()))); + + // X-ray drops behind Whiskey because it says it is built on it; Zulu, + // Yankee and Whiskey, which say nothing about each other, stay in the + // order they arrived in rather than being resorted by id or title. + assertEquals(List.of("Zulu", "Yankee", "Whiskey", "X-ray"), titles(grouping)); + } + + /** A cycle among asserted dependencies is named, not broken silently. */ + @Test + void aReadsCycleIsKeptTogetherRatherThanBrokenArbitrarily() { + IntentGrouping grouping = new IntentGrouping(); + grouping.set("scope-1", List.of( + intent("a", "A", List.of("b")), intent("b", "B", List.of("a")))); + + assertEquals(2, grouping.intentsFor("scope-1", emptyDiff(), Optional.empty()).size()); + } + + /** + * A cycle does NOT reject the batch (controller ruling 3): entangled work + * is a thing an agent may honestly describe. Its members come back as one + * unit in tie-break order, and whatever depends on the unit lands after + * ALL of it -- with a fourth intent present so a cycle that was quietly + * ignored instead of collapsed would give a different answer. + */ + @Test + void aReadsCycleIsOrderedAsOneUnitAndKeepsTheBatch() { + IntentGrouping grouping = new IntentGrouping(); + grouping.set("scope-1", List.of( + intent("dependent", "Built on both", List.of("a")), + intent("a", "Tangled A", List.of("b")), + intent("b", "Tangled B", List.of("a")), + intent("loner", "Unrelated", List.of()))); + + assertEquals(List.of("Tangled A", "Tangled B", "Built on both", "Unrelated"), + titles(grouping)); + } + + /** Numbering stays dense 1..N over the order reads produced, not the array order. */ + @Test + void theRailIsRenumberedOverTheReadsOrder() { + IntentGrouping grouping = new IntentGrouping(); + grouping.set("scope-1", List.of( + intent("uses-it", "Crash-protected resolve()", List.of("the-guard")), + intent("the-guard", "JmpCtxScope guard", List.of()))); + + List intents = grouping.intentsFor("scope-1", emptyDiff(), Optional.empty()); + assertEquals(1, intents.get(0).number()); + assertEquals("the-guard", intents.get(0).id()); + assertEquals(2, intents.get(1).number()); + // The declaration survives the renumbering, so the rail can still say + // what the agent asserted about this card. + assertEquals(List.of("the-guard"), intents.get(1).reads()); + } + + /** + * A batch is all-or-nothing, so a reads naming nothing is rejected whole. + * + *

{@code parse} is the fixture's JSON helper -- the same + * {@code JsonParser.parse(String)} the other codec tests use.

+ */ + @Test + void readsNamingAnUnknownIntentRejectsTheBatch() { + McpToolException thrown = assertThrows(McpToolException.class, + () -> ReviewToolCodec.intentsFromJson(parse(""" + [{"id":"a","title":"A","hunkIds":[],"reads":["nonexistent"]}] + """))); + + // The agent has to know WHICH declaration to fix, not merely that one + // of them is wrong. + assertTrue(thrown.getMessage().contains("nonexistent"), thrown.getMessage()); + assertTrue(thrown.getMessage().contains("'a'"), thrown.getMessage()); + } + + /** A reads may name an intent declared LATER in the same array. */ + @Test + void readsMayNameAnIntentThatComesLaterInTheBatch() throws Exception { + List intents = ReviewToolCodec.intentsFromJson(parse(""" + [{"id":"a","title":"A","hunkIds":[],"reads":["b"]}, + {"id":"b","title":"B","hunkIds":[]}] + """)); + + assertEquals(List.of("b"), intents.get(0).reads()); + assertEquals(List.of(), intents.get(1).reads()); + } + + /** + * A reads that is not an array of strings is rejected, not read as an + * empty list. Every shape here would otherwise decode as "declared + * nothing" and put the rail in the exact reverse of the asserted order, + * with no diagnostic anywhere and nothing echoed back to notice it by -- + * the bare string most of all, which is simply one dependency written + * without the brackets. + */ + @ParameterizedTest + @ValueSource(strings = { + "\"the-guard\"", // one dependency, no brackets + "{\"0\":\"the-guard\"}", // an object rather than an array + "[7]", // an array of the wrong element type + "[\"the-guard\",5]", // one good entry, one not + "[null]", // a null where an id belongs + }) + void aMalformedReadsRejectsTheBatchRatherThanDecodingToNothing(String malformed) { + McpToolException thrown = assertThrows(McpToolException.class, + () -> ReviewToolCodec.intentsFromJson(parse(""" + [{"id":"uses-it","title":"A","hunkIds":[],"reads":%s}, + {"id":"the-guard","title":"B","hunkIds":[]}] + """.formatted(malformed)))); + + assertTrue(thrown.getMessage().contains("'uses-it'"), thrown.getMessage()); + assertTrue(thrown.getMessage().contains("reads"), thrown.getMessage()); + } + + /** + * An explicit null is absent, not broken -- it is how several clients + * spell an omitted optional field, and refusing a batch over it would + * reject a grouping that declared nothing wrong. + */ + @Test + void anExplicitNullReadsIsTheSameAsNoReadsAtAll() throws Exception { + List intents = ReviewToolCodec.intentsFromJson(parse(""" + [{"id":"a","title":"A","hunkIds":[],"reads":null}] + """)); + + assertEquals(List.of(), intents.get(0).reads()); + } + + /** + * The same grouping ordered twice comes out identical -- the branch's + * determinism bar (SectionDeterminismTest), which nothing covered for the + * reads path. + * + *

Twenty intents, ids ANTI-correlated with array position: {@code i19} + * arrives first and {@code i00} last, while the declared chain makes the + * only correct order {@code i00..i19}. A fixture where array order and + * the right answer agree cannot tell a stable ordering from no ordering + * at all, and one small enough to come out right by accident cannot tell + * either.

+ */ + @Test + void theSameGroupingOrdersIdenticallyEveryTime() { + List supplied = new ArrayList<>(); + for (int n = CHAIN_LENGTH - 1; n >= 0; n--) { + supplied.add(intent(chainId(n), "Intent " + n, + n == 0 ? List.of() : List.of(chainId(n - 1)))); + } + List foundationFirst = new ArrayList<>(); + for (int n = 0; n < CHAIN_LENGTH; n++) { + foundationFirst.add(chainId(n)); + } + + IntentGrouping grouping = new IntentGrouping(); + grouping.set("scope-1", supplied); + List first = ids(grouping); + // Set AGAIN, on the same instance: a grouping is replaced in place far + // more often than a fresh one is built, and that is the path that + // could carry state from the previous ordering. + grouping.set("scope-1", supplied); + List second = ids(grouping); + IntentGrouping fresh = new IntentGrouping(); + fresh.set("scope-1", supplied); + + assertEquals(foundationFirst, first); + assertEquals(first, second); + assertEquals(first, ids(fresh)); + } + + private static final int CHAIN_LENGTH = 20; + + /** Fixed width, so id order and array order stay genuinely opposed. */ + private static String chainId(int n) { + return "i%02d".formatted(n); + } + + private static List ids(IntentGrouping grouping) { + return grouping.intentsFor("scope-1", emptyDiff(), Optional.empty()) + .stream().map(ReviewIntent::id).toList(); + } + + private static List titles(IntentGrouping grouping) { + return grouping.intentsFor("scope-1", emptyDiff(), Optional.empty()) + .stream().map(ReviewIntent::title).toList(); + } + + private static ReviewIntent intent(String id, String title, List reads) { + return new ReviewIntent(id, 0, title, ReviewIntent.Kind.CHANGE, ReviewIntent.Risk.NONE, + "", List.of(), Optional.empty(), false, reads); + } + + private static UnifiedDiff emptyDiff() { + return new UnifiedDiff(List.of()); + } + + private static JsonValue parse(String json) { + return JsonParser.parse(json); + } +} diff --git a/app/src/test/java/app/drydock/review/AnnotationStoreTest.java b/app/src/test/java/app/drydock/review/AnnotationStoreTest.java index 82386ead..be307d2c 100644 --- a/app/src/test/java/app/drydock/review/AnnotationStoreTest.java +++ b/app/src/test/java/app/drydock/review/AnnotationStoreTest.java @@ -101,7 +101,7 @@ void removingAScopeLeavesTheOtherScopesIntact(@TempDir Path dir) { store.upsert(finding("rs_left", "f1")); store.upsert(finding("rs_right", "f1")); store.putVerdict(new ReviewVerdict("rs_left", "i1", ReviewVerdict.Decision.APPROVED, - Optional.empty(), AT)); + Optional.empty(), AT, "base-1", "head-1")); store.removeScope("rs_left"); @@ -198,9 +198,9 @@ void forIntentFiltersWithinOneScope(@TempDir Path dir) { void verdictsAreKeyedByScopeAndIntent(@TempDir Path dir) { try (AnnotationStore store = new AnnotationStore(dir.resolve("annotations.json"))) { store.putVerdict(new ReviewVerdict("rs_a", "i1", ReviewVerdict.Decision.APPROVED, - Optional.empty(), AT)); + Optional.empty(), AT, "base-1", "head-1")); store.putVerdict(new ReviewVerdict("rs_b", "i1", ReviewVerdict.Decision.CHANGES, - Optional.of("needs a test"), AT)); + Optional.of("needs a test"), AT, "base-1", "head-1")); assertEquals(ReviewVerdict.Decision.APPROVED, store.verdict("rs_a", "i1").orElseThrow().decision()); @@ -213,9 +213,9 @@ void verdictsAreKeyedByScopeAndIntent(@TempDir Path dir) { void clearingAVerdictOnlyClearsThatOne(@TempDir Path dir) { try (AnnotationStore store = new AnnotationStore(dir.resolve("annotations.json"))) { store.putVerdict(new ReviewVerdict("rs_a", "i1", ReviewVerdict.Decision.APPROVED, - Optional.empty(), AT)); + Optional.empty(), AT, "base-1", "head-1")); store.putVerdict(new ReviewVerdict("rs_a", "i2", ReviewVerdict.Decision.APPROVED, - Optional.empty(), AT)); + Optional.empty(), AT, "base-1", "head-1")); store.clearVerdict("rs_a", "i1"); @@ -303,7 +303,7 @@ void anEntryWrittenBeforeGithubStateExistedStillDecodes() { @Test void verdictsAndSubmissionsRoundTripThroughJson() { ReviewVerdict verdict = new ReviewVerdict("rs_a", "i1", ReviewVerdict.Decision.CHANGES, - Optional.of("please add a test"), AT); + Optional.of("please add a test"), AT, "base-1", "head-1"); String json = JsonWriter.write(AnnotationStore.toJson(List.of(), List.of(verdict), List.of("rs_a"))); @@ -317,7 +317,7 @@ void everythingPersistsAcrossAReload(@TempDir Path dir) throws Exception { try (AnnotationStore store = new AnnotationStore(file)) { store.upsert(finding("rs_a", "f1")); store.putVerdict(new ReviewVerdict("rs_a", "i1", ReviewVerdict.Decision.APPROVED, - Optional.empty(), AT)); + Optional.empty(), AT, "base-1", "head-1")); store.markSubmitted("rs_a"); store.flushPendingSaves(); } diff --git a/app/src/test/java/app/drydock/review/AnnotationStoreVerdictKeyTest.java b/app/src/test/java/app/drydock/review/AnnotationStoreVerdictKeyTest.java new file mode 100644 index 00000000..bbba70ea --- /dev/null +++ b/app/src/test/java/app/drydock/review/AnnotationStoreVerdictKeyTest.java @@ -0,0 +1,92 @@ +package app.drydock.review; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verdicts are stored under a hunk's content, not under a grouping + * (spec §9.1). The round trip is what makes an approval outlive the process + * that recorded it, and the base/head it was given against has to survive + * with it or staleness cannot be derived on the next launch. + */ +class AnnotationStoreVerdictKeyTest { + + private static ReviewVerdict approved(String digest, String base) { + return new ReviewVerdict("scope-1", digest, ReviewVerdict.Decision.APPROVED, + Optional.of("looks right"), Instant.parse("2026-08-22T00:00:00Z"), base, "head-1"); + } + + @Test + void aVerdictRoundTripsThroughDiskWithItsBaseAndHead() throws IOException { + Path file = Files.createTempDirectory("drydock-verdicts").resolve("annotations.json"); + AnnotationStore store = new AnnotationStore(file); + store.putVerdict(approved("digest-a", "base-1")); + store.flushPendingSaves(); + + AnnotationStore reloaded = new AnnotationStore(file); + Optional read = reloaded.verdict("scope-1", "digest-a"); + + assertTrue(read.isPresent()); + assertEquals("base-1", read.get().baseCommit()); + assertEquals("head-1", read.get().headCommit()); + assertEquals(Optional.of("looks right"), read.get().note()); + assertEquals(ReviewVerdict.Decision.APPROVED, read.get().decision()); + } + + /** + * The property that makes overlapping sections possible (spec §5.6): one + * hunk shown in three sections is one digest, so it is one flag. + */ + @Test + void oneDigestIsOneFlagHoweverManySectionsShowIt() throws IOException { + Path file = Files.createTempDirectory("drydock-verdicts").resolve("annotations.json"); + AnnotationStore store = new AnnotationStore(file); + + store.putVerdict(approved("shared-digest", "base-1")); + + assertEquals(1, store.verdictsFor("scope-1").size()); + assertTrue(store.verdict("scope-1", "shared-digest").isPresent()); + } + + @Test + void clearingRemovesTheVerdictForThatDigestOnly() throws IOException { + Path file = Files.createTempDirectory("drydock-verdicts").resolve("annotations.json"); + AnnotationStore store = new AnnotationStore(file); + store.putVerdict(approved("digest-a", "base-1")); + store.putVerdict(approved("digest-b", "base-1")); + + store.clearVerdict("scope-1", "digest-a"); + + assertEquals(List.of("digest-b"), + store.verdictsFor("scope-1").stream().map(ReviewVerdict::hunkDigest).toList()); + } + + /** + * A v3 entry names an intentId and no digest. There are none in the wild + * (which is why no migration is written), but a file carrying one must + * be skipped rather than crash the load -- lenient decoding is the + * store's existing contract. + */ + @Test + void aPreDigestVerdictEntryIsSkippedNotFatal() throws IOException { + Path file = Files.createTempDirectory("drydock-verdicts").resolve("annotations.json"); + Files.writeString(file, """ + {"schemaVersion":3,"annotations":[],"submitted":[], + "verdicts":[{"scopeId":"scope-1","intentId":"auto:change:src", + "verdict":"approved","at":"2026-08-01T00:00:00Z"}]} + """); + + AnnotationStore store = new AnnotationStore(file); + + assertEquals(List.of(), store.verdictsFor("scope-1")); + } +} diff --git a/app/src/test/java/app/drydock/review/BaseMoveTest.java b/app/src/test/java/app/drydock/review/BaseMoveTest.java new file mode 100644 index 00000000..2d83e345 --- /dev/null +++ b/app/src/test/java/app/drydock/review/BaseMoveTest.java @@ -0,0 +1,73 @@ +package app.drydock.review; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.TreeSet; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Which base moves are worth telling the reviewer about (spec §9.2). + * Marking every verdict stale on any base move treats "main advanced in an + * unrelated subsystem" the same as "main rewrote a function this hunk + * calls", and on an active repository the first is nearly all of them -- + * which is how a confirm button becomes reflex. + * + *

{@code between} spawns git and is covered by the running-app pass; + * what is unit-tested here is the decision the spawn feeds.

+ */ +class BaseMoveTest { + + private static BaseMove.Delta delta(String... files) { + return new BaseMove.Delta(false, new TreeSet<>(List.of(files))); + } + + @Test + void aBaseMoveTouchingOnlyUnrelatedFilesCannotMatter() { + assertFalse(BaseMove.couldMatter(delta("docs/README.md", "web/app.ts"), + List.of("src/guards.cpp", "src/guards.h"))); + } + + @Test + void aBaseMoveTouchingAFileThisScopeChangesMatters() { + assertTrue(BaseMove.couldMatter(delta("docs/README.md", "src/guards.h"), + List.of("src/guards.cpp", "src/guards.h"))); + } + + /** + * Failing safe is the only defensible default for a signal about what was + * read: if the old base cannot be resolved -- a force-push, a collected + * commit -- everything is a candidate. + */ + @Test + void anUnresolvableOldBaseMattersRegardlessOfFiles() { + assertTrue(BaseMove.couldMatter(new BaseMove.Delta(true, new TreeSet<>()), + List.of("src/guards.cpp"))); + } + + @Test + void anEmptyDeltaCannotMatter() { + assertFalse(BaseMove.couldMatter(delta(), List.of("src/guards.cpp"))); + } + + /** A scope with no files is not a reason to mark anything. */ + @Test + void aScopeWithNoFilesCannotBeAffected() { + assertFalse(BaseMove.couldMatter(delta("src/guards.h"), List.of())); + } + + /** + * Parsing of git diff --name-only -z output with non-ASCII filenames + * must produce them intact without C-style quoting. + */ + @Test + void parseNamesHandlesNonAsciiFilenames() { + var parsed = BaseMove.parseNames("café.txt\0docs/résumé.md\0src/file.java\0"); + var expected = new TreeSet<>(List.of("café.txt", "docs/résumé.md", "src/file.java")); + var result = new TreeSet<>(parsed); + assertTrue(result.equals(expected), + "Expected " + expected + " but got " + result); + } +} diff --git a/app/src/test/java/app/drydock/review/ChangeGraphTest.java b/app/src/test/java/app/drydock/review/ChangeGraphTest.java new file mode 100644 index 00000000..0670b7f1 --- /dev/null +++ b/app/src/test/java/app/drydock/review/ChangeGraphTest.java @@ -0,0 +1,199 @@ +package app.drydock.review; + +import app.drydock.git.UnifiedDiff; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.OptionalInt; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The one matching rule (spec §4.2): a use resolves to a declaration only + * when EXACTLY ONE changed declaration in the scope carries that name, and + * only across files. Ambiguous names mint nothing -- a false edge sends a + * reviewer to unrelated code and is worse than a missing one -- and + * intra-file edges are noise from short-name matching. + */ +class ChangeGraphTest { + + private static UnifiedDiff.FileDiff file(String path, String... added) { + List lines = new ArrayList<>(); + int n = 1; + for (String text : added) { + lines.add(new UnifiedDiff.Line(UnifiedDiff.Line.Kind.ADD, + OptionalInt.empty(), OptionalInt.of(n++), text)); + } + return new UnifiedDiff.FileDiff(path, "M", added.length, 0, false, false, + List.of(new UnifiedDiff.Hunk("@@", lines))); + } + + @Test + void aUniqueDeclarationUsedInAnotherFileMintsAnEdge() { + ChangeGraph graph = ChangeGraph.of(new UnifiedDiff(List.of( + file("src/Guards.java", "class JmpCtxScope { }"), + file("src/Profiler.java", "void go() { new JmpCtxScope(); }")))); + + assertTrue(graph.filesReferencedBy("src/Profiler.java").contains("src/Guards.java")); + assertTrue(graph.filesReferencing("src/Guards.java").contains("src/Profiler.java")); + } + + /** Two declarations of one name cannot be told apart, so neither is linked. */ + @Test + void anAmbiguousNameMintsNoEdge() { + ChangeGraph graph = ChangeGraph.of(new UnifiedDiff(List.of( + file("src/A.java", "class Helper { }"), + file("src/B.java", "class Helper { }"), + file("src/C.java", "void go() { new Helper(); }")))); + + assertEquals(List.of(), List.copyOf(graph.filesReferencedBy("src/C.java"))); + } + + @Test + void aReferenceWithinOneFileMintsNoEdge() { + ChangeGraph graph = ChangeGraph.of(new UnifiedDiff(List.of( + file("src/Guards.java", "class JmpCtxScope { }", "void go() { new JmpCtxScope(); }")))); + + assertEquals(List.of(), List.copyOf(graph.filesReferencedBy("src/Guards.java"))); + } + + @Test + void aDeclarationIsFoundByName() { + ChangeGraph graph = ChangeGraph.of(new UnifiedDiff(List.of( + file("src/Guards.java", "class JmpCtxScope { }")))); + + assertEquals(Optional.of("src/Guards.java"), graph.fileDeclaring("JmpCtxScope")); + assertTrue(graph.declarationsIn("src/Guards.java").contains("JmpCtxScope")); + assertTrue(graph.changedDeclarations().contains("JmpCtxScope")); + } + + /** + * A use counts wherever it sits in the diff window, not only on a + * changed line. The node set is already restricted to changed files, so + * this cannot pull in unrelated code -- it only connects files already + * under review together, and "the declaration's behaviour changed + * without touching most of its call sites" is the coupling this graph + * exists to surface. Requiring the use itself to be edited too would + * split that section for edge purity the node-set restriction already + * gives for free. + */ + @Test + void aContextLineUseStillMintsAnEdge() { + List profilerLines = List.of( + new UnifiedDiff.Line(UnifiedDiff.Line.Kind.CONTEXT, + OptionalInt.of(1), OptionalInt.of(1), "JmpCtxScope local;"), + new UnifiedDiff.Line(UnifiedDiff.Line.Kind.ADD, + OptionalInt.empty(), OptionalInt.of(2), "int unrelatedEdit = 1;")); + UnifiedDiff.FileDiff profiler = new UnifiedDiff.FileDiff("src/Profiler.java", "M", 1, 0, + false, false, List.of(new UnifiedDiff.Hunk("@@", profilerLines))); + + ChangeGraph graph = ChangeGraph.of(new UnifiedDiff(List.of( + file("src/Guards.java", "class JmpCtxScope { }"), + profiler))); + + assertTrue(graph.filesReferencedBy("src/Profiler.java").contains("src/Guards.java")); + } + + /** + * Fan-in per SYMBOL, which is a different question from fan-in per file: + * a file declaring several changed names has one file-level fan-in and + * its names have their own. Anything asking which symbol a group of + * files is ABOUT has to ask this one, or it answers with whichever name + * sorted first. + */ + @Test + void fanInIsCountedPerSymbolNotPerDeclaringFile() { + ChangeGraph graph = ChangeGraph.of(new UnifiedDiff(List.of( + file("src/Core.java", "class AaaHelper { }", "class ZzzEngine { }"), + file("src/One.java", "void one() { new ZzzEngine(); }"), + file("src/Two.java", "void two() { new ZzzEngine(); }")))); + + assertEquals(List.of("src/One.java", "src/Two.java"), + List.copyOf(graph.filesReferencingSymbol("ZzzEngine"))); + assertEquals(List.of(), List.copyOf(graph.filesReferencingSymbol("AaaHelper"))); + assertEquals(List.of(), List.copyOf(graph.filesReferencingSymbol("NeverSeen"))); + // The file both names live in has the union as ITS fan-in, which is + // exactly why it cannot stand in for either name's. + assertEquals(List.of("src/One.java", "src/Two.java"), + List.copyOf(graph.filesReferencing("src/Core.java"))); + } + + // ---- the hunk-level view ------------------------------------------------ + + private static UnifiedDiff.FileDiff multiHunk(String path, String... oneLinePerHunk) { + List hunks = new ArrayList<>(); + int n = 1; + for (String text : oneLinePerHunk) { + hunks.add(new UnifiedDiff.Hunk("@@", + List.of(new UnifiedDiff.Line(UnifiedDiff.Line.Kind.ADD, + OptionalInt.empty(), OptionalInt.of(n), text)))); + n += 20; + } + return new UnifiedDiff.FileDiff(path, "M", hunks.size(), 0, false, false, hunks); + } + + /** + * The reference belongs to the hunk that makes it, not to every hunk of + * the file. A marker under a hunk that references nothing is a false + * statement about that hunk. + */ + @Test + void aReferenceBelongsToTheHunkThatMakesIt() { + ChangeGraph graph = ChangeGraph.of(new UnifiedDiff(List.of( + file("src/guards.cpp", "class JmpCtxScope { };"), + multiHunk("src/big.cpp", + "void one() { new JmpCtxScope(); }", + "void two() { }", + "void three() { }")))); + + assertEquals(List.of("JmpCtxScope"), + List.copyOf(graph.referencesIn(new ChangeGraph.Hunk("src/big.cpp", 0)))); + assertEquals(List.of(), + List.copyOf(graph.referencesIn(new ChangeGraph.Hunk("src/big.cpp", 1)))); + assertEquals(List.of(), + List.copyOf(graph.referencesIn(new ChangeGraph.Hunk("src/big.cpp", 2)))); + // The file-level answer is unchanged, and is the union. + assertEquals(List.of("src/guards.cpp"), + List.copyOf(graph.filesReferencedBy("src/big.cpp"))); + } + + @Test + void aDeclarationBelongsToTheHunkThatMakesIt() { + ChangeGraph graph = ChangeGraph.of(new UnifiedDiff(List.of( + multiHunk("src/guards.cpp", "class Alpha { };", "class Beta { };"), + file("src/use.cpp", "void go() { new Beta(); }")))); + + assertEquals(List.of("Alpha"), + List.copyOf(graph.declarationsIn(new ChangeGraph.Hunk("src/guards.cpp", 0)))); + assertEquals(List.of("Beta"), + List.copyOf(graph.declarationsIn(new ChangeGraph.Hunk("src/guards.cpp", 1)))); + assertEquals(List.of(new ChangeGraph.Hunk("src/guards.cpp", 1)), + List.copyOf(graph.hunksDeclaring("Beta"))); + assertEquals(List.of(new ChangeGraph.Hunk("src/use.cpp", 0)), + List.copyOf(graph.hunksReferencingSymbol("Beta"))); + } + + /** An intra-file use is noise at either granularity, by the same rule. */ + @Test + void theHunkViewIsCrossFileToo() { + ChangeGraph graph = ChangeGraph.of(new UnifiedDiff(List.of( + multiHunk("src/solo.cpp", "class Solo { };", "void use() { new Solo(); }")))); + + assertEquals(List.of(), + List.copyOf(graph.referencesIn(new ChangeGraph.Hunk("src/solo.cpp", 1)))); + assertEquals(List.of(), List.copyOf(graph.hunksReferencingSymbol("Solo"))); + } + + /** Determinism: iteration order is a property this graph must keep (spec §9.5). */ + @Test + void everyExposedCollectionIsSorted() { + ChangeGraph graph = ChangeGraph.of(new UnifiedDiff(List.of( + file("src/Z.java", "class Zed { }"), + file("src/A.java", "void go() { new Zed(); }")))); + + assertEquals(List.of("src/A.java", "src/Z.java"), List.copyOf(graph.files())); + } +} diff --git a/app/src/test/java/app/drydock/review/GrammarRegistryTest.java b/app/src/test/java/app/drydock/review/GrammarRegistryTest.java new file mode 100644 index 00000000..c15f6cc9 --- /dev/null +++ b/app/src/test/java/app/drydock/review/GrammarRegistryTest.java @@ -0,0 +1,77 @@ +package app.drydock.review; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * A grammar that is not on the classpath is the lexical path, not an error + * (spec §10.2). That single rule is what keeps the shipped language set a + * packaging decision rather than an architectural one -- the .app and the + * jbang jar may ship different sets, and an unsupported language produces a + * coarser surface rather than a broken one. + */ +class GrammarRegistryTest { + + @Test + void aShippedLanguageResolvesToAGrammar() { + assertTrue(GrammarRegistry.forPath("src/Main.java").isPresent()); + } + + @Test + void anUnshippedLanguageResolvesToNothingWithoutThrowing() { + assertTrue(GrammarRegistry.forPath("build/config.zig").isEmpty()); + } + + @Test + void aFileWithNoExtensionResolvesToNothing() { + assertTrue(GrammarRegistry.forPath("Makefile").isEmpty()); + } + + /** Case is not a language: .JAVA is Java. */ + @Test + void extensionMatchingIsCaseInsensitive() { + assertTrue(GrammarRegistry.forPath("src/Main.JAVA").isPresent()); + } + + @Test + void aDirectoryEndingInAKnownExtensionIsNotAFile() { + assertFalse(GrammarRegistry.forPath("vendor/foo.java/").isPresent()); + } + + /** + * A name-match check against the artifact catalog is not enough -- a + * class can exist and still fail reflectively (no no-arg constructor, a + * visibility change). Exercise every shipped extension end-to-end so a + * broken entry fails here, loudly and locally, rather than silently at + * the next dependency bump. + */ + @ParameterizedTest + @ValueSource(strings = { + "java", "kt", "kts", "py", "js", "mjs", "ts", "tsx", + "go", "rs", "c", "h", "cc", "cpp", "hpp" + }) + void everyShippedExtensionResolvesToAGrammar(String extension) { + assertTrue(GrammarRegistry.forPath("Example." + extension).isPresent()); + } + + /** + * A per-class reflective-shape failure must not touch the global + * native-availability latch. Loading all nine grammar classes above and + * still finding the native library available is what proves a single + * broken class cannot take every language down with it. + */ + @Test + void nativeStaysAvailableAfterLoadingEveryGrammar() { + for (String extension : new String[] { + "java", "kt", "kts", "py", "js", "mjs", "ts", "tsx", + "go", "rs", "c", "h", "cc", "cpp", "hpp" + }) { + GrammarRegistry.forPath("Example." + extension); + } + assertTrue(GrammarRegistry.nativeAvailable()); + } +} diff --git a/app/src/test/java/app/drydock/review/GraphsTest.java b/app/src/test/java/app/drydock/review/GraphsTest.java new file mode 100644 index 00000000..7c965d76 --- /dev/null +++ b/app/src/test/java/app/drydock/review/GraphsTest.java @@ -0,0 +1,151 @@ +package app.drydock.review; + +import org.junit.jupiter.api.Test; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.SortedSet; +import java.util.TreeSet; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Order and cycles (spec §6.1). Foundation first: if A is referenced by B, + * A is read before B. A cycle is collapsed into one named unit rather than + * broken arbitrarily -- a cycle among changed symbols is a fact about the + * change worth showing, and a silent arbitrary break is the unexplained + * ordering this whole feature exists to remove. + */ +class GraphsTest { + + private static SortedSet set(String... values) { + return new TreeSet<>(List.of(values)); + } + + private static List> order(Map> dependsOn) { + return Graphs.topologicalOrder(new TreeSet<>(dependsOn.keySet()), + node -> dependsOn.getOrDefault(node, new TreeSet<>()), + Comparator.naturalOrder()); + } + + @Test + void aDependencyIsReadBeforeItsDependent() { + assertEquals(List.of(List.of("guards"), List.of("profiler")), + order(Map.of("profiler", set("guards"), "guards", set()))); + } + + @Test + void independentNodesFallBackToTheTieBreak() { + assertEquals(List.of(List.of("a"), List.of("b"), List.of("c")), + order(Map.of("c", set(), "a", set(), "b", set()))); + } + + @Test + void aCycleBecomesOneUnitHoldingItsMembers() { + List> result = order(Map.of("a", set("b"), "b", set("a"), "c", set("a"))); + + assertEquals(List.of("a", "b"), result.get(0)); + assertEquals(List.of("c"), result.get(1)); + } + + /** + * Determinism, pinned: the same graph presented in a different insertion + * order must produce the identical result (spec §9.5). + */ + @Test + void theOrderDoesNotDependOnInsertionOrder() { + assertEquals(order(Map.of("a", set(), "b", set("a"), "c", set("b"))), + order(Map.of("c", set("b"), "a", set(), "b", set("a")))); + } + + @Test + void anEmptyGraphOrdersToNothing() { + assertEquals(List.of(), order(Map.of())); + } + + // --- The cases below are hand-computed to pin down the iterative + // Tarjan and Kahn condensation: each comment traces the expected order + // step by step rather than just stating it, since that reasoning is + // what would need re-doing if this algorithm is ever touched again. --- + + @Test + void aChainOrdersFoundationFirst() { + // a -> b -> c (a depends on b, b depends on c): c, b, a. + assertEquals(List.of(List.of("c"), List.of("b"), List.of("a")), + order(Map.of("a", set("b"), "b", set("c"), "c", set()))); + } + + @Test + void aThreeNodeCycleCollapsesToOneUnitOrderedByTieBreak() { + // a -> b -> c -> a, a genuine 3-cycle with no other nodes. + List> result = order(Map.of("a", set("b"), "b", set("c"), "c", set("a"))); + + assertEquals(1, result.size()); + assertEquals(List.of("a", "b", "c"), result.get(0)); + } + + @Test + void aCycleWithANodeHangingOffItKeepsTheHangerSeparate() { + // a <-> b is the cycle; c depends on b but nothing depends on c, and + // c is not part of the cycle, so it must be its own trailing unit. + List> result = order(Map.of("a", set("b"), "b", set("a"), "c", set("b"))); + + assertEquals(List.of(List.of("a", "b"), List.of("c")), result); + } + + @Test + void twoDisjointComponentsBothAppearOrderedByTheTieBreak() { + // x -> y and w -> z: two independent chains, unrelated to each + // other. Tracing Kahn's ready set step by step (not just the two + // chains in isolation) is what this case is for: "y" and "z" are + // both foundation nodes, so both are ready first, and "y" < "z" + // picks y. Removing y frees x, so the ready set is now {x, z}, and + // "x" < "z" picks x next -- interleaving the two chains rather than + // draining one chain before starting the other. Then z, then w. + List> result = order(Map.of( + "y", set(), "x", set("y"), + "z", set(), "w", set("z"))); + + assertEquals(List.of(List.of("y"), List.of("x"), List.of("z"), List.of("w")), result); + } + + @Test + void aSelfLoopIsItsOwnSingletonUnit() { + // Not producible by ChangeGraph -- filesReferencedBy never includes + // the file itself -- but Graphs must not corrupt on one anyway. + List> result = order(Map.of("a", set("a"), "b", set("a"))); + + assertEquals(List.of(List.of("a"), List.of("b")), result); + } + + @Test + void aDiamondOrdersTheSharedBaseFirstWithoutMergingLowLinksWrongly() { + // top depends on both left and right, each of which depends on + // base. This is the classic case that catches a wrong low-link + // merge: left and right must NOT be folded into one SCC with base. + List> result = order(Map.of( + "top", set("left", "right"), + "left", set("base"), + "right", set("base"), + "base", set())); + + assertEquals(List.of( + List.of("base"), List.of("left"), List.of("right"), List.of("top")), result); + } + + @Test + void aDependencyOutsideTheNodeSetIsRejectedRatherThanSilentlyDropped() { + // "b" depends on "ghost", which never appears in nodes. Silently + // dropping this edge would make "no such dependency" and "a + // dependency on a node the caller forgot to include" produce the + // same output, so Graphs throws instead of guessing. + IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, + () -> order(Map.of("a", set(), "b", set("a", "ghost")))); + + assertTrue(thrown.getMessage().contains("ghost"), + "expected the phantom node's name in the message, got: " + thrown.getMessage()); + } +} diff --git a/app/src/test/java/app/drydock/review/HunkDigestTest.java b/app/src/test/java/app/drydock/review/HunkDigestTest.java new file mode 100644 index 00000000..437058ef --- /dev/null +++ b/app/src/test/java/app/drydock/review/HunkDigestTest.java @@ -0,0 +1,98 @@ +package app.drydock.review; + +import app.drydock.git.UnifiedDiff; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Locale; +import java.util.OptionalInt; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +/** + * What an approval is pinned to (spec §9.2). A digest that ignores context + * lets an approval stand over code whose surroundings moved; a digest that + * covers the whole file re-reviews hunks nobody touched. These tests pin + * both edges of that window. + */ +class HunkDigestTest { + + private static UnifiedDiff.Line ctx(int line, String text) { + return new UnifiedDiff.Line(UnifiedDiff.Line.Kind.CONTEXT, + OptionalInt.of(line), OptionalInt.of(line), text); + } + + private static UnifiedDiff.Line add(int line, String text) { + return new UnifiedDiff.Line(UnifiedDiff.Line.Kind.ADD, + OptionalInt.empty(), OptionalInt.of(line), text); + } + + private static UnifiedDiff.Hunk hunk(List lines) { + return new UnifiedDiff.Hunk("@@ -1,3 +1,4 @@", lines); + } + + @Test + void theSameContentInTheSamePathDigestsIdentically() { + UnifiedDiff.Hunk left = hunk(List.of(ctx(1, "int a;"), add(2, "int b;"))); + UnifiedDiff.Hunk right = hunk(List.of(ctx(1, "int a;"), add(2, "int b;"))); + + assertEquals(HunkDigest.of("src/a.c", left), HunkDigest.of("src/a.c", right)); + } + + /** A hunk that only moved is the same code, and stays approved. */ + @Test + void movingAHunkWithoutChangingItKeepsTheDigest() { + UnifiedDiff.Hunk before = hunk(List.of(ctx(1, "int a;"), add(2, "int b;"))); + UnifiedDiff.Hunk after = hunk(List.of(ctx(41, "int a;"), add(42, "int b;"))); + + assertEquals(HunkDigest.of("src/a.c", before), HunkDigest.of("src/a.c", after)); + } + + /** + * The reason context is in the digest: a hunk means what it means in + * place, so an edit to the line above it must unsettle the approval even + * though the changed lines are byte-identical. + */ + @Test + void changingOnlyAContextLineChangesTheDigest() { + UnifiedDiff.Hunk before = hunk(List.of(ctx(1, "int a;"), add(2, "int b;"))); + UnifiedDiff.Hunk after = hunk(List.of(ctx(1, "long a;"), add(2, "int b;"))); + + assertNotEquals(HunkDigest.of("src/a.c", before), HunkDigest.of("src/a.c", after)); + } + + @Test + void changingAChangedLineChangesTheDigest() { + UnifiedDiff.Hunk before = hunk(List.of(ctx(1, "int a;"), add(2, "int b;"))); + UnifiedDiff.Hunk after = hunk(List.of(ctx(1, "int a;"), add(2, "int c;"))); + + assertNotEquals(HunkDigest.of("src/a.c", before), HunkDigest.of("src/a.c", after)); + } + + /** Identical hunks in two files are two different things to approve. */ + @Test + void thePathIsPartOfTheIdentity() { + UnifiedDiff.Hunk both = hunk(List.of(ctx(1, "int a;"), add(2, "int b;"))); + + assertNotEquals(HunkDigest.of("src/a.c", both), HunkDigest.of("src/b.c", both)); + } + + /** The line's KIND matters: an added line and a deleted one are not the same review. */ + @Test + void addAndDeleteOfTheSameTextDigestDifferently() { + UnifiedDiff.Hunk added = hunk(List.of(add(1, "int b;"))); + UnifiedDiff.Hunk deleted = hunk(List.of(new UnifiedDiff.Line( + UnifiedDiff.Line.Kind.DEL, OptionalInt.of(1), OptionalInt.empty(), "int b;"))); + + assertNotEquals(HunkDigest.of("src/a.c", added), HunkDigest.of("src/a.c", deleted)); + } + + @Test + void theDigestIsLowercaseHexOfFixedWidth() { + String digest = HunkDigest.of("src/a.c", hunk(List.of(add(1, "x")))); + + assertEquals(64, digest.length()); + assertEquals(digest.toLowerCase(Locale.ROOT), digest); + } +} diff --git a/app/src/test/java/app/drydock/review/IntentGroupingTest.java b/app/src/test/java/app/drydock/review/IntentGroupingTest.java new file mode 100644 index 00000000..e597552c --- /dev/null +++ b/app/src/test/java/app/drydock/review/IntentGroupingTest.java @@ -0,0 +1,243 @@ +package app.drydock.review; + +import app.drydock.git.UnifiedDiff; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.OptionalInt; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * {@link IntentGrouping#intentsFor(String, UnifiedDiff, Optional)}'s + * computed-sections path: the id it mints, the case where it mints none at + * all because {@link Sections#of} found nothing structural, and the kind + * and risk it carries over from the fallback it replaces. + */ +class IntentGroupingTest { + + /** + * Four files whose structure {@code Sections} is known to split: {@code + * m.h}/{@code m.cpp} merge on the same-basename convention, {@code + * z.cpp} and {@code a.cpp} stay their own units -- three sections from + * one fallback group, since all four share {@code directory}'s (kind, + * directory). + */ + private static UnifiedDiff diffOf(String directory, int insertionsPerFile) { + List files = new ArrayList<>(); + for (String name : List.of("z.cpp", "a.cpp", "m.h", "m.cpp")) { + files.add(new UnifiedDiff.FileDiff(directory + "/" + name, "M", insertionsPerFile, 0, + false, false, List.of(new UnifiedDiff.Hunk("@@", List.of(new UnifiedDiff.Line( + UnifiedDiff.Line.Kind.ADD, OptionalInt.empty(), OptionalInt.of(1), + "void go() { helperOne(); }")))))); + } + return new UnifiedDiff(files); + } + + // ---- a genuinely computed grouping mints its own id -------------------- + + @Test + void computedSectionsMintDistinctContentDerivedIds() { + UnifiedDiff diff = diffOf("src", 1); + IntentGrouping grouping = new IntentGrouping(); + List intents = + grouping.intentsFor("scope", diff, Optional.of(ChangeGraph.of(diff))); + + assertTrue(intents.size() > 1, + "the m.h/m.cpp convention pair must produce a non-degenerate split"); + for (ReviewIntent intent : intents) { + assertTrue(intent.id().startsWith("computed:"), + "a genuinely computed section must not reuse a fallback id: " + intent.id()); + } + assertEquals(intents.size(), intents.stream().map(ReviewIntent::id).distinct().count(), + "every computed section must have its own id"); + } + + @Test + void theSameSectionMintsTheSameIdAcrossASeparateRebuild() { + UnifiedDiff diff = diffOf("src", 1); + IntentGrouping grouping = new IntentGrouping(); + List first = grouping.intentsFor("scope", diff, Optional.of(ChangeGraph.of(diff))) + .stream().map(ReviewIntent::id).toList(); + List second = grouping.intentsFor("scope", diff, Optional.of(ChangeGraph.of(diff))) + .stream().map(ReviewIntent::id).toList(); + + assertEquals(first, second, + "hashing over a section's own sorted hunk ids must be reproducible across a rebuild"); + } + + /** + * The discriminating case a positional {@code "computed:" + number} + * cannot pass: the SAME diff's own sections, reached through a + * DIFFERENT topological order. An unrelated extra file that sorts + * before {@code src/a.cpp} is enough on its own -- it becomes its own + * standalone section ahead of everything else, pushing a.cpp's + * position back with none of a.cpp's own hunks touched. A positional id + * would re-point at the section now sitting where a.cpp used to. + */ + @Test + void aSurvivingSectionKeepsItsIdAfterAnUnrelatedFileShiftsTheOrder() { + UnifiedDiff diffWithout = diffOf("src", 1); + IntentGrouping groupingWithout = new IntentGrouping(); + List before = + groupingWithout.intentsFor("scope", diffWithout, Optional.of(ChangeGraph.of(diffWithout))); + ReviewIntent survivorBefore = before.stream() + .filter(intent -> intent.title().startsWith("a.cpp")) + .findFirst().orElseThrow(); + assertEquals(0, before.indexOf(survivorBefore), "a.cpp must lead before the extra file exists"); + + List files = new ArrayList<>(diffWithout.files()); + files.add(new UnifiedDiff.FileDiff("other/n.cpp", "M", 1, 0, false, false, + List.of(new UnifiedDiff.Hunk("@@", List.of(new UnifiedDiff.Line( + UnifiedDiff.Line.Kind.ADD, OptionalInt.empty(), OptionalInt.of(1), + "class Standalone {};")))))); + UnifiedDiff diffWith = new UnifiedDiff(files); + IntentGrouping groupingWith = new IntentGrouping(); + List after = + groupingWith.intentsFor("scope", diffWith, Optional.of(ChangeGraph.of(diffWith))); + ReviewIntent survivorAfter = after.stream() + .filter(intent -> intent.id().equals(survivorBefore.id())) + .findFirst().orElseThrow(() -> new AssertionError( + "a.cpp's id must still be present after the reorder: " + after)); + + assertNotEquals(0, after.indexOf(survivorAfter), + "the extra file must actually have shifted a.cpp's position, or this test proves nothing"); + assertEquals(survivorBefore.hunkIds(), survivorAfter.hunkIds(), + "a.cpp's own hunks must be unaffected by an unrelated file elsewhere in the diff"); + } + + /** + * Two hunkless sections must not collide. {@code UnifiedDiff} carries no + * hunks at all for a binary file or a pure rename, so a section built + * from one alone hashes an EMPTY hunk list -- and without the files + * hashed in too, every such section would mint the identical id, + * silently dropping one from {@code ReviewIntentRail.buttonsByIntentId}. + * A convention-merged pair ({@code m.h}/{@code m.cpp}) is included + * purely to force the computed path rather than the (kind, directory) + * fallback; it plays no other part in the assertion. + */ + @Test + void twoHunklessSectionsDoNotCollide() { + List files = new ArrayList<>(diffOf("src", 1).files()); + files.add(new UnifiedDiff.FileDiff("assets/one.png", "M", 0, 0, false, false, List.of())); + files.add(new UnifiedDiff.FileDiff("assets/two.png", "M", 0, 0, false, false, List.of())); + UnifiedDiff diff = new UnifiedDiff(files); + + IntentGrouping grouping = new IntentGrouping(); + List intents = + grouping.intentsFor("scope", diff, Optional.of(ChangeGraph.of(diff))); + + // Matched by title, not ReviewIntent#touches: a hunkless section's + // hunkIds is empty, so touches() -- which walks hunkIds -- can never + // find it. Sections names a hub-less unit after its own file, so + // the title is "one.png · 1 file" / "two.png · 1 file". + ReviewIntent one = intents.stream() + .filter(intent -> intent.title().startsWith("one.png")).findFirst().orElseThrow(); + ReviewIntent two = intents.stream() + .filter(intent -> intent.title().startsWith("two.png")).findFirst().orElseThrow(); + + assertTrue(one.hunkIds().isEmpty(), "a binary/rename-only section has no hunks to name"); + assertTrue(two.hunkIds().isEmpty()); + assertNotEquals(one.id(), two.id(), + "two different hunkless sections must not mint the same id"); + assertEquals(intents.size(), intents.stream().map(ReviewIntent::id).distinct().count(), + "no id collision anywhere in the rail, hunkless or not"); + } + + // ---- nothing structural: the fallback's own ids survive ----------------- + + @Test + void aStructurelessDiffKeepsTheFallbacksOwnIdentity() { + UnifiedDiff diff = new UnifiedDiff(List.of( + new UnifiedDiff.FileDiff("src/A.java", "M", 1, 0, false, false, List.of( + new UnifiedDiff.Hunk("@@", List.of(new UnifiedDiff.Line( + UnifiedDiff.Line.Kind.ADD, OptionalInt.empty(), OptionalInt.of(1), "x"))))), + new UnifiedDiff.FileDiff("lib/B.java", "M", 1, 0, false, false, List.of( + new UnifiedDiff.Hunk("@@", List.of(new UnifiedDiff.Line( + UnifiedDiff.Line.Kind.ADD, OptionalInt.empty(), OptionalInt.of(1), "y"))))))); + IntentGrouping grouping = new IntentGrouping(); + + List computed = + grouping.intentsFor("scope", diff, Optional.of(ChangeGraph.of(diff))); + List fallback = FallbackIntents.group(diff); + + assertEquals(fallback, computed, + "Sections.of degenerating to the (kind, directory) clustering must not restate it " + + "under a fresh computed: identity -- that would orphan a finding recorded " + + "against the fallback's own id the moment the graph finished"); + } + + // ---- computed cards are numbered off the reading path, not Sections.of's own order ----- + + /** + * Task 18's correction 4: {@code McpToolRouter}'s {@code review_scope} + * and the rail's PATH mode both number sections off {@link + * ReadingPath#of}'s reading order, not {@link Sections#of}'s own + * (rank-free) topological order -- so the plain INTENTS cards this class + * mints must agree, or a human looking at computed card (1) and an agent + * reading section (1) off {@code review_scope} would disagree about + * which section that is. Mirrors {@code ReadingPathTest + * .theWiderFoundationIsReadFirst}: {@code zbase.cpp} carries in-degree 2 + * (referenced by both {@code u1.cpp} and {@code u2.cpp}) and sorts LAST; + * {@code mid.cpp} carries in-degree 1 and sorts FIRST. {@code + * Sections.of}'s own order (no entry-point rank, alphabetical tie-break + * among files ready at each step) puts {@code mid.cpp}'s section first; + * {@link ReadingPath}'s rank puts {@code zbase.cpp}'s first, because + * in-degree outranks the alphabetical tie-break. + */ + @Test + void computedIntentsAreNumberedOffTheReadingPathNotSectionsOwnOrder() { + List files = new ArrayList<>(); + files.add(oneLineFile("src/mid.cpp", "class Mid { };")); + files.add(oneLineFile("src/u1.cpp", "void u1() { new Base(); new Mid(); }")); + files.add(oneLineFile("src/u2.cpp", "void u2() { new Base(); }")); + files.add(oneLineFile("src/zbase.cpp", "class Base { };")); + UnifiedDiff diff = new UnifiedDiff(files); + + IntentGrouping grouping = new IntentGrouping(); + List computed = + grouping.intentsFor("scope", diff, Optional.of(ChangeGraph.of(diff))); + + ReviewIntent first = computed.get(0); + assertTrue(first.hunkIds().contains(ReviewIntent.hunkId("src/zbase.cpp", 0)), + "card 1 must be zbase.cpp's section (the reading path's entry point -- in-degree " + + "2 outranks mid.cpp's alphabetical lead), not Sections.of's own " + + "alphabetically-first card: " + computed); + assertEquals(1, first.number()); + } + + private static UnifiedDiff.FileDiff oneLineFile(String path, String line) { + return new UnifiedDiff.FileDiff(path, "M", 1, 0, false, false, List.of( + new UnifiedDiff.Hunk("@@", List.of(new UnifiedDiff.Line( + UnifiedDiff.Line.Kind.ADD, OptionalInt.empty(), OptionalInt.of(1), line))))); + } + + // ---- a computed section carries over kind and risk --------------------- + + @Test + void computedSectionsCarryOverTheFallbacksKindAndRisk() { + // Under "test/", all four files classify as ReviewIntent.Kind.TESTS; + // 4 files x 40 declared insertions each is 160 total churn, inside + // FallbackIntents' MED band (over 100, at or under 400). + UnifiedDiff diff = diffOf("test", 40); + List fallback = FallbackIntents.group(diff); + assertEquals(1, fallback.size(), "all four files share one (kind, directory) fallback group"); + assertEquals(ReviewIntent.Kind.TESTS, fallback.get(0).kind()); + assertEquals(ReviewIntent.Risk.MED, fallback.get(0).risk()); + + IntentGrouping grouping = new IntentGrouping(); + List computed = + grouping.intentsFor("scope", diff, Optional.of(ChangeGraph.of(diff))); + assertTrue(computed.size() > 1, "the m.h/m.cpp convention pair must still split"); + for (ReviewIntent intent : computed) { + assertEquals(ReviewIntent.Kind.TESTS, intent.kind(), + "a computed section must not flatten to CHANGE when its hunks are all tests"); + assertEquals(ReviewIntent.Risk.MED, intent.risk(), + "a computed section must not flatten to NONE when its hunks carry real churn"); + } + } +} diff --git a/app/src/test/java/app/drydock/review/LegacyVerdictMigrationTest.java b/app/src/test/java/app/drydock/review/LegacyVerdictMigrationTest.java deleted file mode 100644 index 3a10acfb..00000000 --- a/app/src/test/java/app/drydock/review/LegacyVerdictMigrationTest.java +++ /dev/null @@ -1,237 +0,0 @@ -package app.drydock.review; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Instant; -import java.util.List; -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -/** - * Carrying approvals across the change of intent grouping. - * - *

Verdicts are persisted by intent id. The fallback grouping used to emit - * one intent per file, keyed {@code file:}; it now clusters files by - * directory and kind, keyed {@code auto::

}. Without a migration - * every approval recorded before that change would read as unsettled, and a - * finished review would ask to be done again.

- * - *

The merge rule is deliberately asymmetric, because the two directions - * are not equally safe. Requesting changes on part of a group is true of the - * group. Approving a group is a claim that the human read all of it -- so a - * partially-approved group carries nothing forward and is re-settled by - * hand. Silently approving code nobody looked at is the one outcome a - * migration must never produce.

- */ -class LegacyVerdictMigrationTest { - - private Path file; - private AnnotationStore store; - - @BeforeEach - void setUp() throws IOException { - file = Files.createTempDirectory("drydock-verdict-migration").resolve("annotations.json"); - store = new AnnotationStore(file); - } - - @AfterEach - void tearDown() { - store.close(); - } - - @Test - void aFullyApprovedGroupCarriesItsApprovalOver() { - putLegacy("file:src/A.java", ReviewVerdict.Decision.APPROVED); - putLegacy("file:src/B.java", ReviewVerdict.Decision.APPROVED); - - int migrated = store.migrateLegacyVerdicts("scope-1", List.of(intent("auto:change:src", - "src/A.java", "src/B.java"))); - - assertEquals(1, migrated); - assertEquals(ReviewVerdict.Decision.APPROVED, - store.verdict("scope-1", "auto:change:src").orElseThrow().decision()); - } - - @Test - void theLegacyKeysAreGoneOnceMigrated() { - putLegacy("file:src/A.java", ReviewVerdict.Decision.APPROVED); - - store.migrateLegacyVerdicts("scope-1", List.of(intent("auto:change:src", "src/A.java"))); - - assertTrue(store.verdict("scope-1", "file:src/A.java").isEmpty(), - "a migrated verdict must not also stay under its old key"); - } - - /** Changes requested on any file is true of the group that contains it. */ - @Test - void changesRequestedOnOneFileCarriesToTheWholeGroup() { - putLegacy("file:src/A.java", ReviewVerdict.Decision.APPROVED); - putLegacy("file:src/B.java", ReviewVerdict.Decision.CHANGES); - - store.migrateLegacyVerdicts("scope-1", List.of(intent("auto:change:src", - "src/A.java", "src/B.java"))); - - assertEquals(ReviewVerdict.Decision.CHANGES, - store.verdict("scope-1", "auto:change:src").orElseThrow().decision()); - } - - /** The one thing this must never do: approve code the human never settled. */ - @Test - void aPartiallyApprovedGroupCarriesNothing() { - putLegacy("file:src/A.java", ReviewVerdict.Decision.APPROVED); - // src/B.java was never settled. - - int migrated = store.migrateLegacyVerdicts("scope-1", List.of(intent("auto:change:src", - "src/A.java", "src/B.java"))); - - assertEquals(0, migrated); - assertTrue(store.verdict("scope-1", "auto:change:src").isEmpty(), - "approving a group on the strength of one of its files is a lie about what was read"); - } - - /** A partial group's legacy verdicts are kept, not silently dropped. */ - @Test - void aPartiallyApprovedGroupKeepsItsLegacyVerdicts() { - putLegacy("file:src/A.java", ReviewVerdict.Decision.APPROVED); - - store.migrateLegacyVerdicts("scope-1", List.of(intent("auto:change:src", - "src/A.java", "src/B.java"))); - - assertTrue(store.verdict("scope-1", "file:src/A.java").isPresent(), - "discarding the record would lose what the human actually did decide"); - } - - @Test - void aHumanApprovalOutranksAnAgentsAutoApproval() { - putLegacy("file:src/A.java", ReviewVerdict.Decision.AUTO_APPROVED); - putLegacy("file:src/B.java", ReviewVerdict.Decision.APPROVED); - - store.migrateLegacyVerdicts("scope-1", List.of(intent("auto:change:src", - "src/A.java", "src/B.java"))); - - assertEquals(ReviewVerdict.Decision.APPROVED, - store.verdict("scope-1", "auto:change:src").orElseThrow().decision()); - } - - @Test - void anAllAutoApprovedGroupStaysAutoApproved() { - putLegacy("file:src/A.java", ReviewVerdict.Decision.AUTO_APPROVED); - putLegacy("file:src/B.java", ReviewVerdict.Decision.AUTO_APPROVED); - - store.migrateLegacyVerdicts("scope-1", List.of(intent("auto:change:src", - "src/A.java", "src/B.java"))); - - assertEquals(ReviewVerdict.Decision.AUTO_APPROVED, - store.verdict("scope-1", "auto:change:src").orElseThrow().decision()); - } - - /** A migrated verdict says so, so its provenance is not misrepresented. */ - @Test - void aMigratedVerdictIsMarkedAsMigrated() { - putLegacy("file:src/A.java", ReviewVerdict.Decision.APPROVED); - - store.migrateLegacyVerdicts("scope-1", List.of(intent("auto:change:src", "src/A.java"))); - - assertTrue(store.verdict("scope-1", "auto:change:src").orElseThrow() - .note().orElse("").toLowerCase(java.util.Locale.ROOT).contains("regroup"), - "the note must record that this verdict was carried over, not freshly given"); - } - - /** An existing decision on the new id is the newer one and must win. */ - @Test - void aVerdictAlreadyRecordedOnTheNewIdIsNotOverwritten() { - putLegacy("file:src/A.java", ReviewVerdict.Decision.APPROVED); - putLegacy("auto:change:src", ReviewVerdict.Decision.CHANGES); - - store.migrateLegacyVerdicts("scope-1", List.of(intent("auto:change:src", "src/A.java"))); - - assertEquals(ReviewVerdict.Decision.CHANGES, - store.verdict("scope-1", "auto:change:src").orElseThrow().decision(), - "a decision made under the new grouping is newer than one made under the old"); - } - - @Test - void runningTwiceChangesNothingTheSecondTime() { - putLegacy("file:src/A.java", ReviewVerdict.Decision.APPROVED); - List intents = List.of(intent("auto:change:src", "src/A.java")); - - assertEquals(1, store.migrateLegacyVerdicts("scope-1", intents)); - assertEquals(0, store.migrateLegacyVerdicts("scope-1", intents), - "the migration must be idempotent -- it runs on every diff that lands"); - } - - /** Another scope's verdicts are not this scope's to migrate. */ - @Test - void onlyTheNamedScopeIsTouched() { - store.putVerdict(new ReviewVerdict("scope-2", "file:src/A.java", - ReviewVerdict.Decision.APPROVED, Optional.empty(), Instant.EPOCH)); - - store.migrateLegacyVerdicts("scope-1", List.of(intent("auto:change:src", "src/A.java"))); - - assertTrue(store.verdict("scope-2", "file:src/A.java").isPresent(), - "scope-2's verdict belongs to scope-2"); - assertTrue(store.verdict("scope-1", "auto:change:src").isEmpty()); - } - - /** A legacy verdict on a file no longer in the diff has no group to join. */ - @Test - void aLegacyVerdictForAFileNoLongerInTheDiffIsLeftAlone() { - putLegacy("file:src/Deleted.java", ReviewVerdict.Decision.APPROVED); - - int migrated = store.migrateLegacyVerdicts("scope-1", - List.of(intent("auto:change:src", "src/A.java"))); - - assertEquals(0, migrated); - assertTrue(store.verdict("scope-1", "file:src/Deleted.java").isPresent()); - } - - @Test - void noIntentsMeansNoMigration() { - putLegacy("file:src/A.java", ReviewVerdict.Decision.APPROVED); - - assertEquals(0, store.migrateLegacyVerdicts("scope-1", List.of()), - "a scope whose diff has not loaded must not have its verdicts rewritten"); - assertTrue(store.verdict("scope-1", "file:src/A.java").isPresent()); - } - - @Test - void theMigrationSurvivesAReload() throws Exception { - putLegacy("file:src/A.java", ReviewVerdict.Decision.APPROVED); - store.migrateLegacyVerdicts("scope-1", List.of(intent("auto:change:src", "src/A.java"))); - store.close(); - - // Handed to the field so tearDown closes this one and not the store - // that is already shut down -- closing twice submits to a dead - // executor and fails the test for a reason that is not the point. - store = new AnnotationStore(file); - - assertEquals(ReviewVerdict.Decision.APPROVED, - store.verdict("scope-1", "auto:change:src").orElseThrow().decision(), - "the migration must be written to disk, or it runs again forever"); - assertFalse(store.verdict("scope-1", "file:src/A.java").isPresent()); - } - - // ---- helpers -------------------------------------------------------- - - private void putLegacy(String intentId, ReviewVerdict.Decision decision) { - store.putVerdict(new ReviewVerdict("scope-1", intentId, decision, - Optional.empty(), Instant.EPOCH)); - } - - private static ReviewIntent intent(String id, String... files) { - List hunkIds = new java.util.ArrayList<>(); - for (String file : files) { - hunkIds.add(ReviewIntent.hunkId(file, 0)); - } - return new ReviewIntent(id, 1, "an intent", ReviewIntent.Kind.CHANGE, - ReviewIntent.Risk.LOW, "", hunkIds, Optional.empty(), false); - } -} diff --git a/app/src/test/java/app/drydock/review/OutOfDiffFanInTest.java b/app/src/test/java/app/drydock/review/OutOfDiffFanInTest.java new file mode 100644 index 00000000..78de2e85 --- /dev/null +++ b/app/src/test/java/app/drydock/review/OutOfDiffFanInTest.java @@ -0,0 +1,289 @@ +package app.drydock.review; + +import app.drydock.git.UnifiedDiff; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.OptionalInt; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The strongest entry-point signal (spec §4.3): a changed symbol called from + * OUTSIDE the change. A diff-scoped graph cannot see it, and the reference + * implementation buys it with a repository-wide ingest this codebase has + * twice refused to build. One bounded git grep gets it instead. + * + *

The locations are kept, not just counted: a fan-in with nowhere to click + * is a statistic, not comprehension, and it lands exactly when a reviewer + * wants to look.

+ * + *

{@code git grep -n -F} without {@code -z} C-quotes any path with a + * non-ASCII byte or special character -- the same defect a base-move fix + * elsewhere in this package already paid for once. So the scan is spawned + * with {@code -z}, and the parsing below is against that framing: {@code + * filelinetext\n} per match, not the colon-joined text {@code git + * grep} prints without it. The scan test at the bottom spawns real git + * against a repo with a non-ASCII filename to prove the whole pipeline, not + * just the parser, carries it through intact.

+ */ +class OutOfDiffFanInTest { + + private static final char NUL = '\0'; + + @Test + void parsingKeepsFileLineAndText() { + List parsed = OutOfDiffFanIn.parse( + "src/other.cpp" + NUL + "42" + NUL + " JmpCtxScope guard;\n", + Set.of("src/guards.cpp")); + + assertEquals(1, parsed.size()); + assertEquals("src/other.cpp", parsed.get(0).file()); + assertEquals(42, parsed.get(0).line()); + assertTrue(parsed.get(0).text().contains("JmpCtxScope")); + } + + /** Occurrences inside the change are not "outside" it. */ + @Test + void matchesInChangedFilesAreExcluded() { + assertEquals(List.of(), OutOfDiffFanIn.parse( + "src/guards.cpp" + NUL + "9" + NUL + " JmpCtxScope guard;\n", + Set.of("src/guards.cpp"))); + } + + @Test + void aMalformedLineIsSkippedRatherThanFatal() { + assertEquals(List.of(), OutOfDiffFanIn.parse("not a grep line\n", Set.of())); + } + + /** A path containing a colon must not be truncated at it -- NUL, not ':', separates fields. */ + @Test + void aPathContainingAColonParsesBackToItself() { + List parsed = OutOfDiffFanIn.parse( + "src/a:b.cpp" + NUL + "7" + NUL + "x();\n", Set.of()); + + assertEquals("src/a:b.cpp", parsed.get(0).file()); + assertEquals(7, parsed.get(0).line()); + } + + /** + * The defect this task exists to avoid repeating: a non-ASCII filename + * must round-trip intact, not arrive C-quoted with octal escapes. + */ + @Test + void aNonAsciiPathParsesBackToItself() { + List parsed = OutOfDiffFanIn.parse( + "src/café.txt" + NUL + "1" + NUL + "JmpCtxScope guard;\n", Set.of()); + + assertEquals("src/café.txt", parsed.get(0).file()); + } + + /** Multiple matches in one grep run, across records, all parse. */ + @Test + void multipleRecordsInOneRunAllParse() { + String stdout = "src/a.cpp" + NUL + "1" + NUL + "JmpCtxScope x;\n" + + "src/b.cpp" + NUL + "2" + NUL + "JmpCtxScope y;\n"; + + List parsed = OutOfDiffFanIn.parse(stdout, Set.of()); + + assertEquals(2, parsed.size()); + assertEquals("src/a.cpp", parsed.get(0).file()); + assertEquals("src/b.cpp", parsed.get(1).file()); + } + + @Test + void anEmptyStdoutParsesToNoOccurrences() { + assertEquals(List.of(), OutOfDiffFanIn.parse("", Set.of())); + } + + // ---- scan(): the real spawn, a real repo, a real non-ASCII filename ---- + + private static UnifiedDiff.FileDiff file(String path, String... added) { + List lines = new ArrayList<>(); + int n = 1; + for (String text : added) { + lines.add(new UnifiedDiff.Line(UnifiedDiff.Line.Kind.ADD, + OptionalInt.empty(), OptionalInt.of(n++), text)); + } + return new UnifiedDiff.FileDiff(path, "M", added.length, 0, false, false, + List.of(new UnifiedDiff.Hunk("@@", lines))); + } + + @Test + void scanFindsOutOfDiffUsesAcrossFilesIncludingANonAsciiCaller(@TempDir Path dir) + throws IOException, InterruptedException { + Path repo = initCommittedRepoWithFanIn(dir); + ChangeGraph graph = ChangeGraph.of(new UnifiedDiff( + List.of(file("src/Guards.java", "class JmpCtxScope { }")))); + + OutOfDiffFanIn.Result result = OutOfDiffFanIn.scan(repo, graph, Set.of("src/Guards.java")); + + assertFalse(result.unavailable()); + List hits = result.bySymbol().get("JmpCtxScope"); + assertTrue(hits != null && hits.size() >= 2, + "expected hits in both the plain and non-ASCII caller, got: " + hits); + List files = hits.stream().map(OutOfDiffFanIn.Occurrence::file).toList(); + assertTrue(files.contains("src/Other.java"), "plain caller missing: " + files); + assertTrue(files.contains("src/café.txt"), "non-ASCII caller missing: " + files); + assertFalse(files.contains("src/Guards.java"), "the changed file itself must be excluded"); + } + + @Test + void scanReturnsUnavailableWhenGitCannotRun(@TempDir Path dir) throws IOException { + Path notARepo = dir.resolve("does-not-exist"); + ChangeGraph graph = ChangeGraph.of(new UnifiedDiff( + List.of(file("src/Guards.java", "class JmpCtxScope { }")))); + + OutOfDiffFanIn.Result result = OutOfDiffFanIn.scan(notARepo, graph, Set.of("src/Guards.java")); + + assertTrue(result.unavailable(), "a scan that could not run must report unavailable, not zero"); + assertEquals(Map.of(), result.bySymbol()); + } + + @Test + void aScopeWithNoChangedDeclarationsScansNothing(@TempDir Path dir) { + ChangeGraph graph = ChangeGraph.of(new UnifiedDiff(List.of())); + + OutOfDiffFanIn.Result result = OutOfDiffFanIn.scan(dir, graph, Set.of()); + + assertFalse(result.unavailable()); + assertEquals(Map.of(), result.bySymbol()); + } + + /** + * {@code git grep} exits 1 for "no matches", and that must reach the + * caller as an empty-but-available answer. This is the property a + * regression narrowing {@code exitCode() > 1} to {@code >= 1} would + * silently break, so it is pinned through a REAL spawn (a repo git + * actually greps and finds nothing in) rather than through the + * could-not-launch path {@code scanReturnsUnavailableWhenGitCannotRun} + * already covers. + */ + @Test + void aSymbolMatchingNowhereIsAnEmptyAnswerNotUnavailable(@TempDir Path dir) + throws IOException, InterruptedException { + Path repo = initCommittedRepoWithFanIn(dir); + ChangeGraph graph = ChangeGraph.of(new UnifiedDiff( + List.of(file("src/Guards.java", "class TotallyAbsentSymbolXyz { }")))); + + OutOfDiffFanIn.Result result = OutOfDiffFanIn.scan(repo, graph, Set.of("src/Guards.java")); + + assertFalse(result.unavailable(), + "git grep exit 1 (no matches anywhere) is a valid empty answer, not unavailable"); + assertEquals(Map.of(), result.bySymbol()); + } + + // ---- word boundaries: an inflated count reorders what a human reads ---- + + /** + * {@code ZetaSymHelper} is not two uses of {@code ZetaSym}; it is a + * different class that happens to start with those letters. Before this + * fix, both halves of the match were plain substring tests -- {@code git + * grep -F} finding the lines and {@code text.contains(symbol)} + * attributing them -- so the rail rendered "called from 2 places outside + * the change" and the popover listed two lines that are not usages at + * all. + * + *

Worth a real spawn rather than a unit test of the filter: it is + * {@code -w} on the git side that has to be right too, and a filter + * fixed alone would still be handed lines the symbol never appears in. + * This number is the reading path's FIRST rank term, so an inflated one + * does not merely read wrong -- it reorders what a reviewer reads + * next.

+ */ + @Test + void aLongerIdentifierThatMerelyStartsWithTheSymbolIsNotAUse(@TempDir Path dir) + throws IOException, InterruptedException { + Path repo = Files.createDirectories(dir.resolve("repo")); + runGit(repo, "init", "-b", "main"); + runGit(repo, "config", "user.name", "Test"); + runGit(repo, "config", "user.email", "test@example.com"); + Files.createDirectories(repo.resolve("src")); + Files.writeString(repo.resolve("src/Zeta.java"), "class ZetaSym { }\n", StandardCharsets.UTF_8); + Files.writeString(repo.resolve("src/Caller.java"), + "void a() { new ZetaSymHelper(); }\nvoid b() { ZetaSymHelper.of(); }\n", + StandardCharsets.UTF_8); + runGit(repo, "add", "-A"); + runGit(repo, "commit", "-m", "initial commit"); + ChangeGraph graph = ChangeGraph.of(new UnifiedDiff( + List.of(file("src/Zeta.java", "class ZetaSym { }")))); + + OutOfDiffFanIn.Result result = OutOfDiffFanIn.scan(repo, graph, Set.of("src/Zeta.java")); + + assertFalse(result.unavailable()); + assertEquals(Map.of(), result.bySymbol(), + "ZetaSymHelper is a different identifier; counting it inflates rank term 1"); + } + + /** + * The other half: when one changed declaration's name is a prefix of + * another's, a line using the LONGER one comes back from {@code git grep + * -w} legitimately -- and must then be attributed to that one only. + * {@code git grep -w} cannot make this distinction for us, which is why + * {@link OutOfDiffFanIn#mentions} exists rather than a plain {@code + * contains}. + */ + @Test + void aLineIsAttributedOnlyToTheSymbolItActuallyNames(@TempDir Path dir) + throws IOException, InterruptedException { + Path repo = Files.createDirectories(dir.resolve("repo")); + runGit(repo, "init", "-b", "main"); + runGit(repo, "config", "user.name", "Test"); + runGit(repo, "config", "user.email", "test@example.com"); + Files.createDirectories(repo.resolve("src")); + Files.writeString(repo.resolve("src/Pair.java"), "class Foo { }\nclass FooBar { }\n", + StandardCharsets.UTF_8); + Files.writeString(repo.resolve("src/Caller.java"), "void a() { new FooBar(); }\n", + StandardCharsets.UTF_8); + runGit(repo, "add", "-A"); + runGit(repo, "commit", "-m", "initial commit"); + ChangeGraph graph = ChangeGraph.of(new UnifiedDiff( + List.of(file("src/Pair.java", "class Foo { }", "class FooBar { }")))); + + OutOfDiffFanIn.Result result = OutOfDiffFanIn.scan(repo, graph, Set.of("src/Pair.java")); + + assertFalse(result.unavailable()); + assertEquals(Set.of("FooBar"), result.bySymbol().keySet(), + "the caller names FooBar, not Foo: " + result.bySymbol()); + } + + private static Path initCommittedRepoWithFanIn(Path parent) throws IOException, InterruptedException { + Path repo = Files.createDirectories(parent.resolve("repo")); + runGit(repo, "init", "-b", "main"); + runGit(repo, "config", "user.name", "Test"); + runGit(repo, "config", "user.email", "test@example.com"); + Files.createDirectories(repo.resolve("src")); + Files.writeString(repo.resolve("src/Guards.java"), "class JmpCtxScope { }\n", StandardCharsets.UTF_8); + Files.writeString(repo.resolve("src/Other.java"), + "void go() { new JmpCtxScope(); }\n", StandardCharsets.UTF_8); + Files.writeString(repo.resolve("src/café.txt"), + "JmpCtxScope guard;\n", StandardCharsets.UTF_8); + runGit(repo, "add", "-A"); + runGit(repo, "commit", "-m", "initial commit"); + return repo; + } + + private static void runGit(Path repo, String... args) throws IOException, InterruptedException { + List command = new ArrayList<>(List.of("git")); + command.addAll(List.of(args)); + Process process = new ProcessBuilder(command) + .directory(repo.toFile()) + .redirectErrorStream(true) + .start(); + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + int exit = process.waitFor(); + if (exit != 0) { + throw new IllegalStateException("git " + String.join(" ", args) + " exited " + exit + ": " + output); + } + } +} diff --git a/app/src/test/java/app/drydock/review/ProvenanceTest.java b/app/src/test/java/app/drydock/review/ProvenanceTest.java new file mode 100644 index 00000000..04f7ca94 --- /dev/null +++ b/app/src/test/java/app/drydock/review/ProvenanceTest.java @@ -0,0 +1,48 @@ +package app.drydock.review; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * A measured edge and a claimed one fail differently (spec §6.5), so the + * surface has to say which it is holding. + */ +class ProvenanceTest { + + @Test + void eachWarrantNamesItself() { + assertEquals("measured", Provenance.MEASURED.label()); + assertEquals("claimed", Provenance.CLAIMED.label()); + } + + /** + * The convenience constructors on {@link ReadingPath.Step} and {@link + * ReadingPath.Link} default the warrant, and every default in this design + * points at the MORE trusted value. Flipping either to CLAIMED survived + * the suite until this pinned it -- a silent default in the direction the + * feature exists to prevent is exactly what wants a test. + */ + @Test + void theConvenienceConstructorsDefaultToMeasured() { + assertEquals(Provenance.MEASURED, + new ReadingPath.Link("calls", "h_a_0", "a.cpp").provenance()); + assertEquals(Provenance.MEASURED, + new ReadingPath.Step("h_a_0", "a.cpp", 1, "why", + List.of(), true).provenance()); + } + + /** + * Only the claimed case carries a modifier: the ordinary rail row must + * stay on the plain class, or every row is decorated and the distinction + * says nothing. + */ + @Test + void onlyTheClaimedWarrantCarriesAStyleClass() { + assertEquals("provenance-claimed", Provenance.CLAIMED.styleClass()); + assertTrue(Provenance.MEASURED.styleClass().isEmpty()); + } +} diff --git a/app/src/test/java/app/drydock/review/ReadingPathTest.java b/app/src/test/java/app/drydock/review/ReadingPathTest.java new file mode 100644 index 00000000..12138e4d --- /dev/null +++ b/app/src/test/java/app/drydock/review/ReadingPathTest.java @@ -0,0 +1,591 @@ +package app.drydock.review; + +import app.drydock.git.UnifiedDiff; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalInt; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Where to start, what follows, and why (spec §6). Entry-point rank is + * applied INSIDE the sort rather than as a marking pass afterwards: ordering + * first and marking second lets "card 1" and "START HERE" disagree, and a + * START HERE badge on card 4 reads as a bug rather than a design. + * + *

Every ordering test here is built so that path order alone would give + * the WRONG answer -- the file that must come first is deliberately named so + * it sorts second. A fixture whose expected order happens to be alphabetical + * cannot fail when the signal it names is deleted.

+ */ +class ReadingPathTest { + + private static UnifiedDiff.FileDiff file(String path, String... added) { + return new UnifiedDiff.FileDiff(path, "M", added.length, 0, false, false, + List.of(hunk(1, added))); + } + + private static UnifiedDiff.Hunk hunk(int firstLine, String... added) { + List lines = new ArrayList<>(); + int n = firstLine; + for (String text : added) { + lines.add(new UnifiedDiff.Line(UnifiedDiff.Line.Kind.ADD, + OptionalInt.empty(), OptionalInt.of(n++), text)); + } + return new UnifiedDiff.Hunk("@@", lines); + } + + /** A file of several hunks, one line each, twenty lines apart. */ + private static UnifiedDiff.FileDiff multiHunk(String path, String... oneLinePerHunk) { + List hunks = new ArrayList<>(); + int n = 1; + for (String text : oneLinePerHunk) { + hunks.add(hunk(n, text)); + n += 20; + } + return new UnifiedDiff.FileDiff(path, "M", hunks.size(), 0, false, false, hunks); + } + + private static List pathOf(UnifiedDiff diff, OutOfDiffFanIn.Result fanIn) { + return fullPathOf(diff, fanIn).steps(); + } + + private static ReadingPath.Path fullPathOf(UnifiedDiff diff, OutOfDiffFanIn.Result fanIn) { + ChangeGraph graph = ChangeGraph.of(diff); + return ReadingPath.of(diff, graph, Sections.of(diff, graph), fanIn); + } + + private static List filesOf(List path) { + return path.stream().map(ReadingPath.Step::file).distinct().toList(); + } + + private static OutOfDiffFanIn.Result fanIn(String symbol, int occurrences) { + List where = new ArrayList<>(); + for (int i = 0; i < occurrences; i++) { + where.add(new OutOfDiffFanIn.Occurrence("other/caller.cpp", i + 1, " use();")); + } + return new OutOfDiffFanIn.Result(Map.of(symbol, where), false); + } + + private static final OutOfDiffFanIn.Result NO_FAN_IN = + new OutOfDiffFanIn.Result(Map.of(), false); + + /** + * The dependent is named so it sorts first AND is the top-ranked entry + * point (it is the one with out-of-diff callers). Only the edge can put + * the foundation first, so deleting the edge -- or handing {@code Graphs} + * the nodes without them -- fails this. + */ + @Test + void theFoundationIsReadBeforeWhatUsesIt() { + List path = pathOf(new UnifiedDiff(List.of( + file("src/aprofiler.cpp", "void hotEntry() { new JmpCtxScope(); }"), + file("src/guards.cpp", "class JmpCtxScope { };"))), fanIn("hotEntry", 4)); + + assertEquals(List.of("src/guards.cpp", "src/aprofiler.cpp"), filesOf(path)); + } + + /** The first step and the entry point are the same step, by construction. */ + @Test + void theFirstStepIsTheEntryPoint() { + List path = pathOf(new UnifiedDiff(List.of( + file("src/aprofiler.cpp", "void hotEntry() { new JmpCtxScope(); }"), + file("src/guards.cpp", "class JmpCtxScope { };"))), fanIn("hotEntry", 4)); + + assertTrue(path.get(0).entryPoint()); + assertTrue(path.stream().skip(1).noneMatch(ReadingPath.Step::entryPoint)); + } + + /** + * Called from outside the change outranks everything else. {@code + * src/zeta.cpp} sorts last and has in-degree 0; {@code src/internal.cpp} + * sorts first and is referenced by a changed file. Without the fan-in + * term, internal wins on both of the remaining signals. + */ + @Test + void outOfDiffFanInOutranksInDegree() { + UnifiedDiff diff = new UnifiedDiff(List.of( + file("src/internal.cpp", "class Internal { };"), + file("src/user.cpp", "void u() { new Internal(); }"), + file("src/zeta.cpp", "class PublicThing { };"))); + + assertEquals("src/internal.cpp", pathOf(diff, NO_FAN_IN).get(0).file()); + assertEquals("src/zeta.cpp", pathOf(diff, fanIn("PublicThing", 1)).get(0).file()); + } + + /** + * In-degree is a count, not a flag: two files that both have dependents + * are still ordered by how many. {@code zbase.cpp} sorts last and carries + * two, {@code mid.cpp} sorts first and carries one, so nothing but the + * count separates them -- no fan-in, neither a test, both the same kind. + */ + @Test + void theWiderFoundationIsReadFirst() { + List path = pathOf(new UnifiedDiff(List.of( + file("src/mid.cpp", "class Mid { };"), + file("src/u1.cpp", "void u1() { new Base(); new Mid(); }"), + file("src/u2.cpp", "void u2() { new Base(); }"), + file("src/zbase.cpp", "class Base { };"))), NO_FAN_IN); + + assertEquals("src/zbase.cpp", path.get(0).file()); + } + + /** + * With every §6.2 signal silent the rank degrades to today's fallback + * order rather than to alphabetical chaos: production code before + * configuration, even where the path says otherwise. + */ + @Test + void theKindOrderBreaksATieTheSignalsCannot() { + List path = pathOf(new UnifiedDiff(List.of( + file("src/a.json", "{\"key\": 1}"), + file("src/z.cpp", "class Zed { };"))), NO_FAN_IN); + + assertEquals("src/z.cpp", path.get(0).file()); + } + + /** + * A tie-break for when the graph is silent, not an override of it: where + * a test references changed code the edge already orders it. The test + * path sorts FIRST here ({@code _} before {@code g}), so only the signal + * can demote it. + */ + @Test + void aTestOnlySectionDoesNotBecomeTheEntryPoint() { + List path = pathOf(new UnifiedDiff(List.of( + file("src/__tests__/unrelated_ut.cpp", "void t() { somethingElse(); }"), + file("src/guards.cpp", "class JmpCtxScope { };"))), NO_FAN_IN); + + assertEquals("src/guards.cpp", path.get(0).file()); + } + + /** + * The four §6.2 signals rank ahead of {@code FallbackIntents}' kind + * order, which is only what the rank falls back to. Isolates the + * not-a-test signal from the kind order, which would otherwise demote + * every test on its own and leave the signal untestable: a vendored file + * is GENERATED, which the kind order ranks BELOW tests, and it sorts + * last as well. + */ + @Test + void theTestSignalOutranksTheFallbackKindOrder() { + List path = pathOf(new UnifiedDiff(List.of( + file("src/__tests__/probe_ut.cpp", "void probe() { }"), + file("src/vendor/lib.cpp", "class VendorThing { };"))), NO_FAN_IN); + + assertEquals("src/vendor/lib.cpp", path.get(0).file()); + } + + /** With every signal equal the tie-break is the path, and it is TOTAL. */ + @Test + void withNothingToTellThemApartStepsFollowPathOrder() { + List path = pathOf(new UnifiedDiff(List.of( + file("src/c.cpp", "class Cee { };"), + file("src/a.cpp", "class Aye { };"), + file("src/b.cpp", "class Bee { };"))), NO_FAN_IN); + + assertEquals(List.of("src/a.cpp", "src/b.cpp", "src/c.cpp"), filesOf(path)); + } + + /** + * Spec §6.4: links and entry-point marks "are computed in both cases: + * they are facts about the diff, not a grouping". So everything + * ReadingPath produces is MEASURED -- it orders the computed grouping + * only, and never sees the agent's `reads`. The accessor exists because + * §6.3 has labels carry their provenance, and §14's checklist requires a + * scope with an agent grouping AND a computed link set to mark each + * correctly: a claimed rail beside measured links. + */ + @Test + void everythingReadingPathComputesIsMeasured() { + ReadingPath.Path path = fullPathOf(new UnifiedDiff(List.of( + file("src/guards.cpp", "class JmpCtxScope { };"), + file("src/profiler.cpp", "void go() { new JmpCtxScope(); }"))), NO_FAN_IN); + + assertTrue(path.steps().stream() + .allMatch(step -> step.provenance() == Provenance.MEASURED)); + assertTrue(path.steps().stream().flatMap(step -> step.links().stream()) + .allMatch(link -> link.provenance() == Provenance.MEASURED)); + assertFalse(path.steps().stream().flatMap(step -> step.links().stream()).toList().isEmpty(), + "a vacuous pass if this diff produced no links at all"); + } + + @Test + void aStepLinksToWhatCallsIt() { + List path = pathOf(new UnifiedDiff(List.of( + file("src/guards.cpp", "class JmpCtxScope { };"), + file("src/profiler.cpp", "void go() { new JmpCtxScope(); }"))), NO_FAN_IN); + + ReadingPath.Step guards = stepFor(path, "src/guards.cpp"); + ReadingPath.Link link = guards.links().stream() + .filter(candidate -> candidate.kind().equals("called by")) + .findFirst().orElseThrow(); + assertEquals(ReviewIntent.hunkId("src/profiler.cpp", 0), link.targetHunkId()); + assertTrue(link.label().contains("profiler.cpp"), link.label()); + assertTrue(link.label().contains("JmpCtxScope"), link.label()); + } + + @Test + void aStepLinksToWhatItCalls() { + List path = pathOf(new UnifiedDiff(List.of( + file("src/guards.cpp", "class JmpCtxScope { };"), + file("src/profiler.cpp", "void go() { new JmpCtxScope(); }"))), NO_FAN_IN); + + ReadingPath.Step profiler = stepFor(path, "src/profiler.cpp"); + ReadingPath.Link link = profiler.links().stream() + .filter(candidate -> candidate.kind().equals("calls")) + .findFirst().orElseThrow(); + assertEquals(ReviewIntent.hunkId("src/guards.cpp", 0), link.targetHunkId()); + assertTrue(link.label().contains("guards.cpp:JmpCtxScope"), link.label()); + } + + /** Same-concept links name the symbol they share; a bare affinity says nothing. */ + @Test + void sameConceptLinksNameTheSharedSymbol() { + List path = pathOf(new UnifiedDiff(List.of( + file("src/guards.cpp", "class JmpCtxScope { };"), + file("src/a.cpp", "void a() { new JmpCtxScope(); }"), + file("src/b.cpp", "void b() { new JmpCtxScope(); }"))), NO_FAN_IN); + + ReadingPath.Link shared = stepFor(path, "src/a.cpp").links().stream() + .filter(link -> link.kind().equals("same concept")) + .findFirst().orElseThrow(); + assertEquals(ReviewIntent.hunkId("src/b.cpp", 0), shared.targetHunkId()); + assertTrue(shared.label().contains("JmpCtxScope"), shared.label()); + assertTrue(shared.label().contains("b.cpp"), shared.label()); + } + + /** + * Deduplicated by target hunk: {@code a.cpp} both calls {@code guards.cpp} + * and shares {@code JmpCtxScope} with it, and that is one link, not two. + */ + @Test + void aTargetHunkIsLinkedOnce() { + List path = pathOf(new UnifiedDiff(List.of( + file("src/guards.cpp", "class JmpCtxScope { };"), + file("src/a.cpp", "void a() { new JmpCtxScope(); }"), + file("src/b.cpp", "void b() { new JmpCtxScope(); }"))), NO_FAN_IN); + + for (ReadingPath.Step step : path) { + List targets = + step.links().stream().map(ReadingPath.Link::targetHunkId).toList(); + assertEquals(targets.stream().distinct().toList(), targets, step.hunkId()); + } + } + + /** + * The reviewer's case: a three-hunk file where only hunk 0 references the + * changed symbol. A link renders as a footer beneath ONE hunk (§7.2), so + * a file-level answer spread over the file would ship "calls guards.cpp" + * under two hunks that call nothing -- a false statement about a specific + * hunk, not a soft one about the file. + */ + @Test + void aLinkSitsOnlyOnTheHunkThatMakesTheReference() { + List path = pathOf(new UnifiedDiff(List.of( + file("src/guards.cpp", "class JmpCtxScope { };"), + multiHunk("src/big.cpp", + "void one() { new JmpCtxScope(); }", + "void two() { }", + "void three() { }"))), NO_FAN_IN); + + assertEquals(List.of("calls"), kindsOn(path, ReviewIntent.hunkId("src/big.cpp", 0))); + assertEquals(List.of(), kindsOn(path, ReviewIntent.hunkId("src/big.cpp", 1))); + assertEquals(List.of(), kindsOn(path, ReviewIntent.hunkId("src/big.cpp", 2))); + } + + /** + * The guards hunk is linked from the hunk that uses it, and only that + * one: "called by" points at a hunk, not at a file's first hunk. + */ + @Test + void aCalledByLinkPointsAtTheHunkThatMakesTheCall() { + List path = pathOf(new UnifiedDiff(List.of( + file("src/guards.cpp", "class JmpCtxScope { };"), + multiHunk("src/big.cpp", + "void one() { }", + "void two() { new JmpCtxScope(); }"))), NO_FAN_IN); + + ReadingPath.Link link = stepFor(path, "src/guards.cpp").links().stream() + .filter(candidate -> candidate.kind().equals("called by")) + .findFirst().orElseThrow(); + assertEquals(ReviewIntent.hunkId("src/big.cpp", 1), link.targetHunkId()); + } + + /** + * Deduplication by target hunk drops one direction of a mutual pair. At + * hunk granularity that only happens where the same two HUNKS reference + * each other -- where the directions sit in different hunks, both + * survive, which they did not when links were a file's answer copied + * onto each of its hunks. + */ + @Test + void aMutualPairKeepsBothDirectionsWhenTheHunksDiffer() { + List path = pathOf(new UnifiedDiff(List.of( + multiHunk("src/alpha.cpp", "class Alpha { };", "void a() { new Beta(); }"), + multiHunk("src/beta.cpp", "class Beta { };", "void b() { new Alpha(); }"))), + NO_FAN_IN); + + assertEquals(List.of("called by"), + kindsOn(path, ReviewIntent.hunkId("src/alpha.cpp", 0))); + assertEquals(List.of("calls"), + kindsOn(path, ReviewIntent.hunkId("src/alpha.cpp", 1))); + assertEquals(ReviewIntent.hunkId("src/beta.cpp", 1), + linkOn(path, ReviewIntent.hunkId("src/alpha.cpp", 0)).targetHunkId()); + assertEquals(ReviewIntent.hunkId("src/beta.cpp", 0), + linkOn(path, ReviewIntent.hunkId("src/alpha.cpp", 1)).targetHunkId()); + } + + /** Same concept points at the hunk that touches the symbol, not at hunk 0. */ + @Test + void aSameConceptLinkPointsAtTheHunkThatTouchesTheSymbol() { + List path = pathOf(new UnifiedDiff(List.of( + file("src/guards.cpp", "class JmpCtxScope { };"), + multiHunk("src/a.cpp", "void a0() { }", "void a1() { new JmpCtxScope(); }"), + multiHunk("src/b.cpp", "void b0() { }", "void b1() { new JmpCtxScope(); }"))), + NO_FAN_IN); + + ReadingPath.Link shared = linksOn(path, ReviewIntent.hunkId("src/a.cpp", 1)).stream() + .filter(link -> link.kind().equals("same concept")) + .findFirst().orElseThrow(); + assertEquals(ReviewIntent.hunkId("src/b.cpp", 1), shared.targetHunkId()); + assertEquals(List.of(), kindsOn(path, ReviewIntent.hunkId("src/a.cpp", 0))); + } + + /** Cross-file only: two hunks of one file are not a relationship. */ + @Test + void aFileDoesNotLinkToItself() { + UnifiedDiff.FileDiff both = new UnifiedDiff.FileDiff( + "src/solo.cpp", "M", 2, 0, false, false, + List.of(hunk(1, "class Solo { };"), hunk(40, "void use() { new Solo(); }"))); + + List path = pathOf(new UnifiedDiff(List.of(both)), NO_FAN_IN); + + assertEquals(2, path.size()); + assertTrue(path.stream().allMatch(step -> step.links().isEmpty())); + } + + /** + * Cross-FILE, not merely cross-hunk. Two overloads in one file both + * declare the name, so both hunks touch it -- and linking them would put + * a footer under a hunk pointing at its own file, which §6.3 excludes at + * every kind. + */ + @Test + void twoHunksOfOneFileSharingASymbolAreNotLinked() { + List path = pathOf(new UnifiedDiff(List.of( + multiHunk("src/solo.cpp", + "void render(int a) { }", + "void render(float b) { }"))), NO_FAN_IN); + + assertEquals(2, path.size()); + assertTrue(path.stream().allMatch(step -> step.links().isEmpty()), + path.toString()); + } + + @Test + void everyHunkIsOnThePathExactlyOnce() { + UnifiedDiff.FileDiff two = new UnifiedDiff.FileDiff( + "src/profiler.cpp", "M", 2, 0, false, false, + List.of(hunk(1, "void go() { new JmpCtxScope(); }"), hunk(40, "void stop() { }"))); + + List path = pathOf(new UnifiedDiff(List.of( + file("src/guards.cpp", "class JmpCtxScope { };"), two)), NO_FAN_IN); + + assertEquals(List.of( + ReviewIntent.hunkId("src/guards.cpp", 0), + ReviewIntent.hunkId("src/profiler.cpp", 0), + ReviewIntent.hunkId("src/profiler.cpp", 1)), + path.stream().map(ReadingPath.Step::hunkId).toList()); + } + + @Test + void everyStepCarriesTheSectionItsHunkIsIn() { + ReadingPath.Path path = fullPathOf(new UnifiedDiff(List.of( + file("src/guards.cpp", "class JmpCtxScope { };"), + file("src/profiler.cpp", "void go() { new JmpCtxScope(); }"))), NO_FAN_IN); + + for (ReadingPath.Step step : path.steps()) { + int expected = 0; + for (int index = 0; index < path.sections().size(); index++) { + if (path.sections().get(index).hunkIds().contains(step.hunkId())) { + expected = index + 1; + break; + } + } + assertNotEquals(0, expected, step.hunkId()); + assertEquals(expected, step.sectionNumber(), step.hunkId()); + } + } + + /** + * The rail and the path are one order. Sections orders its units by path + * -- it has no entry-point rank to consult -- so on this diff its own + * first card is NOT the entry point's section. A rail listing sections in + * that order while badging the entry point would put START HERE on card + * 2, which is the failure the rank-inside-the-sort rule exists to + * prevent, one level up. + */ + @Test + void theSectionOrderAgreesWithThePathAboutWhatComesFirst() { + UnifiedDiff diff = new UnifiedDiff(List.of( + file("src/internal.cpp", "class Internal { };"), + file("src/user.cpp", "void u() { new Internal(); }"), + file("src/zeta.cpp", "class PublicThing { };"))); + ChangeGraph graph = ChangeGraph.of(diff); + List asGrouped = Sections.of(diff, graph); + String entry = ReviewIntent.hunkId("src/zeta.cpp", 0); + + ReadingPath.Path path = + ReadingPath.of(diff, graph, asGrouped, fanIn("PublicThing", 1)); + + // The fixture is only worth anything if the two orders disagree. + assertFalse(asGrouped.get(0).hunkIds().contains(entry), asGrouped.toString()); + assertEquals(entry, path.steps().get(0).hunkId()); + assertTrue(path.steps().get(0).entryPoint()); + assertEquals(1, path.steps().get(0).sectionNumber()); + assertTrue(path.sections().get(0).hunkIds().contains(entry), + path.sections().toString()); + } + + /** + * A section the path never reaches goes to the end of the rail, not out + * of it. A card falling out is worse than one sitting last. + */ + @Test + void aSectionThePathNeverReachesIsKeptAtTheEnd() { + UnifiedDiff diff = new UnifiedDiff(List.of( + file("src/guards.cpp", "class JmpCtxScope { };"), + file("src/profiler.cpp", "void go() { new JmpCtxScope(); }"))); + ChangeGraph graph = ChangeGraph.of(diff); + Sections.Section orphan = new Sections.Section("Orphan", List.of(), + List.of(ReviewIntent.hunkId("src/gone.cpp", 0)), Optional.empty(), List.of()); + List withOrphan = new ArrayList<>(Sections.of(diff, graph)); + withOrphan.add(0, orphan); + + List ordered = + ReadingPath.of(diff, graph, withOrphan, NO_FAN_IN).sections(); + + assertEquals(withOrphan.size(), ordered.size()); + assertEquals(orphan, ordered.get(ordered.size() - 1)); + } + + /** Reordering the rail may not lose a card from it. */ + @Test + void everySectionKeepsItsPlaceInTheRail() { + UnifiedDiff diff = new UnifiedDiff(List.of( + file("src/internal.cpp", "class Internal { };"), + file("src/user.cpp", "void u() { new Internal(); }"), + file("src/zeta.cpp", "class PublicThing { };"))); + ChangeGraph graph = ChangeGraph.of(diff); + List asGrouped = Sections.of(diff, graph); + + List ordered = + ReadingPath.of(diff, graph, asGrouped, fanIn("PublicThing", 1)).sections(); + + assertEquals(asGrouped.size(), ordered.size()); + assertTrue(ordered.containsAll(asGrouped), ordered.toString()); + } + + @Test + void everyStepStatesWhyItSitsWhereItDoes() { + List path = pathOf(new UnifiedDiff(List.of( + file("src/guards.cpp", "class JmpCtxScope { };"), + file("src/profiler.cpp", "void go() { new JmpCtxScope(); }"))), NO_FAN_IN); + + assertTrue(path.stream().noneMatch(step -> step.reason().isBlank())); + } + + /** + * The reason points at the rail, in the rail's own notation (§7.1): the + * foundation says which sections reference it, and what builds on it says + * so the other way round. + */ + @Test + void theReasonNamesTheSectionsOnTheOtherEndOfTheEdge() { + List path = pathOf(new UnifiedDiff(List.of( + file("src/guards.cpp", "class JmpCtxScope { };"), + file("src/profiler.cpp", "void go() { new JmpCtxScope(); }"))), NO_FAN_IN); + + String foundation = stepFor(path, "src/guards.cpp").reason(); + String dependent = stepFor(path, "src/profiler.cpp").reason(); + assertTrue(foundation.matches("referenced by [①-⑳](, [①-⑳])*"), foundation); + assertTrue(dependent.matches("builds on [①-⑳](, [①-⑳])*"), dependent); + } + + /** + * "referenced by ①" on a row that is itself in ① tells a reviewer + * nothing, and a section carries the files its unit depends on, so an + * edge inside one section is the common case. The header and its + * same-basename implementation are one section, and the reason names the + * file instead. + */ + @Test + void aReasonNamesTheFileWhenTheEdgeStaysInsideOneSection() { + List path = pathOf(new UnifiedDiff(List.of( + file("src/guards.h", "class JmpCtxScope { };"), + file("src/guards.cpp", "void install() { new JmpCtxScope(); }"))), NO_FAN_IN); + + ReadingPath.Step header = stepFor(path, "src/guards.h"); + ReadingPath.Step implementation = stepFor(path, "src/guards.cpp"); + assertEquals(header.sectionNumber(), implementation.sectionNumber()); + assertEquals("referenced by guards.cpp", header.reason()); + } + + /** The reason names the count and the callers, not a bare "entry point". */ + @Test + void theReasonNamesWhatCallsItFromOutside() { + List path = pathOf(new UnifiedDiff(List.of( + file("src/api.cpp", "class PublicThing { };"))), fanIn("PublicThing", 7)); + + assertTrue(path.get(0).reason().contains("7"), path.get(0).reason()); + assertTrue(path.get(0).reason().contains("outside the change"), path.get(0).reason()); + } + + /** + * A scan that could not run is not a scan that found nothing. The reason + * for a file with no in-diff references says so, rather than implying + * that nothing outside the change uses it. + */ + @Test + void anUnavailableScanIsNotReadAsZero() { + UnifiedDiff diff = new UnifiedDiff(List.of(file("src/lonely.cpp", "class Lonely { };"))); + + String measured = pathOf(diff, NO_FAN_IN).get(0).reason(); + String unknown = pathOf(diff, new OutOfDiffFanIn.Result(Map.of(), true)).get(0).reason(); + + assertFalse(measured.contains("unknown"), measured); + assertTrue(unknown.contains("unknown"), unknown); + } + + @Test + void anEmptyDiffHasNoPath() { + assertEquals(List.of(), pathOf(new UnifiedDiff(List.of()), NO_FAN_IN)); + } + + private static List linksOn(List path, String hunkId) { + return path.stream().filter(step -> step.hunkId().equals(hunkId)) + .findFirst().orElseThrow().links(); + } + + private static List kindsOn(List path, String hunkId) { + return linksOn(path, hunkId).stream().map(ReadingPath.Link::kind).toList(); + } + + private static ReadingPath.Link linkOn(List path, String hunkId) { + return linksOn(path, hunkId).get(0); + } + + private static ReadingPath.Step stepFor(List path, String file) { + Optional found = + path.stream().filter(step -> step.file().equals(file)).findFirst(); + return found.orElseThrow(); + } +} diff --git a/app/src/test/java/app/drydock/review/RecheckAsymmetryTest.java b/app/src/test/java/app/drydock/review/RecheckAsymmetryTest.java new file mode 100644 index 00000000..42256d3c --- /dev/null +++ b/app/src/test/java/app/drydock/review/RecheckAsymmetryTest.java @@ -0,0 +1,254 @@ +package app.drydock.review; + +import app.drydock.state.json.JsonParser; +import app.drydock.state.json.JsonValue; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The asymmetry (spec §9.7). "Affected" applies, because it can only ever + * ADD reading and because it closes the blind spot the file-level relevance + * filter admits to. "Unaffected" is advice, because an agent wrong THAT way + * would cost an approval on code nobody re-read -- which is the outcome the + * whole reviewed-state model refuses. + * + *

This is the STORE's half. The translation from the positional {@code + * hunkId} an agent actually sends to the content digest a verdict is keyed + * by lives in {@code McpToolRouterRecheckTest}: every test here hands the + * store a digest directly and so exercises none of it.

+ */ +class RecheckAsymmetryTest { + + private static AnnotationStore store() throws IOException { + return new AnnotationStore(Files.createTempDirectory("drydock-recheck") + .resolve("annotations.json")); + } + + private static ReviewVerdict approved(String base) { + return new ReviewVerdict("scope-1", "digest-1", ReviewVerdict.Decision.APPROVED, + Optional.empty(), Instant.EPOCH, base, "head-1"); + } + + @Test + void anAffectedAssessmentMarksAVerdictTheFilterWouldHaveMissed() throws IOException { + AnnotationStore store = store(); + store.putVerdict(approved("base-1")); + store.putAssessment(new RecheckAssessment("scope-1", "digest-1", "base-1", "base-2", + true, "resolve() now returns nullptr on failure", Instant.EPOCH)); + + assertTrue(store.assessedAffected("scope-1", "digest-1", "base-1", "base-2")); + } + + @Test + void anUnaffectedAssessmentDoesNotClearTheVerdictsStaleness() throws IOException { + AnnotationStore store = store(); + store.putVerdict(approved("base-1")); + store.putAssessment(new RecheckAssessment("scope-1", "digest-1", "base-1", "base-2", + false, "the base change is in an unrelated subsystem", Instant.EPOCH)); + + assertFalse(store.assessedAffected("scope-1", "digest-1", "base-1", "base-2")); + assertTrue(store.verdict("scope-1", "digest-1").orElseThrow().staleAgainst("base-2"), + "an agent must not clear a human's approval"); + } + + /** + * The same pair, both directions, on ONE store -- so an implementation + * that let the later "unaffected" un-record the earlier "affected" (or + * vice versa) is caught. A test that only ever writes one assessment per + * key cannot tell an overwrite that is right from one that is wrong. + */ + @Test + void aLaterUnaffectedReplacesAnEarlierAffectedButStillClearsNothing() throws IOException { + AnnotationStore store = store(); + store.putVerdict(approved("base-1")); + store.putAssessment(new RecheckAssessment("scope-1", "digest-1", "base-1", "base-2", + true, "resolve() now returns nullptr", Instant.EPOCH)); + store.putAssessment(new RecheckAssessment("scope-1", "digest-1", "base-1", "base-2", + false, "second look: unrelated", Instant.EPOCH.plusSeconds(60))); + + // The agent is allowed to withdraw its OWN mark -- that only removes + // something the agent itself added. + assertFalse(store.assessedAffected("scope-1", "digest-1", "base-1", "base-2")); + // What it never touches is the human's verdict, which is still stale + // against the new base on the filter's own terms. + assertTrue(store.verdict("scope-1", "digest-1").orElseThrow().staleAgainst("base-2")); + assertEquals(ReviewVerdict.Decision.APPROVED, + store.verdict("scope-1", "digest-1").orElseThrow().decision()); + } + + /** An assessment is about one base pair; a later move is a new question. */ + @Test + void anAssessmentDoesNotCarryToADifferentBasePair() throws IOException { + AnnotationStore store = store(); + store.putAssessment(new RecheckAssessment("scope-1", "digest-1", "base-1", "base-2", + true, "why", Instant.EPOCH)); + + assertFalse(store.assessedAffected("scope-1", "digest-1", "base-2", "base-3")); + // Nor to a different hunk, nor to a different scope: every term of + // the key is load-bearing, and a fixture varying only one of them + // cannot say so. + assertFalse(store.assessedAffected("scope-1", "digest-2", "base-1", "base-2")); + assertFalse(store.assessedAffected("scope-2", "digest-1", "base-1", "base-2")); + assertTrue(store.assessedAffected("scope-1", "digest-1", "base-1", "base-2")); + } + + @Test + void assessmentsRoundTripThroughDisk() throws IOException { + Path file = Files.createTempDirectory("drydock-recheck").resolve("annotations.json"); + AnnotationStore store = new AnnotationStore(file); + store.putAssessment(new RecheckAssessment("scope-1", "digest-1", "base-1", "base-2", + true, "why", Instant.EPOCH)); + store.flushPendingSaves(); + + assertTrue(new AnnotationStore(file) + .assessedAffected("scope-1", "digest-1", "base-1", "base-2")); + } + + /** + * An "unaffected" survives a restart as an unaffected, rather than being + * dropped and re-read as "never asked". The two are the same to every + * caller, so a round trip that lost it would go unnoticed by + * {@link #assessmentsRoundTripThroughDisk} -- this reads the record + * itself. + */ + @Test + void anUnaffectedAssessmentIsPersistedRatherThanDroppedAsInert() throws IOException { + Path file = Files.createTempDirectory("drydock-recheck").resolve("annotations.json"); + AnnotationStore store = new AnnotationStore(file); + store.putAssessment(new RecheckAssessment("scope-1", "digest-1", "base-1", "base-2", + false, "unrelated subsystem", Instant.EPOCH)); + store.flushPendingSaves(); + + List reloaded = new AnnotationStore(file).assessmentsFor("scope-1"); + assertEquals(1, reloaded.size()); + assertFalse(reloaded.get(0).affected()); + assertEquals("unrelated subsystem", reloaded.get(0).why()); + } + + /** + * The schema version the store WRITES is 5. + * + *

Pinned on the constant's actual effect, because the bump is the only + * thing that tells a v4 file from a v5 one: without an assertion the + * constant can be reverted and every other test on this branch still + * passes, which the reviewer demonstrated by doing exactly that.

+ */ + @Test + void theWrittenSchemaVersionIsFive() throws IOException { + Path file = Files.createTempDirectory("drydock-recheck").resolve("annotations.json"); + AnnotationStore store = new AnnotationStore(file); + store.putAssessment(new RecheckAssessment("scope-1", "digest-1", "base-1", "base-2", + true, "why", Instant.EPOCH)); + store.flushPendingSaves(); + + JsonValue root = JsonParser.parse(Files.readString(file, StandardCharsets.UTF_8)); + assertEquals(5, ((JsonValue.JsonNumber) ((JsonValue.JsonObject) root).get("schemaVersion")) + .asInt(), + "persisting a new assessments array is a schema change; without the bump a v4 " + + "file and a v5 file are indistinguishable"); + } + + /** + * A file written before this task (schema 4, no {@code assessments} key) + * loads cleanly and yields no assessments. The branch has no migration + * and needs none -- {@code loadFromDisk} reads each named array + * independently -- but "old file loads cleanly" is pinned rather than + * assumed. + * + *

The {@code submitted} flag is what makes this test able to FAIL. A + * decode that threw on the missing key would be swallowed by {@code + * loadFromDisk}'s lenient catch, and the verdict read BEFORE it would + * survive in the map anyway -- so a fixture asserting only on assessments + * and verdicts passes just as well when the load blew up halfway. {@code + * submitted} is read after the assessments and is the first thing such a + * load would lose.

+ */ + @Test + void aFileWrittenBeforeAssessmentsExistedLoadsWithNoneAndKeepsEverythingElse() throws IOException { + Path file = Files.createTempDirectory("drydock-recheck").resolve("annotations.json"); + Files.writeString(file, """ + {"schemaVersion":4, + "annotations":[], + "verdicts":[{"scopeId":"scope-1","hunkDigest":"digest-1","verdict":"approved", + "at":"1970-01-01T00:00:00Z","base":"base-1","head":"head-1"}], + "submitted":["scope-1"]} + """, StandardCharsets.UTF_8); + + AnnotationStore store = new AnnotationStore(file); + + assertEquals(List.of(), store.assessmentsFor("scope-1")); + assertFalse(store.assessedAffected("scope-1", "digest-1", "base-1", "base-2")); + assertTrue(store.verdict("scope-1", "digest-1").isPresent(), + "the v4 verdict must survive the schema bump"); + assertTrue(store.isSubmitted("scope-1"), + "everything read after the assessments must survive too"); + } + + /** + * Dropping a scope drops its rechecks with it. A stale assessment left + * behind would answer for whatever scope handle the store minted next. + */ + @Test + void removingAScopeRemovesItsAssessments() throws IOException { + AnnotationStore store = store(); + store.putAssessment(new RecheckAssessment("scope-1", "digest-1", "base-1", "base-2", + true, "why", Instant.EPOCH)); + store.putAssessment(new RecheckAssessment("scope-2", "digest-1", "base-1", "base-2", + true, "why", Instant.EPOCH)); + + store.removeScope("scope-1"); + + assertFalse(store.assessedAffected("scope-1", "digest-1", "base-1", "base-2")); + assertTrue(store.assessedAffected("scope-2", "digest-1", "base-1", "base-2"), + "removing one scope must not take another's rechecks with it"); + } + + /** + * The branch's determinism bar: the same assessments come back in the + * order they arrived, byte for byte, across two separate store instances + * -- across PROCESSES, in effect, since a second instance re-decodes from + * disk with nothing carried over in memory. + * + *

Twenty of them, with digests running OPPOSITE to insertion order, so + * a hash-ordered map could not come out right by accident and a fixture + * of two or three could not tell the difference.

+ */ + @Test + void assessmentsKeepTheirArrivalOrderAcrossAReload() throws IOException { + Path file = Files.createTempDirectory("drydock-recheck").resolve("annotations.json"); + AnnotationStore store = new AnnotationStore(file); + List arrival = new java.util.ArrayList<>(); + for (int n = 19; n >= 0; n--) { + String digest = "digest-%02d".formatted(n); + arrival.add(digest); + store.putAssessment(new RecheckAssessment("scope-1", digest, "base-1", "base-2", + n % 2 == 0, "why " + n, Instant.EPOCH)); + } + store.flushPendingSaves(); + String firstText = Files.readString(file, StandardCharsets.UTF_8); + + AnnotationStore reloaded = new AnnotationStore(file); + assertEquals(arrival, reloaded.assessmentsFor("scope-1").stream() + .map(RecheckAssessment::hunkDigest).toList()); + + // Re-saving what was re-read reproduces the same bytes, re-stating the + // FIRST entry included: an overwrite that moved its key to the end + // would reorder the file, and an ordering that only survived because + // nothing had been round-tripped yet would drift here. + reloaded.putAssessment(new RecheckAssessment("scope-1", "digest-19", "base-1", "base-2", + false, "why 19", Instant.EPOCH)); + reloaded.flushPendingSaves(); + assertEquals(firstText, Files.readString(file, StandardCharsets.UTF_8)); + } +} diff --git a/app/src/test/java/app/drydock/review/RecheckDispatchTest.java b/app/src/test/java/app/drydock/review/RecheckDispatchTest.java new file mode 100644 index 00000000..83a33e7a --- /dev/null +++ b/app/src/test/java/app/drydock/review/RecheckDispatchTest.java @@ -0,0 +1,103 @@ +package app.drydock.review; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * One automatic recheck per base move, not one per render. + * + *

The store cannot answer this on its own: {@link + * AnnotationStore#assessedAffected} returns false both for "assessed + * unaffected" and for "never asked", so it cannot see a dispatch that has + * gone out and not yet come back. Every re-render inside that window would + * dispatch again. This is the record that closes it.

+ */ +class RecheckDispatchTest { + + @Test + void theFirstClaimOnAMoveSucceeds() { + assertTrue(new RecheckDispatch().claim("scope-1", "a1b2c3", "d4e5f6")); + } + + /** The window the store cannot see: dispatched, nothing assessed yet. */ + @Test + void aSecondClaimOnTheSameMoveIsRefused() { + RecheckDispatch dispatch = new RecheckDispatch(); + + assertTrue(dispatch.claim("scope-1", "a1b2c3", "d4e5f6")); + assertFalse(dispatch.claim("scope-1", "a1b2c3", "d4e5f6")); + } + + /** Keyed by the base PAIR, so a later move is a new question. */ + @Test + void aLaterBaseMoveIsANewQuestion() { + RecheckDispatch dispatch = new RecheckDispatch(); + dispatch.claim("scope-1", "a1b2c3", "d4e5f6"); + + assertTrue(dispatch.claim("scope-1", "d4e5f6", "999aaa")); + } + + /** + * Varies ONLY the destination. A move to a further base is a different + * question about the same approval, and a key that dropped {@code + * toBase} would call it already answered. + */ + @Test + void theSameStartingBaseMovingSomewhereElseIsANewQuestion() { + RecheckDispatch dispatch = new RecheckDispatch(); + dispatch.claim("scope-1", "a1b2c3", "d4e5f6"); + + assertTrue(dispatch.claim("scope-1", "a1b2c3", "999aaa")); + } + + /** + * Varies ONLY the origin. Two approvals in one scope can have been + * recorded against different bases and now face the same current one -- + * two distinct moves, each owed its own recheck. + */ + @Test + void twoApprovalsWithDifferentRecordedBasesAreSeparateQuestions() { + RecheckDispatch dispatch = new RecheckDispatch(); + dispatch.claim("scope-1", "a1b2c3", "999aaa"); + + assertTrue(dispatch.claim("scope-1", "d4e5f6", "999aaa")); + } + + @Test + void aDifferentScopeClaimsIndependently() { + RecheckDispatch dispatch = new RecheckDispatch(); + dispatch.claim("scope-1", "a1b2c3", "d4e5f6"); + + assertTrue(dispatch.claim("scope-2", "a1b2c3", "d4e5f6")); + } + + /** + * A hand-off that did not happen must not be remembered as done, or the + * scope never gets its recheck at all. Every existing caller of {@code + * sendToBoundSession} checks its boolean for this reason. + */ + @Test + void releasingAFailedHandOffAllowsARetry() { + RecheckDispatch dispatch = new RecheckDispatch(); + assertTrue(dispatch.claim("scope-1", "a1b2c3", "d4e5f6")); + + dispatch.release("scope-1", "a1b2c3", "d4e5f6"); + + assertTrue(dispatch.claim("scope-1", "a1b2c3", "d4e5f6")); + } + + /** + * The three parts are joined, so a separator that could appear inside one + * of them would let two different moves collide on one key. Scope handles + * are arbitrary strings; commits are not. + */ + @Test + void movesThatDifferOnlyInWhereTheirPartsSplitDoNotCollide() { + RecheckDispatch dispatch = new RecheckDispatch(); + assertTrue(dispatch.claim("s", "a-b", "c")); + + assertTrue(dispatch.claim("s-a", "b", "c")); + } +} diff --git a/app/src/test/java/app/drydock/review/ReviewInstructionsRecheckTest.java b/app/src/test/java/app/drydock/review/ReviewInstructionsRecheckTest.java new file mode 100644 index 00000000..8007787e --- /dev/null +++ b/app/src/test/java/app/drydock/review/ReviewInstructionsRecheckTest.java @@ -0,0 +1,56 @@ +package app.drydock.review; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * A recheck is a small bounded task -- it reads one base delta and the stale + * hunks, not the change -- which is why it earns a dispatch of its own rather + * than a full re-review (spec §9.7). + */ +class ReviewInstructionsRecheckTest { + + @Test + void itNamesBothBasesAndTheTool() { + String instruction = ReviewInstructions.forRecheck("scope-1", "a1b2c3", "d4e5f6"); + + assertTrue(instruction.contains("a1b2c3")); + assertTrue(instruction.contains("d4e5f6")); + assertTrue(instruction.contains("review_recheck")); + assertTrue(instruction.contains("subagent")); + } + + /** + * The bases are ORDERED, not merely mentioned: "between {@code toBase} and + * {@code fromBase}" asks the agent to read the delta backwards, and a + * containment check on each base separately cannot tell the two apart -- + * both are present either way. + */ + @Test + void itReadsTheDeltaFromTheOldBaseToTheNew() { + assertTrue(ReviewInstructions.forRecheck("s", "a1b2c3", "d4e5f6") + .contains("between a1b2c3 and d4e5f6")); + } + + /** The agent must be told it cannot clear an approval, not left to infer it. */ + @Test + void itSaysThatUnaffectedIsAdviceOnly() { + assertTrue(ReviewInstructions.forRecheck("s", "a", "b").contains("does not clear")); + } + + @Test + void itNamesTheScopeHandle() { + assertTrue(ReviewInstructions.forRecheck("rs_abc123", "a", "b").contains("rs_abc123")); + } + + /** + * Delivered through TerminalBridge.sendPrompt, which types the string into + * a prompt: a newline would submit half an instruction. + */ + @Test + void itIsASingleLine() { + assertFalse(ReviewInstructions.forRecheck("s", "a", "b").contains("\n")); + } +} diff --git a/app/src/test/java/app/drydock/review/ReviewVerdictTest.java b/app/src/test/java/app/drydock/review/ReviewVerdictTest.java new file mode 100644 index 00000000..efaed820 --- /dev/null +++ b/app/src/test/java/app/drydock/review/ReviewVerdictTest.java @@ -0,0 +1,64 @@ +package app.drydock.review; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * A verdict names what it was given against (spec §9.2). A digest over the + * hunk's own text cannot see the base move underneath it, so the base is + * recorded and staleness is derived from it -- and "confirm still good" + * rewrites the recorded base rather than storing a fourth state. + */ +class ReviewVerdictTest { + + private static ReviewVerdict approvedAt(String base) { + return new ReviewVerdict("scope-1", "digest-1", ReviewVerdict.Decision.APPROVED, + Optional.empty(), Instant.EPOCH, base, "head-1"); + } + + @Test + void aVerdictIsKeyedByScopeAndHunkDigest() { + assertEquals(new ReviewVerdict.Key("scope-1", "digest-1"), approvedAt("base-1").key()); + } + + @Test + void aVerdictGivenAgainstTheCurrentBaseIsNotStale() { + assertFalse(approvedAt("base-1").staleAgainst("base-1")); + } + + @Test + void aVerdictGivenAgainstAnOlderBaseIsStale() { + assertTrue(approvedAt("base-1").staleAgainst("base-2")); + } + + /** + * Confirming rewrites the recorded base. Keeping a separate "confirmed" + * flag would mean two sources of truth for the same question, and the + * next base move would have to remember to clear it. + */ + @Test + void confirmingRewritesTheRecordedBaseAndClearsStaleness() { + ReviewVerdict confirmed = approvedAt("base-1") + .confirmedAgainst("base-2", "head-2", Instant.ofEpochSecond(10)); + + assertFalse(confirmed.staleAgainst("base-2")); + assertEquals("base-2", confirmed.baseCommit()); + assertEquals("head-2", confirmed.headCommit()); + assertEquals(ReviewVerdict.Decision.APPROVED, confirmed.decision()); + assertEquals("digest-1", confirmed.hunkDigest()); + } + + @Test + void aBlankHunkDigestIsRefused() { + assertThrows(IllegalArgumentException.class, () -> new ReviewVerdict( + "scope-1", " ", ReviewVerdict.Decision.APPROVED, + Optional.empty(), Instant.EPOCH, "base-1", "head-1")); + } +} diff --git a/app/src/test/java/app/drydock/review/SectionDeterminismTest.java b/app/src/test/java/app/drydock/review/SectionDeterminismTest.java new file mode 100644 index 00000000..69079655 --- /dev/null +++ b/app/src/test/java/app/drydock/review/SectionDeterminismTest.java @@ -0,0 +1,67 @@ +package app.drydock.review; + +import app.drydock.git.UnifiedDiff; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.OptionalInt; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Calling the computed layer stable is a claim the code has to keep + * (spec §9.5). The cheapest way to lose it is a hash-ordered collection, and + * the hardest place to notice is a single JVM, which usually agrees with + * itself. The cross-process half of that check is the running-app pass; this + * pins the in-process half and the shape the other half compares. + */ +class SectionDeterminismTest { + + private static UnifiedDiff diff() { + List files = new java.util.ArrayList<>(); + for (String path : List.of("src/z.cpp", "src/a.cpp", "src/m.h", "src/m.cpp")) { + files.add(new UnifiedDiff.FileDiff(path, "M", 1, 0, false, false, + List.of(new UnifiedDiff.Hunk("@@", List.of(new UnifiedDiff.Line( + UnifiedDiff.Line.Kind.ADD, OptionalInt.empty(), OptionalInt.of(1), + "void go() { helperOne(); }")))))); + } + return new UnifiedDiff(files); + } + + private static List titles() { + UnifiedDiff diff = diff(); + return Sections.of(diff, ChangeGraph.of(diff)).stream() + .map(Sections.Section::title).toList(); + } + + @Test + void theSameDiffProducesTheSameSectionsEveryTime() { + assertEquals(titles(), titles()); + } + + @Test + void theSameDiffProducesTheSameHunkOrderEveryTime() { + UnifiedDiff diff = diff(); + assertEquals(Sections.of(diff, ChangeGraph.of(diff)).stream() + .map(Sections.Section::hunkIds).toList(), + Sections.of(diff, ChangeGraph.of(diff)).stream() + .map(Sections.Section::hunkIds).toList()); + } + + /** A reviewer's grouping still wins; the computed one is the fallback. */ + @Test + void aReviewerGroupingIsNotRecomputed() { + IntentGrouping grouping = new IntentGrouping(); + ReviewIntent supplied = new ReviewIntent("agent-1", 1, "Crash-protected resolve()", + ReviewIntent.Kind.CHANGE, ReviewIntent.Risk.HIGH, "", + List.of(ReviewIntent.hunkId("src/a.cpp", 0)), java.util.Optional.empty(), false); + grouping.set("scope-1", List.of(supplied)); + + UnifiedDiff diff = diff(); + List intents = grouping.intentsFor("scope-1", diff, + java.util.Optional.of(ChangeGraph.of(diff))); + + assertEquals(List.of("Crash-protected resolve()"), + intents.stream().map(ReviewIntent::title).toList()); + } +} diff --git a/app/src/test/java/app/drydock/review/SectionsTest.java b/app/src/test/java/app/drydock/review/SectionsTest.java new file mode 100644 index 00000000..6a24e257 --- /dev/null +++ b/app/src/test/java/app/drydock/review/SectionsTest.java @@ -0,0 +1,361 @@ +package app.drydock.review; + +import app.drydock.git.UnifiedDiff; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.OptionalInt; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Sections follow the code's structure, not its folders (spec §5). + * + *

The failure this replaces, measured on a real C++ change: cards reading + * "main/cpp · 12 files", "test/cpp · 4 files", "cpp/hotspot · 6 files" -- + * each individually correct and collectively saying nothing, because the + * grouping had no structural input at all.

+ */ +class SectionsTest { + + private static UnifiedDiff.FileDiff file(String path, String... added) { + List lines = new ArrayList<>(); + int n = 1; + for (String text : added) { + lines.add(new UnifiedDiff.Line(UnifiedDiff.Line.Kind.ADD, + OptionalInt.empty(), OptionalInt.of(n++), text)); + } + return new UnifiedDiff.FileDiff(path, "M", added.length, 0, false, false, + List.of(new UnifiedDiff.Hunk("@@", lines))); + } + + private static List sectionsOf(UnifiedDiff diff) { + return Sections.of(diff, ChangeGraph.of(diff)); + } + + private static Sections.Section sectionContaining(List sections, String file) { + return sections.stream().filter(s -> s.files().contains(file)).findFirst().orElseThrow(); + } + + /** The convention a C or C++ change is unreadable without. */ + @Test + void aHeaderGroupsWithItsSameBasenameImplementation() { + List sections = sectionsOf(new UnifiedDiff(List.of( + file("src/guards.h", "class JmpCtxScope { };"), + file("src/guards.cpp", "void install() { }")))); + + assertTrue(sectionContaining(sections, "src/guards.h").files().contains("src/guards.cpp")); + } + + /** + * The counters.h case from the reference output: a header with no changed + * symbol of its own still belongs with the file that pulls it in. + */ + @Test + void aHeaderGroupsWithAChangedImplementationThatReferencesIt() { + List sections = sectionsOf(new UnifiedDiff(List.of( + file("src/counters.h", "#define FAULTS 1"), + file("src/profiler.cpp", "#include \"counters.h\"", "void loop() { }")))); + + assertTrue(sectionContaining(sections, "src/profiler.cpp").files().contains("src/counters.h")); + } + + /** + * The same rule through an {@code import}: the languages that spell the + * dependency with a dotted name get it too, not just {@code #include}. + */ + @Test + void anImportedFileWithNoChangedSymbolGroupsWithItsImporter() { + List sections = sectionsOf(new UnifiedDiff(List.of( + file("src/Constants.java", "// the shared table"), + file("src/Main.java", "import app.Constants;", "void run() { }")))); + + assertTrue(sectionContaining(sections, "src/Main.java").files().contains("src/Constants.java")); + } + + /** + * Naming a file in prose is not depending on it. A substring test over + * hunk text -- which is what the first sketch of this class did -- fires + * on comments, string literals and unrelated words, and would drag every + * file that mentions a header into that header's section. + */ + @Test + void aFileNameMentionedInACommentIsNotADependency() { + List sections = sectionsOf(new UnifiedDiff(List.of( + file("src/counters.h", "#define FAULTS 1"), + file("src/profiler.cpp", "#include \"counters.h\"", "void loop() { }"), + file("src/notes.cpp", "// counters.h explains the flag", "void notes() { }")))); + + assertTrue(sectionContaining(sections, "src/profiler.cpp").files().contains("src/counters.h")); + assertFalse(sectionContaining(sections, "src/notes.cpp").files().contains("src/counters.h"), + "a comment naming a header is not an include of it"); + } + + /** Overlap is the point (spec §5.6): a shared header appears in both. */ + @Test + void aFileNeededByTwoSectionsAppearsInBoth() { + List sections = sectionsOf(new UnifiedDiff(List.of( + file("src/guards.h", "class JmpCtxScope { };"), + file("src/a.cpp", "#include \"guards.h\"", "void alpha() { new JmpCtxScope(); }"), + file("src/b.cpp", "#include \"guards.h\"", "void beta() { new JmpCtxScope(); }")))); + + List withHeader = sections.stream() + .filter(s -> s.files().contains("src/guards.h")).toList(); + + assertTrue(withHeader.size() >= 2, "a shared header must appear wherever it is needed"); + // Discriminating: the appearances must be in genuinely different + // sections, not one section counted twice. + assertTrue(withHeader.stream().anyMatch(s -> s.files().contains("src/a.cpp"))); + assertTrue(withHeader.stream().anyMatch(s -> s.files().contains("src/b.cpp"))); + assertFalse(withHeader.stream() + .anyMatch(s -> s.files().containsAll(List.of("src/a.cpp", "src/b.cpp"))), + "the two consumers are separate changes; sharing a header does not merge them"); + } + + /** Foundation first: the guard is read before what uses it. */ + @Test + void sectionsAreOrderedByDependencyDirection() { + List sections = sectionsOf(new UnifiedDiff(List.of( + file("src/profiler.cpp", "void loop() { new JmpCtxScope(); }"), + file("src/guards.cpp", "class JmpCtxScope { };")))); + + assertEquals("src/guards.cpp", sections.get(0).files().get(0)); + } + + /** Within a section too: the file being depended on is read first. */ + @Test + void aSectionListsItsFoundationBeforeWhatUsesIt() { + List sections = sectionsOf(new UnifiedDiff(List.of( + file("src/aaa.cpp", "void loop() { new JmpCtxScope(); }"), + file("src/zzz.cpp", "class JmpCtxScope { };")))); + + Sections.Section user = sectionContaining(sections, "src/aaa.cpp"); + assertEquals(List.of("src/zzz.cpp", "src/aaa.cpp"), user.files(), + "alphabetical order would put aaa.cpp first; reading order must not"); + } + + /** A test referencing a changed symbol lands with it -- no path-based split. */ + @Test + void aTestReferencingAChangedSymbolIsInThatSymbolsSection() { + List sections = sectionsOf(new UnifiedDiff(List.of( + file("src/guards.cpp", "class JmpCtxScope { };"), + file("test/guards_ut.cpp", "void probe() { new JmpCtxScope(); }")))); + + assertTrue(sectionContaining(sections, "test/guards_ut.cpp") + .files().contains("src/guards.cpp")); + assertFalse(sections.stream().anyMatch(s -> s.files().equals(List.of("test/guards_ut.cpp"))), + "a tests-only section is the path heuristic this class replaces"); + } + + /** A test referencing nothing changed is its own section, honestly. */ + @Test + void aTestReferencingNothingChangedFormsItsOwnSection() { + List sections = sectionsOf(new UnifiedDiff(List.of( + file("src/guards.cpp", "class JmpCtxScope { };"), + file("test/unrelated_ut.cpp", "void probe() { checkSomethingElse(); }")))); + + assertEquals(List.of("test/unrelated_ut.cpp"), + sectionContaining(sections, "test/unrelated_ut.cpp").files()); + } + + /** The name is the thing, not the folder. */ + @Test + void aSectionIsTitledByItsHighestFanInChangedSymbol() { + List sections = sectionsOf(new UnifiedDiff(List.of( + file("src/guards.cpp", "class JmpCtxScope { };"), + file("src/a.cpp", "void alpha() { new JmpCtxScope(); }"), + file("src/b.cpp", "void beta() { new JmpCtxScope(); }")))); + + assertTrue(sections.get(0).title().startsWith("JmpCtxScope"), + "expected a hub-symbol title, got: " + sections.get(0).title()); + } + + /** + * Fan-in is counted per SYMBOL, not per file. Scoring every declaration + * with its file's fan-in -- which the first sketch of this class did -- + * makes the "hub" whatever sorts first alphabetically in the + * most-referenced file, which is not a claim about the code at all. + */ + @Test + void theHubIsTheMostReferencedSymbolNotTheFirstOneInTheFile() { + List sections = sectionsOf(new UnifiedDiff(List.of( + file("src/core.cpp", "class AaaHelper { };", "class ZzzEngine { };"), + file("src/one.cpp", "void one() { new ZzzEngine(); }"), + file("src/two.cpp", "void two() { new ZzzEngine(); }")))); + + Sections.Section core = sections.get(0); + assertEquals(Optional.of("ZzzEngine"), core.hubSymbol(), + "AaaHelper is referenced by nothing; it cannot be what the section is about"); + assertTrue(core.title().startsWith("ZzzEngine"), "got: " + core.title()); + } + + /** + * A card nobody can name, whose files another card already carries, is + * the folder failure coming back in through the side door: it would read + * "src · 1 file" and sit next to the sections that already show it. + */ + @Test + void aHublessSectionAlreadyCarriedElsewhereGetsNoCardOfItsOwn() { + List sections = sectionsOf(new UnifiedDiff(List.of( + file("src/counters.h", "#define FAULTS 1"), + file("src/one.cpp", "#include \"counters.h\"", "void one() { }"), + file("src/two.cpp", "#include \"counters.h\"", "void two() { }")))); + + assertFalse(sections.stream().anyMatch(s -> s.files().equals(List.of("src/counters.h"))), + "an unnameable card the rail already covers is noise"); + assertEquals(2, sections.stream().filter(s -> s.files().contains("src/counters.h")).count(), + "dropping the card must not drop the file from the sections that need it"); + } + + /** + * The flagship case, and the one the first cut got wrong: two files, two + * declarations, neither referenced by anything in a two-file change, so + * fan-in cannot separate them. The card must still read JmpCtxScope -- + * a folder title here is the exact failure this class was commissioned + * to fix. + */ + @Test + void aConventionJoinedPairIsTitledByItsTypeNotItsFolder() { + List sections = sectionsOf(new UnifiedDiff(List.of( + file("src/guards.h", "class JmpCtxScope { };"), + file("src/guards.cpp", "void install() { }")))); + + Sections.Section guard = sectionContaining(sections, "src/guards.h"); + assertEquals(Optional.of("JmpCtxScope"), guard.hubSymbol()); + assertTrue(guard.title().startsWith("JmpCtxScope"), "got: " + guard.title()); + } + + /** + * Fan-in on its own titles cards after loop variables. Measured on this + * repository's own branch, ranking by fan-in produced "hunk · 29 files" + * and "isEmpty", beating BaseMove and HunkDigest by a reference or two. + * A type is what a group of files is about; a member name is what they + * happen to have in common. + */ + @Test + void aTypeOutranksAMoreWidelyUsedMemberName() { + List sections = sectionsOf(new UnifiedDiff(List.of( + file("src/core.cpp", "class Widget { };", "void helper() { }"), + file("src/one.cpp", "void runOne() { helper(); }"), + file("src/two.cpp", "void runTwo() { helper(); }"), + file("src/three.cpp", "void runThree() { new Widget(); }")))); + + Sections.Section core = sectionContaining(sections, "src/core.cpp"); + assertEquals(Optional.of("Widget"), core.hubSymbol(), + "helper has the higher fan-in and is still not what the section is about"); + } + + /** + * FallbackIntents guarantees two cards can never read the same, because + * a grouping is only useful if its entries can be told apart. This makes + * the same guarantee: on the first real diff it was run against, three + * cards read identically. + */ + @Test + void noTwoCardsReadTheSame() { + List sections = sectionsOf(new UnifiedDiff(List.of( + file("a/util.cpp", "void alpha() { }", "void bravo() { }"), + file("b/util.cpp", "void charlie() { }", "void delta() { }"), + file("src/guards.cpp", "class JmpCtxScope { };"), + file("src/user.cpp", "void use() { new JmpCtxScope(); }")))); + + List titles = sections.stream().map(Sections.Section::title).toList(); + assertEquals(titles.size(), titles.stream().distinct().count(), "duplicate titles: " + titles); + assertTrue(titles.contains("a/util.cpp · 1 file"), "got: " + titles); + assertTrue(titles.contains("b/util.cpp · 1 file"), "got: " + titles); + } + + /** + * A card names something it actually contains. The first cut read the + * directory off the first file in reading order -- a pulled-in + * foundation, not a member -- and titled a card after a package holding + * none of the files the card was about. + */ + @Test + void anUnnameableCardNamesAFileItIsActuallyAbout() { + List sections = sectionsOf(new UnifiedDiff(List.of( + file("src/guards.cpp", "class JmpCtxScope { };"), + file("other/mixed.cpp", "void alpha() { new JmpCtxScope(); }", "void bravo() { }")))); + + Sections.Section mixed = sectionContaining(sections, "other/mixed.cpp"); + assertEquals(Optional.empty(), mixed.hubSymbol(), "two unreferenced functions name nothing"); + assertTrue(mixed.files().contains("src/guards.cpp"), "guards is carried as foundation"); + assertTrue(mixed.title().startsWith("mixed.cpp"), + "the card must name a file it is about, got: " + mixed.title()); + } + + /** With nothing to consult, today's behaviour survives unchanged. */ + @Test + void anEdgelessDiffFallsBackToDirectoryClustering() { + UnifiedDiff diff = new UnifiedDiff(List.of( + file("web/a.zzz", "nothing"), file("web/b.zzz", "nothing"))); + + assertEquals(FallbackIntents.group(diff).size(), sectionsOf(diff).size()); + } + + /** A genuine mutual reference is reported as a cycle. */ + @Test + void mutuallyReferencingFilesAreOneSectionMarkedAsACycle() { + List sections = sectionsOf(new UnifiedDiff(List.of( + file("src/alpha.cpp", "class Alpha { };", "void useBeta() { new Beta(); }"), + file("src/beta.cpp", "class Beta { };", "void useAlpha() { new Alpha(); }")))); + + assertEquals(1, sections.size()); + assertEquals(List.of("src/alpha.cpp", "src/beta.cpp"), sections.get(0).cycleWith()); + } + + /** + * A header joined to its implementation by convention is not a cycle. + * Both are one unit, but nothing about the code depends on itself, and + * saying so would be a lie a reviewer acts on. + */ + @Test + void aConventionJoinedPairIsNotReportedAsACycle() { + List sections = sectionsOf(new UnifiedDiff(List.of( + file("src/guards.h", "class JmpCtxScope { };"), + file("src/guards.cpp", "void install() { }")))); + + assertEquals(List.of(), sectionContaining(sections, "src/guards.h").cycleWith()); + } + + /** Nothing may fall out of the rail: every hunk lands in some section. */ + @Test + void everyHunkAppearsInSomeSection() { + UnifiedDiff diff = new UnifiedDiff(List.of( + file("src/guards.h", "class JmpCtxScope { };"), + file("src/guards.cpp", "void install() { }"), + file("src/profiler.cpp", "void loop() { new JmpCtxScope(); }"), + file("src/orphan.cpp", "void lonely() { }"))); + List sections = sectionsOf(diff); + + for (UnifiedDiff.FileDiff file : diff.files()) { + String hunkId = ReviewIntent.hunkId(file.path(), 0); + assertTrue(sections.stream().anyMatch(s -> s.hunkIds().contains(hunkId)), + "no section carries " + hunkId); + } + } + + /** + * Determinism (spec §9.5) is a requirement, not a property. The input's + * own order must not reach the output -- that is the cheapest way for + * hash iteration to leak in unnoticed. + */ + @Test + void theSameChangeInADifferentFileOrderGivesTheSameSections() { + List files = List.of( + file("src/guards.h", "class JmpCtxScope { };"), + file("src/guards.cpp", "void install() { }"), + file("src/a.cpp", "#include \"guards.h\"", "void alpha() { new JmpCtxScope(); }"), + file("src/b.cpp", "#include \"guards.h\"", "void beta() { new JmpCtxScope(); }")); + List reversed = new ArrayList<>(files); + Collections.reverse(reversed); + + assertEquals(sectionsOf(new UnifiedDiff(files)), + sectionsOf(new UnifiedDiff(List.copyOf(reversed)))); + } +} diff --git a/app/src/test/java/app/drydock/review/SymbolScanTest.java b/app/src/test/java/app/drydock/review/SymbolScanTest.java new file mode 100644 index 00000000..7341faab --- /dev/null +++ b/app/src/test/java/app/drydock/review/SymbolScanTest.java @@ -0,0 +1,332 @@ +package app.drydock.review; + +import app.drydock.git.UnifiedDiff; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.OptionalInt; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * What a file contributes to the change graph (spec §4.2). Tree-sitter tells + * us a token is a declaration and another is a call; it does NOT tell us + * which declaration a call resolves to, so it raises the precision of + * classification and not the correctness of resolution. A file with no + * grammar therefore still contributes uses -- it simply cannot claim to + * declare anything, because a lexical scan cannot tell one from the other + * without guessing. + */ +class SymbolScanTest { + + private static UnifiedDiff.FileDiff file(String path, String... addedLines) { + List lines = new java.util.ArrayList<>(); + int n = 1; + for (String text : addedLines) { + lines.add(new UnifiedDiff.Line(UnifiedDiff.Line.Kind.ADD, + OptionalInt.empty(), OptionalInt.of(n++), text)); + } + return new UnifiedDiff.FileDiff(path, "M", addedLines.length, 0, false, false, + List.of(new UnifiedDiff.Hunk("@@ -1,0 +1," + addedLines.length + " @@", lines))); + } + + /** + * A file of several hunks, each starting at the line number given, so a + * symbol's hunk index can be told apart from its line. + */ + private static UnifiedDiff.FileDiff multiHunk(String path, String... oneLinePerHunk) { + List hunks = new java.util.ArrayList<>(); + int n = 1; + for (String text : oneLinePerHunk) { + hunks.add(new UnifiedDiff.Hunk("@@ -" + n + ",0 +" + n + ",1 @@", + List.of(new UnifiedDiff.Line(UnifiedDiff.Line.Kind.ADD, + OptionalInt.empty(), OptionalInt.of(n), text)))); + n += 20; + } + return new UnifiedDiff.FileDiff(path, "M", hunks.size(), 0, false, false, hunks); + } + + /** + * A symbol knows which hunk it is in, not just which file. Everything + * downstream that claims something about one hunk -- a link footer under + * it, most of all -- is false without this. + */ + @Test + void aSymbolKnowsWhichHunkItIsIn() { + List symbols = SymbolScan.of(multiHunk("src/guards.cpp", + "void one() { alpha(); }", + "void two() { }", + "void three() { beta(); }")); + + assertEquals(List.of(0), hunksOf(symbols, "alpha")); + assertEquals(List.of(2), hunksOf(symbols, "beta")); + assertEquals(List.of(0), hunksOf(symbols, "one")); + assertEquals(List.of(1), hunksOf(symbols, "two")); + assertEquals(List.of(2), hunksOf(symbols, "three")); + } + + /** A file with no grammar still places its names in the right hunk. */ + @Test + void anUngrammaredFileStillPlacesItsNamesInAHunk() { + List symbols = SymbolScan.of(multiHunk("build/setup.zig", + "const alpha = 1;", "const beta = 2;")); + + assertEquals(List.of(0), hunksOf(symbols, "alpha")); + assertEquals(List.of(1), hunksOf(symbols, "beta")); + } + + private static List hunksOf(List symbols, String name) { + return symbols.stream().filter(s -> s.name().equals(name)) + .map(SymbolScan.Symbol::hunk).distinct().sorted().toList(); + } + + private static boolean has(List symbols, String name, boolean declaration) { + return symbols.stream().anyMatch(s -> s.name().equals(name) + && s.declaration() == declaration); + } + + @Test + void aGrammarBackedFileDeclaresItsTypesAndMethods() { + List symbols = SymbolScan.of(file("src/Guards.java", + "class JmpCtxScope {", " void install() { helper(); }", "}")); + + assertTrue(has(symbols, "JmpCtxScope", true)); + assertTrue(has(symbols, "install", true)); + assertTrue(has(symbols, "helper", false)); + } + + /** + * The honest floor: no grammar means uses only. Claiming a declaration + * from a regex is exactly the guess this design refuses to make. + */ + @Test + void aFileWithNoGrammarContributesUsesButNoDeclarations() { + List symbols = SymbolScan.of(file("build/setup.zig", + "const JmpCtxScope = struct {};")); + + assertTrue(has(symbols, "JmpCtxScope", false)); + assertFalse(has(symbols, "JmpCtxScope", true)); + } + + /** SymbolWords is the shared vocabulary; keywords are not symbols. */ + @Test + void keywordsAndShortIdentifiersAreNotSymbols() { + List symbols = SymbolScan.of(file("build/setup.zig", + "return id;")); + + assertFalse(symbols.stream().anyMatch(s -> s.name().equals("return"))); + assertFalse(symbols.stream().anyMatch(s -> s.name().equals("id"))); + } + + /** Context lines are scanned but marked, so an edge can require a changed line. */ + @Test + void aSymbolOnAContextLineIsNotOnAChangedLine() { + UnifiedDiff.FileDiff file = new UnifiedDiff.FileDiff("src/Guards.java", "M", 0, 0, + false, false, List.of(new UnifiedDiff.Hunk("@@ -1,1 +1,1 @@", + List.of(new UnifiedDiff.Line(UnifiedDiff.Line.Kind.CONTEXT, + OptionalInt.of(1), OptionalInt.of(1), "helper();"))))); + + assertTrue(SymbolScan.of(file).stream() + .filter(s -> s.name().equals("helper")).noneMatch(SymbolScan.Symbol::onChangedLine)); + } + + /** + * The defect this file's one-line fixtures hid. A C++ class body spans + * lines in every real header; parsed a line at a time, the opening line + * alone is an incomplete construct whose name tree-sitter never reports, + * so the type vanished and only its members were declared. + * Deliberately multi-line -- a one-line fixture here proves nothing. + */ + @Test + void aMultiLineClassBodyStillDeclaresItsTypeName() { + List symbols = SymbolScan.of(file("src/guards.h", + "class JmpCtxScope {", + "public:", + " void arm();", + " void disarm();", + "};")); + + assertTrue(has(symbols, "JmpCtxScope", true)); + assertTrue(has(symbols, "arm", true)); + assertTrue(has(symbols, "disarm", true)); + } + + /** The same shape one level up: a multi-line Java type keeps its name. */ + @Test + void aMultiLineJavaTypeKeepsItsNameWhenTheBraceIsOnItsOwnLine() { + List symbols = SymbolScan.of(file("src/Guards.java", + "public final class JmpCtxScope", + " implements AutoCloseable", + "{", + " void install() { helper(); }", + "}")); + + assertTrue(has(symbols, "JmpCtxScope", true)); + assertTrue(has(symbols, "install", true)); + assertTrue(has(symbols, "helper", false)); + } + + /** + * A qualified name references its qualifier. Without this {@code + * guards.cpp} names nothing its own header declares, so the pair never + * links by symbol -- the qualifier IS the reference. + */ + @Test + void aQualifiedDefinitionReferencesItsQualifier() { + List symbols = SymbolScan.of(file("src/guards.cpp", + "#include \"guards.h\"", + "", + "void JmpCtxScope::arm() { }")); + + assertTrue(has(symbols, "JmpCtxScope", false)); + } + + /** + * Prose is not code. A design document quoting Java, or a stylesheet + * whose class names happen to spell a changed type, minted reference + * edges to every symbol it mentioned -- 15% of the edges on this + * branch's own diff. The "no grammar means uses only" rule was written + * for unsupported languages, not for a {@code .md} file. + */ + @Test + void aFileThatIsNotPlausiblyCodeContributesNothingAtAll() { + assertTrue(SymbolScan.of(file("docs/design.md", + "Then `JmpCtxScope` arms the guard:", + "", + " class JmpCtxScope { void arm(); }")).isEmpty()); + assertTrue(SymbolScan.of(file("app/src/main/resources/app.css", + ".sections-rail { -fx-padding: 4; }")).isEmpty()); + } + + /** + * A hunk holds ADD, DEL and CONTEXT lines at once. Both states are + * scanned: the deleted line's symbols are still reported, and still + * count as changed, so removing a call is part of the same change as + * what replaced it. + */ + @Test + void deletedLinesAreScannedAndCountAsChanged() { + UnifiedDiff.FileDiff file = new UnifiedDiff.FileDiff("src/guards.h", "M", 1, 1, + false, false, List.of(new UnifiedDiff.Hunk("@@ -1,4 +1,4 @@", List.of( + context(1, 1, "class JmpCtxScope {"), + context(2, 2, "public:"), + deleted(3, " void armOld();"), + added(3, " void armNew();"), + context(4, 4, "};"))))); + + List symbols = SymbolScan.of(file); + + assertTrue(has(symbols, "armOld", true)); + assertTrue(has(symbols, "armNew", true)); + assertTrue(symbols.stream().filter(s -> s.name().equals("armOld")) + .allMatch(SymbolScan.Symbol::onChangedLine)); + assertTrue(symbols.stream().filter(s -> s.name().equals("armNew")) + .allMatch(SymbolScan.Symbol::onChangedLine)); + // The type name comes from context lines only, so it is not changed. + assertTrue(has(symbols, "JmpCtxScope", true)); + assertTrue(symbols.stream().filter(s -> s.name().equals("JmpCtxScope")) + .noneMatch(SymbolScan.Symbol::onChangedLine)); + } + + /** + * A context line is scanned once, not once per parsed state. Reporting + * it twice would be harmless to {@link ChangeGraph} (its collections are + * sets) and a lie to anything that counts. + */ + @Test + void aContextLineIsReportedOnceEvenWhenBothStatesAreParsed() { + UnifiedDiff.FileDiff file = new UnifiedDiff.FileDiff("src/guards.cpp", "M", 1, 1, + false, false, List.of(new UnifiedDiff.Hunk("@@ -1,3 +1,3 @@", List.of( + context(1, 1, "void install(JmpCtxScope scope) {"), + deleted(2, " oldHelper();"), + added(2, " newHelper();"), + context(3, 3, "}"))))); + + assertEquals(1, SymbolScan.of(file).stream() + .filter(s -> s.name().equals("JmpCtxScope")).count()); + } + + /** + * A name that appears only in documentation contributes nothing. This + * is the largest single behaviour change per-hunk parsing brought, and + * it arrived as a side effect, so it is asserted rather than assumed. + * + *

The fixture is deliberately a MULTI-LINE block comment. A whole + * {@code //} line, or a one-line {@code /** ... *}{@code /}, was already + * a comment node to the line-at-a-time scan and never leaked. What + * leaked was an INTERIOR line of a block comment: {@code * ranks + * {@code BaseMove} above {@link HunkDigest}} is not + * a comment on its own, so it lexed as bare identifiers and minted real + * reference edges -- 108 of this branch's 337. Verified against the + * pre-change class: it reports all five doc names, this reports none. + * A {@link SymbolScan} change that starts descending into comments + * again would otherwise restore all 108 with no signal.

+ */ + @Test + void aNameThatAppearsOnlyInACommentIsNotAUse() { + List symbols = SymbolScan.of(file("src/Guards.java", + "/**", + " * Ranks {@code BaseMove} above {@link HunkDigest}, leaving", + " * {@link SectionStates.Staleness#UNKNOWN} last.", + " */", + "class Guards {", + " void install() { helper(); }", + "}")); + + assertFalse(named(symbols, "BaseMove")); + assertFalse(named(symbols, "HunkDigest")); + assertFalse(named(symbols, "SectionStates")); + assertFalse(named(symbols, "Staleness")); + assertFalse(named(symbols, "UNKNOWN")); + // The code around the documentation is still read. + assertTrue(has(symbols, "Guards", true)); + assertTrue(has(symbols, "install", true)); + assertTrue(has(symbols, "helper", false)); + } + + private static boolean named(List symbols, String name) { + return symbols.stream().anyMatch(s -> s.name().equals(name)); + } + + /** + * A hunk fragment is many lines of UTF-8, and tree-sitter answers in + * BYTE offsets. A multi-byte character on an early line shifts every + * later offset, so a line table counted in characters would slice the + * wrong bytes out of a later name and attribute it to the wrong line. + */ + @Test + void aMultiByteCharacterEarlierInTheHunkDoesNotShiftLaterSymbols() { + UnifiedDiff.FileDiff file = new UnifiedDiff.FileDiff("src/guards.h", "M", 1, 0, + false, false, List.of(new UnifiedDiff.Hunk("@@ -1,3 +1,4 @@", List.of( + context(1, 1, "// naïve — a guard, 日本語 too"), + context(2, 2, "class JmpCtxScope {"), + added(3, " void arm();"), + context(3, 4, "};"))))); + + List symbols = SymbolScan.of(file); + + assertTrue(has(symbols, "arm", true)); + assertTrue(symbols.stream().filter(s -> s.name().equals("arm")) + .allMatch(SymbolScan.Symbol::onChangedLine)); + assertTrue(has(symbols, "JmpCtxScope", true)); + assertTrue(symbols.stream().filter(s -> s.name().equals("JmpCtxScope")) + .noneMatch(SymbolScan.Symbol::onChangedLine)); + } + + private static UnifiedDiff.Line context(int oldLine, int newLine, String text) { + return new UnifiedDiff.Line(UnifiedDiff.Line.Kind.CONTEXT, + OptionalInt.of(oldLine), OptionalInt.of(newLine), text); + } + + private static UnifiedDiff.Line added(int newLine, String text) { + return new UnifiedDiff.Line(UnifiedDiff.Line.Kind.ADD, + OptionalInt.empty(), OptionalInt.of(newLine), text); + } + + private static UnifiedDiff.Line deleted(int oldLine, String text) { + return new UnifiedDiff.Line(UnifiedDiff.Line.Kind.DEL, + OptionalInt.of(oldLine), OptionalInt.empty(), text); + } +} diff --git a/app/src/test/java/app/drydock/review/VerdictMergeTest.java b/app/src/test/java/app/drydock/review/VerdictMergeTest.java new file mode 100644 index 00000000..7eff043b --- /dev/null +++ b/app/src/test/java/app/drydock/review/VerdictMergeTest.java @@ -0,0 +1,78 @@ +package app.drydock.review; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * How a section's state follows from its hunks (spec §9.1). The asymmetry is + * the point and it is not new -- it is the rule migrateLegacyVerdicts was + * written around, promoted from a one-off migration to the live derivation: + * "something in here needs work" survives any redrawing of the group, while + * approving a group claims the human read all of it. + */ +class VerdictMergeTest { + + private static Optional of(ReviewVerdict.Decision decision) { + return Optional.of(new ReviewVerdict("s", "d" + decision.ordinal(), decision, + Optional.empty(), Instant.EPOCH, "base", "head")); + } + + private static final Optional UNSETTLED = Optional.empty(); + + @Test + void everyHunkApprovedApprovesTheSection() { + assertEquals(Optional.of(ReviewVerdict.Decision.APPROVED), + VerdictMerge.derive(List.of(of(ReviewVerdict.Decision.APPROVED), + of(ReviewVerdict.Decision.APPROVED)))); + } + + /** Any changes request survives however the group is drawn. */ + @Test + void oneChangesRequestMakesTheWholeSectionChanges() { + assertEquals(Optional.of(ReviewVerdict.Decision.CHANGES), + VerdictMerge.derive(List.of(of(ReviewVerdict.Decision.APPROVED), + of(ReviewVerdict.Decision.CHANGES)))); + } + + /** + * The outcome this must never produce: approving code nobody looked at. + * A section with one unread hunk is not approved, it is unsettled. + */ + @Test + void oneUnsettledHunkLeavesTheSectionUnsettled() { + assertEquals(Optional.empty(), + VerdictMerge.derive(List.of(of(ReviewVerdict.Decision.APPROVED), UNSETTLED))); + } + + /** But a changes request outranks an unread hunk: it is already true. */ + @Test + void changesWinsEvenWithAnUnsettledHunkPresent() { + assertEquals(Optional.of(ReviewVerdict.Decision.CHANGES), + VerdictMerge.derive(List.of(of(ReviewVerdict.Decision.CHANGES), UNSETTLED))); + } + + @Test + void autoApprovalCountsAsSettledAndIsReportedAsItself() { + assertEquals(Optional.of(ReviewVerdict.Decision.AUTO_APPROVED), + VerdictMerge.derive(List.of(of(ReviewVerdict.Decision.AUTO_APPROVED), + of(ReviewVerdict.Decision.AUTO_APPROVED)))); + } + + /** A human approval outranks the agent's assertion in the label. */ + @Test + void aMixOfHumanAndAutoApprovalReadsAsApproved() { + assertEquals(Optional.of(ReviewVerdict.Decision.APPROVED), + VerdictMerge.derive(List.of(of(ReviewVerdict.Decision.AUTO_APPROVED), + of(ReviewVerdict.Decision.APPROVED)))); + } + + @Test + void anEmptySectionHasNoDecision() { + assertEquals(Optional.empty(), VerdictMerge.derive(List.of())); + } +} diff --git a/app/src/test/java/app/drydock/ui/HandoffBannerTest.java b/app/src/test/java/app/drydock/ui/HandoffBannerTest.java index dbfa831b..9d9065f6 100644 --- a/app/src/test/java/app/drydock/ui/HandoffBannerTest.java +++ b/app/src/test/java/app/drydock/ui/HandoffBannerTest.java @@ -38,8 +38,7 @@ class HandoffBannerTest extends ApplicationTest { @Override public void start(Stage stage) { banner = new HandoffBanner(); - stage.setScene(new Scene(new StackPane(banner), 800, 100)); - stage.show(); + TestStages.show(stage, new Scene(new StackPane(banner), 800, 100)); } private static Optional brief() { diff --git a/app/src/test/java/app/drydock/ui/ModalLayerTest.java b/app/src/test/java/app/drydock/ui/ModalLayerTest.java index 6c79fbf5..f3945918 100644 --- a/app/src/test/java/app/drydock/ui/ModalLayerTest.java +++ b/app/src/test/java/app/drydock/ui/ModalLayerTest.java @@ -32,8 +32,7 @@ class ModalLayerTest extends ApplicationTest { @Override public void start(Stage stage) { layer = new ModalLayer(); - stage.setScene(new Scene(new StackPane(layer), 400, 300)); - stage.show(); + TestStages.show(stage, new Scene(new StackPane(layer), 400, 300)); } @Test diff --git a/app/src/test/java/app/drydock/ui/NewWorktreeModalTest.java b/app/src/test/java/app/drydock/ui/NewWorktreeModalTest.java index 4d4d6e59..116ef55d 100644 --- a/app/src/test/java/app/drydock/ui/NewWorktreeModalTest.java +++ b/app/src/test/java/app/drydock/ui/NewWorktreeModalTest.java @@ -91,8 +91,7 @@ public void start(Stage stage) { : null; }); - stage.setScene(new Scene(new StackPane(modal), 620, 640)); - stage.show(); + TestStages.show(stage, new Scene(new StackPane(modal), 620, 640)); } /** diff --git a/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java b/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java index b8298eb2..3ca80391 100644 --- a/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java +++ b/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java @@ -4,6 +4,8 @@ import app.drydock.domain.ManagedSessionId; import app.drydock.git.DiffService; import app.drydock.git.UnifiedDiff; +import app.drydock.review.BaseMove; +import app.drydock.review.ChangeGraph; import app.drydock.review.ReviewAnnotation; import app.drydock.review.ReviewIntent; import app.drydock.review.ReviewScope; @@ -33,6 +35,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.TreeSet; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -56,8 +59,7 @@ class OpenSessionTabReviewSubTabTest extends ApplicationTest { @Override public void start(Stage stage) { this.stage = stage; - stage.setScene(new Scene(new StackPane(), 200, 200)); - stage.show(); + TestStages.show(stage, new Scene(new StackPane(), 200, 200)); } @AfterEach @@ -485,17 +487,49 @@ public List findings(ReviewScope scope) { } @Override - public List intents(ReviewScope scope, UnifiedDiff diff) { + public List intents(ReviewScope scope, UnifiedDiff diff, + Optional graph) { return List.of(); } @Override - public Optional verdict(ReviewScope scope, ReviewIntent intent) { + public long groupingVersion(ReviewScope scope) { + return 0; + } + + @Override + public boolean hasReviewerGrouping(ReviewScope scope) { + return false; + } + + @Override + public Optional verdict(ReviewScope scope, String hunkDigest) { return Optional.empty(); } @Override - public void setVerdict(ReviewScope scope, ReviewIntent intent, Optional decision) { + public void setVerdict(ReviewScope scope, ReviewIntent intent, List hunkDigests, + Optional decision, boolean blocked) { + } + + @Override + public void confirmStillGood(ReviewScope scope, List hunkDigests) { + } + + @Override + public String currentBase(ReviewScope scope) { + return SessionReviewView.UNRESOLVED_BASE; + } + + @Override + public BaseMove.Delta baseMove(ReviewScope scope, String recordedBase) { + return new BaseMove.Delta(true, new TreeSet<>()); + } + + @Override + public boolean assessedAffected(ReviewScope scope, String hunkDigest, + String fromBase, String toBase) { + return false; } @Override @@ -523,7 +557,8 @@ public void overrideSeverity(ReviewScope scope, ReviewAnnotation finding, Severi } @Override - public void askAgentToFix(ReviewScope scope, ReviewIntent intent, List findings) { + public boolean askAgentToFix(ReviewScope scope, ReviewIntent intent, List findings) { + return false; } @Override @@ -534,5 +569,20 @@ public void submit(ReviewScope scope, SubmitPlan.DiffIndex index, List Optional.of(3)); - stage.setScene(new Scene(new StackPane(sidebar), 420, 640)); - stage.show(); + TestStages.show(stage, new Scene(new StackPane(sidebar), 420, 640)); repository = repositoryManager.addRepository(repoRoot).get(20, TimeUnit.SECONDS); session = sessionOnWorktree(repository, PrLink.of(PrState.OPEN, Optional.of(PR_NUMBER))); diff --git a/app/src/test/java/app/drydock/ui/SessionHeaderLayoutTest.java b/app/src/test/java/app/drydock/ui/SessionHeaderLayoutTest.java index 0e25e65f..0e3d8bd2 100644 --- a/app/src/test/java/app/drydock/ui/SessionHeaderLayoutTest.java +++ b/app/src/test/java/app/drydock/ui/SessionHeaderLayoutTest.java @@ -71,8 +71,7 @@ public void start(Stage stage) { scene.getStylesheets().setAll( SessionHeaderLayoutTest.class.getResource("/app/drydock/ui/theme-dark.css").toExternalForm(), SessionHeaderLayoutTest.class.getResource("/app/drydock/ui/app.css").toExternalForm()); - stage.setScene(scene); - stage.show(); + TestStages.show(stage, scene); } @BeforeEach diff --git a/app/src/test/java/app/drydock/ui/SettingsModalSkimRowTest.java b/app/src/test/java/app/drydock/ui/SettingsModalSkimRowTest.java index fc5f4d40..253103f6 100644 --- a/app/src/test/java/app/drydock/ui/SettingsModalSkimRowTest.java +++ b/app/src/test/java/app/drydock/ui/SettingsModalSkimRowTest.java @@ -80,8 +80,7 @@ public CompletableFuture saveOpenChangedFilesInSkim(boolean value) { scene.getStylesheets().addAll( SettingsModal.class.getResource("/app/drydock/ui/theme-dark.css").toExternalForm(), SettingsModal.class.getResource("/app/drydock/ui/app.css").toExternalForm()); - stage.setScene(scene); - stage.show(); + TestStages.show(stage, scene); } @Test diff --git a/app/src/test/java/app/drydock/ui/ShortcutsOverlayParityTest.java b/app/src/test/java/app/drydock/ui/ShortcutsOverlayParityTest.java index 0bbcb881..99c653f6 100644 --- a/app/src/test/java/app/drydock/ui/ShortcutsOverlayParityTest.java +++ b/app/src/test/java/app/drydock/ui/ShortcutsOverlayParityTest.java @@ -62,7 +62,7 @@ void theReviewBoardAdvertisesExactlyTheKeysItBinds() { .collect(Collectors.toSet()); Set bound = Set.of("d", "c", "m", "i", "\\", "[", "]", "n", "a", "r", "u", - "⏎", "⇧F", "f"); + "⏎", "⇧F", "f", "⇧A", "⇧R", "p"); assertEquals(bound, advertised, "the overlay's IN REVIEW rows and what SessionReviewView.handleShortcut " diff --git a/app/src/test/java/app/drydock/ui/TestStages.java b/app/src/test/java/app/drydock/ui/TestStages.java new file mode 100644 index 00000000..6877255d --- /dev/null +++ b/app/src/test/java/app/drydock/ui/TestStages.java @@ -0,0 +1,57 @@ +package app.drydock.ui; + +import javafx.scene.Scene; +import javafx.stage.Stage; + +/** + * Shows a TestFX scene on a stage that is sized to it, explicitly. + * + *

Why this exists. TestFX hands every test class in a JVM + * the SAME primary stage, and a stage remembers an explicit size across + * classes. While no class ever set one, {@code stage.show()} sized itself to + * whatever {@code Scene} it had just been given and every class silently got + * the width it asked for. One class does set one -- {@code + * ReviewVerdictBarFitTest} must, since the width IS what it tests -- and from + * that moment every later class in the run inherits it instead of its own.

+ * + *

Nothing about that failure names a width. It surfaces as a class whose + * clicks land on nothing ("no clickable gutter") or whose geometry assertion + * silently inverts: {@code ReviewDiffColumnWidthTest}'s wrap check passes at + * an inherited 560px and FAILS at an inherited 1400px, because a 400-character + * line stops needing to wrap. Which way it falls depends on the order the + * classes happen to run in, so it presents as flakiness -- and it cost this + * branch two rounds of chasing exactly that.

+ * + *

The rule, so it does not have to be re-derived: a test class that + * renders anything owns its own stage size. Take it from the scene + * the class already declares rather than from a number repeated beside it, so + * the two cannot drift.

+ */ +public final class TestStages { + + private TestStages() { + } + + /** + * Sets {@code scene} on {@code stage}, sizes the stage to it, and shows + * it. The size comes from the scene's own constructed dimensions, so + * there is no second copy of the number to keep in step. + */ + public static void show(Stage stage, Scene scene) { + stage.setScene(scene); + if (scene.getWidth() > 0 && scene.getHeight() > 0) { + stage.setWidth(scene.getWidth()); + stage.setHeight(scene.getHeight()); + } else { + // A scene built without dimensions -- new Scene(root) -- has none + // to copy, and setting them anyway pins the stage at 0x0: every + // node lays out at nothing and the class fails naming no width, + // which is the exact signature this helper exists to eliminate. + // sizeToScene is what the plain stage.show() this replaced would + // have done, so an unsized scene keeps its old behaviour AND + // stops inheriting whatever the last class left. + stage.sizeToScene(); + } + stage.show(); + } +} diff --git a/app/src/test/java/app/drydock/ui/TestStagesTest.java b/app/src/test/java/app/drydock/ui/TestStagesTest.java new file mode 100644 index 00000000..5dd04af4 --- /dev/null +++ b/app/src/test/java/app/drydock/ui/TestStagesTest.java @@ -0,0 +1,66 @@ +package app.drydock.ui; + +import javafx.scene.Scene; +import javafx.scene.control.Label; +import javafx.scene.layout.StackPane; +import javafx.stage.Stage; +import org.junit.jupiter.api.Test; +import org.testfx.framework.junit5.ApplicationTest; +import org.testfx.util.WaitForAsyncUtils; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The helper every rendering test now goes through is itself a place a stage + * can end up the wrong size -- and a helper that promises "sizes the stage to + * it" and quietly produces 0x0 would fail exactly the way this whole round + * exists to stop: a class laying out at nothing, naming no width. + */ +class TestStagesTest extends ApplicationTest { + + @Override + public void start(Stage stage) { + TestStages.show(stage, new Scene(new StackPane(new Label("host")), 400, 300)); + } + + /** + * A scene built WITHOUT dimensions has none to copy. Copying them anyway + * pins the stage at 0x0; {@code TestStages} falls back to + * {@code sizeToScene()}, which is what the plain {@code stage.show()} it + * replaced would have done. + */ + @Test + void anUnsizedSceneStillGetsAStageWithSizeInIt() { + double[] size = new double[2]; + interact(() -> { + Stage extra = new Stage(); + TestStages.show(extra, new Scene(new StackPane(new Label("a label with real width")))); + size[0] = extra.getWidth(); + size[1] = extra.getHeight(); + extra.hide(); + }); + WaitForAsyncUtils.waitForFxEvents(); + + assertTrue(size[0] > 0 && size[1] > 0, + "an unsized scene must still yield a stage with size in it, got " + + Math.round(size[0]) + "x" + Math.round(size[1])); + } + + /** The ordinary case: the stage takes the size the scene declares. */ + @Test + void aSizedSceneSetsTheStageToItsOwnDimensions() { + double[] size = new double[2]; + interact(() -> { + Stage extra = new Stage(); + TestStages.show(extra, new Scene(new StackPane(), 640, 480)); + size[0] = extra.getWidth(); + size[1] = extra.getHeight(); + extra.hide(); + }); + WaitForAsyncUtils.waitForFxEvents(); + + assertTrue(Math.abs(size[0] - 640) < 1 && Math.abs(size[1] - 480) < 1, + "the stage must take the scene's own size, got " + + Math.round(size[0]) + "x" + Math.round(size[1])); + } +} diff --git a/app/src/test/java/app/drydock/ui/explorer/SearchRailViewTest.java b/app/src/test/java/app/drydock/ui/explorer/SearchRailViewTest.java index f5861d30..19a82d0b 100644 --- a/app/src/test/java/app/drydock/ui/explorer/SearchRailViewTest.java +++ b/app/src/test/java/app/drydock/ui/explorer/SearchRailViewTest.java @@ -1,5 +1,6 @@ package app.drydock.ui.explorer; +import app.drydock.ui.TestStages; import app.drydock.search.SessionSearchService; import javafx.scene.Node; import javafx.scene.Scene; @@ -117,8 +118,7 @@ public void start(Stage stage) { scene.getStylesheets().addAll( SearchRail.class.getResource("/app/drydock/ui/theme-dark.css").toExternalForm(), SearchRail.class.getResource("/app/drydock/ui/app.css").toExternalForm()); - stage.setScene(scene); - stage.show(); + TestStages.show(stage, scene); WaitForAsyncUtils.waitForFxEvents(); } diff --git a/app/src/test/java/app/drydock/ui/explorer/SessionExplorerViewTest.java b/app/src/test/java/app/drydock/ui/explorer/SessionExplorerViewTest.java index da18618d..0efef04e 100644 --- a/app/src/test/java/app/drydock/ui/explorer/SessionExplorerViewTest.java +++ b/app/src/test/java/app/drydock/ui/explorer/SessionExplorerViewTest.java @@ -1,5 +1,6 @@ package app.drydock.ui.explorer; +import app.drydock.ui.TestStages; import app.drydock.search.SessionSearchService; import javafx.geometry.Pos; import javafx.scene.Node; @@ -78,8 +79,7 @@ void layoutChildren() { scene.getStylesheets().addAll( SessionExplorerView.class.getResource("/app/drydock/ui/theme-dark.css").toExternalForm(), SessionExplorerView.class.getResource("/app/drydock/ui/app.css").toExternalForm()); - stage.setScene(scene); - stage.show(); + TestStages.show(stage, scene); } @AfterEach diff --git a/app/src/test/java/app/drydock/ui/explorer/SkimViewTest.java b/app/src/test/java/app/drydock/ui/explorer/SkimViewTest.java index 7dd8caa1..b6c11e36 100644 --- a/app/src/test/java/app/drydock/ui/explorer/SkimViewTest.java +++ b/app/src/test/java/app/drydock/ui/explorer/SkimViewTest.java @@ -1,5 +1,6 @@ package app.drydock.ui.explorer; +import app.drydock.ui.TestStages; import javafx.scene.Node; import javafx.scene.Scene; import javafx.scene.control.Button; @@ -62,8 +63,7 @@ public void start(Stage stage) { scene.getStylesheets().addAll( SkimView.class.getResource("/app/drydock/ui/theme-dark.css").toExternalForm(), SkimView.class.getResource("/app/drydock/ui/app.css").toExternalForm()); - stage.setScene(scene); - stage.show(); + TestStages.show(stage, scene); } private void show(Set changed, Map findings) { diff --git a/app/src/test/java/app/drydock/ui/review/BlockingFindingAcrossOverlappingIntentsTest.java b/app/src/test/java/app/drydock/ui/review/BlockingFindingAcrossOverlappingIntentsTest.java new file mode 100644 index 00000000..ded9c52e --- /dev/null +++ b/app/src/test/java/app/drydock/ui/review/BlockingFindingAcrossOverlappingIntentsTest.java @@ -0,0 +1,161 @@ +package app.drydock.ui.review; + +import app.drydock.ui.TestStages; +import app.drydock.git.DiffService; +import app.drydock.git.UnifiedDiff; +import app.drydock.review.AnnotationStatus; +import app.drydock.review.Confidence; +import app.drydock.review.HunkDigest; +import app.drydock.review.ReviewAnnotation; +import app.drydock.review.ReviewIntent; +import app.drydock.review.ReviewScope; +import app.drydock.review.ReviewScopeRegistry; +import app.drydock.review.ReviewVerdict; +import app.drydock.review.Severity; +import app.drydock.review.SessionReviewScopes; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.input.KeyCode; +import javafx.stage.Stage; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.testfx.framework.junit5.ApplicationTest; +import org.testfx.util.WaitForAsyncUtils; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.OptionalInt; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The verdict bar's own rendered "blocked" and the write path a keypress + * takes must agree, for exactly the case where the two disagreed: a finding + * naming an intent that STILL EXISTS, filed against a file a DIFFERENT + * intent also touches. + * + *

Before this test existed, {@code MainWorkspace.blockingFindingOpen} + * fell back to file overlap unconditionally, while {@code + * SessionReviewView.belongsToCurrentIntent} (what the bar renders "blocked" + * from) only falls back when the named id no longer resolves to anything. + * The result: the bar showed Beta clear, {@code a} silently refused it + * anyway, and nothing on screen said why.

+ */ +class BlockingFindingAcrossOverlappingIntentsTest extends ApplicationTest { + + private static final String FILE_A = "src/A.java"; + private static final String FILE_B = "src/B.java"; + + private final DiffService diffService = new DiffService(); + private final ReviewScopeRegistry registry = new ReviewScopeRegistry(); + private FakeReviewHost host; + private SessionReviewView view; + private ReviewScope scope; + + @Override + public void start(Stage stage) { + try { + host = new FakeReviewHost(Files.createTempDirectory("drydock-overlap-block") + .resolve("annotations.json")); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + host.diff = new UnifiedDiff(List.of(file(FILE_A), file(FILE_B))); + view = new SessionReviewView(host, diffService, null); + Scene scene = new Scene(view, 1400, 900); + scene.getStylesheets().addAll( + getClass().getResource("/app/drydock/ui/app.css").toExternalForm(), + getClass().getResource("/app/drydock/ui/theme-dark.css").toExternalForm()); + TestStages.show(stage, scene); + } + + @AfterEach + void tearDown() { + diffService.close(); + host.store.close(); + } + + private static UnifiedDiff.FileDiff file(String path) { + return new UnifiedDiff.FileDiff(path, "M", 1, 0, false, false, + List.of(new UnifiedDiff.Hunk("@@", List.of(new UnifiedDiff.Line( + UnifiedDiff.Line.Kind.ADD, OptionalInt.empty(), OptionalInt.of(1), "x"))))); + } + + /** Alpha covers only A; Beta covers A and B -- they overlap on A. */ + private void showOverlappingIntents() { + scope = registry.mint(ReviewScopeRegistry.spec(ReviewScope.Kind.WORKING_TREE, + Path.of("/tmp/nowhere"), Optional.of(Path.of("/tmp/nowhere")), "main", "main", + Optional.empty(), Optional.empty())); + host.intents.set(scope.id(), List.of( + new ReviewIntent("alpha-id", 0, "Alpha", ReviewIntent.Kind.CHANGE, ReviewIntent.Risk.MED, + "", List.of(ReviewIntent.hunkId(FILE_A, 0)), Optional.empty(), false), + new ReviewIntent("beta-id", 0, "Beta", ReviewIntent.Kind.CHANGE, ReviewIntent.Risk.MED, + "", List.of(ReviewIntent.hunkId(FILE_A, 0), ReviewIntent.hunkId(FILE_B, 0)), + Optional.empty(), false))); + interact(() -> view.showScopes(new SessionReviewScopes.Scopes(scope, Optional.empty()), + SessionReviewScopes.Choice.LOCAL)); + interact(() -> view.diagShowDiff(scope, host.diff)); + WaitForAsyncUtils.waitForFxEvents(); + } + + private void addBlockingFindingNaming(String intentId, String file) { + host.store.upsert(new ReviewAnnotation(scope.id(), "f1", Optional.of(intentId), file, "n1", "n1", + Severity.BLOCKING, Confidence.HIGH, Optional.of("blocker"), "Claude", Instant.EPOCH, + List.of(), Optional.empty(), Optional.empty(), List.of(), List.of(), + Optional.empty(), AnnotationStatus.OPEN, Optional.empty(), false)); + } + + private void selectCard(int index) { + List cards = new ArrayList<>(lookup(".review-intent-card").queryAll()); + interact(((Button) cards.get(index))::fire); + WaitForAsyncUtils.waitForFxEvents(); + } + + private boolean isApproved(String file) { + String digest = HunkDigest.of(file, host.diff.files().stream() + .filter(f -> f.path().equals(file)).findFirst().orElseThrow().hunks().get(0)); + return host.store.verdict(scope.id(), digest) + .filter(v -> v.decision() == ReviewVerdict.Decision.APPROVED) + .isPresent(); + } + + @Test + void approvingAnIntentTheFindingDoesNotNameIsNotBlockedByAnOverlappingFile() { + showOverlappingIntents(); + addBlockingFindingNaming("alpha-id", FILE_A); + + // Beta is the second card; it shares FILE_A with Alpha, but the + // finding names Alpha specifically, and Alpha still exists. + selectCard(1); + type(KeyCode.A); + WaitForAsyncUtils.waitForFxEvents(); + + assertTrue(isApproved(FILE_B), + "Beta must be approvable: the blocking finding names Alpha, a real, " + + "different, still-current intent -- not Beta"); + assertFalse(lookup(".review-verdict-refusal").queryAll().stream() + .anyMatch(Node::isVisible), + "the bar must not have shown Beta as blocked either"); + } + + @Test + void approvingTheIntentTheFindingActuallyNamesIsStillBlocked() { + showOverlappingIntents(); + addBlockingFindingNaming("alpha-id", FILE_A); + + // Alpha is the first card, and the finding names it directly. + selectCard(0); + type(KeyCode.A); + WaitForAsyncUtils.waitForFxEvents(); + + assertFalse(isApproved(FILE_A), "Alpha must stay refused: the finding names it by id"); + } +} diff --git a/app/src/test/java/app/drydock/ui/review/DiffLineSelectionTest.java b/app/src/test/java/app/drydock/ui/review/DiffLineSelectionTest.java index a5390cd7..8297d939 100644 --- a/app/src/test/java/app/drydock/ui/review/DiffLineSelectionTest.java +++ b/app/src/test/java/app/drydock/ui/review/DiffLineSelectionTest.java @@ -21,7 +21,7 @@ private static ReviewDiffRow.Line line(String file, int newLine) { } private static ReviewDiffRow.HunkHeader header(String file) { - return new ReviewDiffRow.HunkHeader(file, "L1-2", 1, false, false); + return new ReviewDiffRow.HunkHeader(file, "L1-2", 1, false, false, 0); } @Test diff --git a/app/src/test/java/app/drydock/ui/review/FakeReviewHost.java b/app/src/test/java/app/drydock/ui/review/FakeReviewHost.java index 844a9eb8..a5954c12 100644 --- a/app/src/test/java/app/drydock/ui/review/FakeReviewHost.java +++ b/app/src/test/java/app/drydock/ui/review/FakeReviewHost.java @@ -3,6 +3,8 @@ import app.drydock.git.UnifiedDiff; import app.drydock.review.AnnotationStatus; import app.drydock.review.AnnotationStore; +import app.drydock.review.BaseMove; +import app.drydock.review.ChangeGraph; import app.drydock.review.IntentGrouping; import app.drydock.review.ReviewAnnotation; import app.drydock.review.ReviewIntent; @@ -16,6 +18,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.TreeSet; /** * A {@link SessionReviewView.Host} backed by a real {@link AnnotationStore} @@ -33,6 +36,18 @@ final class FakeReviewHost implements SessionReviewView.Host { final IntentGrouping intents = new IntentGrouping(); final List handedOffPrompts = new ArrayList<>(); + + /** Every automatic recheck asked for, as {@code fromBase->toBase}. */ + final List recheckDispatches = new ArrayList<>(); + + /** Whether the recheck hand-off reaches a terminal; false stands in for a closed tab. */ + boolean recheckHandOffSucceeds = true; + + /** Whether this scope's agent may be asked automatically (spec §9.7). */ + boolean supportsAutomaticRecheck = true; + + /** Per-recorded-base deltas; {@link #baseDelta} answers for any base not listed. */ + final java.util.Map baseDeltaByRecordedBase = new java.util.HashMap<>(); final List submittedScopes = new ArrayList<>(); final List explorerJumps = new ArrayList<>(); @@ -43,6 +58,22 @@ final class FakeReviewHost implements SessionReviewView.Host { /** What {@link #intents} groups by when no reviewer has supplied a grouping. */ UnifiedDiff diff = new UnifiedDiff(List.of()); + /** + * What {@code scope.base()} / {@code scope.head()} RESOLVE to. Refs are + * branch names; a verdict stamped with one and compared against the same + * one could never be stale, so the real host resolves them through git + * and this fake stands in for that answer. Tests move {@link #baseCommit} + * to make a verdict stale. + */ + String baseCommit = "1".repeat(40); + String headCommit = "2".repeat(40); + + /** + * What a base move touched, as {@code BaseMove.between} would report it. + * Empty and resolvable by default: a move that provably could not matter. + */ + BaseMove.Delta baseDelta = new BaseMove.Delta(false, new TreeSet<>()); + /** Whether the Explorer jump can succeed (no session bound = false). */ boolean explorerAvailable; @@ -95,38 +126,91 @@ public List findings(ReviewScope scope) { } @Override - public List intents(ReviewScope scope, UnifiedDiff diff) { - List grouped = intents.intentsFor(scope.id(), diff); - // The real host migrates here too. A fake that skipped it would be - // fine right up until the migration broke, which is the one moment a - // fake earns its keep. - store.migrateLegacyVerdicts(scope.id(), grouped); - return grouped; + public List intents(ReviewScope scope, UnifiedDiff diff, + Optional graph) { + return intents.intentsFor(scope.id(), diff, graph); + } + + @Override + public long groupingVersion(ReviewScope scope) { + return intents.version(scope.id()); + } + + @Override + public boolean hasReviewerGrouping(ReviewScope scope) { + return intents.hasReviewerGrouping(scope.id()); } @Override - public Optional verdict(ReviewScope scope, ReviewIntent intent) { - return store.verdict(scope.id(), intent.id()); + public Optional verdict(ReviewScope scope, String hunkDigest) { + return store.verdict(scope.id(), hunkDigest); } @Override - public void setVerdict(ReviewScope scope, ReviewIntent intent, - Optional decision) { + public void setVerdict(ReviewScope scope, ReviewIntent intent, List hunkDigests, + Optional decision, boolean blocked) { if (decision.isEmpty()) { - store.clearVerdict(scope.id(), intent.id()); + for (String digest : hunkDigests) { + store.clearVerdict(scope.id(), digest); + } return; } - if (decision.get() == ReviewVerdict.Decision.APPROVED && blocked(scope, intent)) { + if (decision.get() == ReviewVerdict.Decision.APPROVED && blocked) { return; } - store.putVerdict(new ReviewVerdict(scope.id(), intent.id(), decision.get(), - Optional.empty(), Instant.now())); + for (String digest : hunkDigests) { + store.putVerdict(new ReviewVerdict(scope.id(), digest, decision.get(), + Optional.empty(), Instant.now(), baseCommit, headCommit)); + } } - private boolean blocked(ReviewScope scope, ReviewIntent intent) { - return store.forScope(scope.id()).stream() - .filter(finding -> finding.intentId().map(id -> id.equals(intent.id())).orElse(true)) - .anyMatch(ReviewAnnotation::blocksApproval); + @Override + public void confirmStillGood(ReviewScope scope, List hunkDigests) { + Instant now = Instant.now(); + for (String digest : hunkDigests) { + store.verdict(scope.id(), digest).ifPresent(verdict -> + store.putVerdict(verdict.confirmedAgainst(baseCommit, headCommit, now))); + } + } + + @Override + public String currentBase(ReviewScope scope) { + return baseCommit; + } + + @Override + public BaseMove.Delta baseMove(ReviewScope scope, String recordedBase) { + // Per RECORDED base, like the real host, which memoizes one delta per + // (scope, oldBase, newBase). Returning one field for every base was a + // fiction that made a whole defect class untestable: two approvals + // recorded at different bases genuinely can resolve differently -- + // one MOVED, one still in flight, one provably irrelevant -- and a + // fake that collapses them cannot express it. + return baseDeltaByRecordedBase.getOrDefault(recordedBase, baseDelta); + } + + /** Reads the real store, so a test drives this through {@code putAssessment}. */ + @Override + public boolean assessedAffected(ReviewScope scope, String hunkDigest, + String fromBase, String toBase) { + return store.assessedAffected(scope.id(), hunkDigest, fromBase, toBase); + } + + @Override + public boolean supportsAutomaticRecheck(ReviewScope scope) { + return supportsAutomaticRecheck; + } + + /** Reads the real store, like {@link #assessedAffected}. */ + @Override + public boolean assessedMove(ReviewScope scope, String fromBase, String toBase) { + return store.assessedMove(scope.id(), fromBase, toBase); + } + + @Override + public boolean dispatchRecheck(ReviewScope scope, String fromBase, String toBase) { + recheckDispatches.add(fromBase + "->" + toBase); + return recheckHandOffSucceeds; } @Override @@ -165,12 +249,20 @@ public void overrideSeverity(ReviewScope scope, ReviewAnnotation finding, Severi store.mutate(finding.key(), current -> current.withSeverityOverride(severity)); } + /** + * Whether a session is bound to hand work to. False models the real + * "no session, or its tab is closed" case, which the real host reports + * through {@code sendToBoundSession}'s own boolean. + */ + boolean sessionBound = true; + @Override - public void askAgentToFix(ReviewScope scope, ReviewIntent intent, List findings) { - if (findings.isEmpty()) { - return; + public boolean askAgentToFix(ReviewScope scope, ReviewIntent intent, List findings) { + if (findings.isEmpty() || !sessionBound) { + return false; } handedOffPrompts.add(intent.title() + ": " + findings.size() + " findings"); + return true; } @Override diff --git a/app/src/test/java/app/drydock/ui/review/ReviewCarriedOverVerdictTest.java b/app/src/test/java/app/drydock/ui/review/ReviewCarriedOverVerdictTest.java deleted file mode 100644 index e0546d58..00000000 --- a/app/src/test/java/app/drydock/ui/review/ReviewCarriedOverVerdictTest.java +++ /dev/null @@ -1,167 +0,0 @@ -package app.drydock.ui.review; - -import app.drydock.git.DiffService; -import app.drydock.git.UnifiedDiff; -import app.drydock.review.ReviewScope; -import app.drydock.review.ReviewScopeRegistry; -import app.drydock.review.ReviewVerdict; -import app.drydock.review.SessionReviewScopes; - -import javafx.scene.Node; -import javafx.scene.Scene; -import javafx.scene.control.Label; -import javafx.stage.Stage; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; -import org.testfx.framework.junit5.ApplicationTest; -import org.testfx.util.WaitForAsyncUtils; - -import java.io.IOException; -import java.io.UncheckedIOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.OptionalInt; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -/** - * An approval given under the old per-file grouping still reads as settled. - * - *

The store-level rules are covered by {@code LegacyVerdictMigrationTest}; - * what this pins is that the migration is actually WIRED -- that opening - * Review on a scope with pre-existing verdicts runs it, and that the rail and - * the progress count reflect the result. A migration nothing calls is worth - * nothing.

- */ -class ReviewCarriedOverVerdictTest extends ApplicationTest { - - private final DiffService diffService = new DiffService(); - private final ReviewScopeRegistry registry = new ReviewScopeRegistry(); - private FakeReviewHost host; - private SessionReviewView view; - private ReviewScope scope; - - @Override - public void start(Stage stage) { - try { - host = new FakeReviewHost(Files.createTempDirectory("drydock-carryover") - .resolve("annotations.json")); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - // Two directories: two intents, so "one settled of two" is observable. - host.diff = new UnifiedDiff(List.of(file("src/Main.java"), file("web/Other.java"))); - view = new SessionReviewView(host, diffService, null); - Scene scene = new Scene(view, 1400, 900); - scene.getStylesheets().addAll( - getClass().getResource("/app/drydock/ui/app.css").toExternalForm(), - getClass().getResource("/app/drydock/ui/theme-dark.css").toExternalForm()); - stage.setScene(scene); - stage.show(); - } - - @AfterEach - void tearDown() { - diffService.close(); - host.store.close(); - } - - @Test - void anOldPerFileApprovalStillCountsAsSettled() { - seedLegacyVerdict("file:src/Main.java", ReviewVerdict.Decision.APPROVED); - - showQueue(); - - assertEquals(ReviewVerdict.Decision.APPROVED, - host.store.verdict(scope.id(), "auto:change:src").orElseThrow().decision(), - "opening Review must carry the old approval onto the new intent id"); - } - - @Test - void theRailShowsTheCarriedOverIntentAsSettled() { - seedLegacyVerdict("file:src/Main.java", ReviewVerdict.Decision.APPROVED); - - showQueue(); - - assertTrue(settledCardCount() >= 1, - "a carried-over approval must dim its card, or the review looks undone"); - } - - /** The count in the verdict bar is what tells the human they are finished. */ - @Test - void theProgressCountIncludesCarriedOverVerdicts() { - seedLegacyVerdict("file:src/Main.java", ReviewVerdict.Decision.APPROVED); - seedLegacyVerdict("file:web/Other.java", ReviewVerdict.Decision.APPROVED); - - showQueue(); - - assertTrue(progressText().startsWith("2/2"), - "both approvals must carry over; progress read " + progressText()); - } - - @Test - void anOldChangeRequestCarriesOverToo() { - seedLegacyVerdict("file:src/Main.java", ReviewVerdict.Decision.CHANGES); - - showQueue(); - - assertEquals(ReviewVerdict.Decision.CHANGES, - host.store.verdict(scope.id(), "auto:change:src").orElseThrow().decision()); - } - - /** Nothing to carry must not disturb a scope that was never reviewed. */ - @Test - void aScopeWithNoOldVerdictsIsUntouched() { - showQueue(); - - assertTrue(host.store.verdictsFor(scope.id()).isEmpty()); - assertTrue(progressText().startsWith("0/2"), "progress read " + progressText()); - } - - // ---- helpers -------------------------------------------------------- - - private void seedLegacyVerdict(String intentId, ReviewVerdict.Decision decision) { - scope = registry.mint(ReviewScopeRegistry.spec(ReviewScope.Kind.WORKING_TREE, - Path.of("/tmp/nowhere"), Optional.of(Path.of("/tmp/nowhere")), "main", "main", - Optional.empty(), Optional.empty())); - host.store.putVerdict(new ReviewVerdict(scope.id(), intentId, decision, - Optional.empty(), Instant.EPOCH)); - } - - private void showQueue() { - if (scope == null) { - scope = registry.mint(ReviewScopeRegistry.spec(ReviewScope.Kind.WORKING_TREE, - Path.of("/tmp/nowhere"), Optional.of(Path.of("/tmp/nowhere")), "main", "main", - Optional.empty(), Optional.empty())); - } - interact(() -> view.showScopes(new SessionReviewScopes.Scopes(scope, Optional.empty()), - SessionReviewScopes.Choice.LOCAL)); - interact(() -> view.diagShowDiff(scope, host.diff)); - WaitForAsyncUtils.waitForFxEvents(); - } - - private long settledCardCount() { - List cards = new ArrayList<>(); - interact(() -> cards.addAll(lookup(".review-intent-card").queryAll())); - return cards.stream().filter(card -> card.getStyleClass().contains("settled")).count(); - } - - private String progressText() { - List labels = new ArrayList<>(); - interact(() -> labels.addAll(lookup(".review-verdict-progress-label").queryAll())); - return labels.stream().map(node -> ((Label) node).getText()) - .findFirst().orElse(""); - } - - private static UnifiedDiff.FileDiff file(String path) { - return new UnifiedDiff.FileDiff(path, "M", 1, 0, false, false, List.of( - new UnifiedDiff.Hunk("@@ -1 +1 @@", List.of( - new UnifiedDiff.Line(UnifiedDiff.Line.Kind.ADD, OptionalInt.empty(), - OptionalInt.of(1), "int x = 1;"))))); - } -} diff --git a/app/src/test/java/app/drydock/ui/review/ReviewCommentComposerTest.java b/app/src/test/java/app/drydock/ui/review/ReviewCommentComposerTest.java index 9ffeba9c..b98b4034 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewCommentComposerTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewCommentComposerTest.java @@ -1,5 +1,6 @@ package app.drydock.ui.review; +import app.drydock.ui.TestStages; import app.drydock.git.DiffService; import app.drydock.git.UnifiedDiff; import app.drydock.review.ReviewAnnotation; @@ -61,8 +62,7 @@ public void start(Stage stage) { scene.getStylesheets().addAll( getClass().getResource("/app/drydock/ui/app.css").toExternalForm(), getClass().getResource("/app/drydock/ui/theme-dark.css").toExternalForm()); - stage.setScene(scene); - stage.show(); + TestStages.show(stage, scene); } @AfterEach @@ -183,7 +183,7 @@ void theCommentIsFiledUnderTheIntentThatOwnsTheFile() { Optional intentId = host.findings(scope).get(0).intentId(); assertTrue(intentId.isPresent(), "the comment must name an intent"); - assertTrue(host.intents(scope, host.diff).stream() + assertTrue(host.intents(scope, host.diff, Optional.empty()).stream() .anyMatch(intent -> intent.id().equals(intentId.get())), "and it must be an intent that exists: " + intentId.get()); } diff --git a/app/src/test/java/app/drydock/ui/review/ReviewDiffColumnIntentFilterTest.java b/app/src/test/java/app/drydock/ui/review/ReviewDiffColumnIntentFilterTest.java index 758c138a..42e5da87 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewDiffColumnIntentFilterTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewDiffColumnIntentFilterTest.java @@ -1,5 +1,6 @@ package app.drydock.ui.review; +import app.drydock.ui.TestStages; import app.drydock.git.DiffService; import app.drydock.git.UnifiedDiff; import app.drydock.review.ReviewIntent; @@ -48,8 +49,7 @@ public void start(Stage stage) { scene.getStylesheets().addAll( getClass().getResource("/app/drydock/ui/app.css").toExternalForm(), getClass().getResource("/app/drydock/ui/theme-dark.css").toExternalForm()); - stage.setScene(scene); - stage.show(); + TestStages.show(stage, scene); } @AfterEach diff --git a/app/src/test/java/app/drydock/ui/review/ReviewDiffColumnPublishTest.java b/app/src/test/java/app/drydock/ui/review/ReviewDiffColumnPublishTest.java index ca0e444d..efcf112a 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewDiffColumnPublishTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewDiffColumnPublishTest.java @@ -1,5 +1,6 @@ package app.drydock.ui.review; +import app.drydock.ui.TestStages; import app.drydock.git.DiffService; import app.drydock.review.ReviewScope; import app.drydock.review.ReviewScopeRegistry; @@ -42,8 +43,7 @@ public void start(Stage stage) { published.put(scopeId, outcome); order.add(scopeId); }); - stage.setScene(new Scene(column, 1400, 900)); - stage.show(); + TestStages.show(stage, new Scene(column, 1400, 900)); } @AfterEach diff --git a/app/src/test/java/app/drydock/ui/review/ReviewDiffColumnTest.java b/app/src/test/java/app/drydock/ui/review/ReviewDiffColumnTest.java index 8b96ea27..11aff570 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewDiffColumnTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewDiffColumnTest.java @@ -1,5 +1,6 @@ package app.drydock.ui.review; +import app.drydock.ui.TestStages; import app.drydock.git.DiffService; import app.drydock.review.ReviewScope; import app.drydock.review.ReviewScopeRegistry; @@ -48,8 +49,7 @@ public void start(Stage stage) { scene.getStylesheets().addAll( getClass().getResource("/app/drydock/ui/app.css").toExternalForm(), getClass().getResource("/app/drydock/ui/theme-dark.css").toExternalForm()); - stage.setScene(scene); - stage.show(); + TestStages.show(stage, scene); } @AfterEach diff --git a/app/src/test/java/app/drydock/ui/review/ReviewDiffColumnUntrackedToggleTest.java b/app/src/test/java/app/drydock/ui/review/ReviewDiffColumnUntrackedToggleTest.java index cd9a946b..dc909c31 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewDiffColumnUntrackedToggleTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewDiffColumnUntrackedToggleTest.java @@ -1,5 +1,6 @@ package app.drydock.ui.review; +import app.drydock.ui.TestStages; import app.drydock.git.DiffService; import app.drydock.git.UnifiedDiff; import app.drydock.review.ReviewScope; @@ -50,8 +51,7 @@ public void start(Stage stage) { scene.getStylesheets().addAll( getClass().getResource("/app/drydock/ui/app.css").toExternalForm(), getClass().getResource("/app/drydock/ui/theme-dark.css").toExternalForm()); - stage.setScene(scene); - stage.show(); + TestStages.show(stage, scene); } @AfterEach diff --git a/app/src/test/java/app/drydock/ui/review/ReviewDiffColumnWidthTest.java b/app/src/test/java/app/drydock/ui/review/ReviewDiffColumnWidthTest.java index cae7ee23..79d55aed 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewDiffColumnWidthTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewDiffColumnWidthTest.java @@ -1,5 +1,6 @@ package app.drydock.ui.review; +import app.drydock.ui.TestStages; import app.drydock.git.DiffService; import app.drydock.git.UnifiedDiff; import app.drydock.review.ReviewScope; @@ -52,8 +53,7 @@ public void start(Stage stage) { scene.getStylesheets().addAll( getClass().getResource("/app/drydock/ui/app.css").toExternalForm(), getClass().getResource("/app/drydock/ui/theme-dark.css").toExternalForm()); - stage.setScene(scene); - stage.show(); + TestStages.show(stage, scene); } @AfterEach diff --git a/app/src/test/java/app/drydock/ui/review/ReviewDiffGutterSelectionTest.java b/app/src/test/java/app/drydock/ui/review/ReviewDiffGutterSelectionTest.java index 5d3dd8f4..ce9d5768 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewDiffGutterSelectionTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewDiffGutterSelectionTest.java @@ -1,5 +1,6 @@ package app.drydock.ui.review; +import app.drydock.ui.TestStages; import app.drydock.git.DiffService; import app.drydock.git.UnifiedDiff; import app.drydock.review.ReviewAnnotation; @@ -71,8 +72,7 @@ public void start(Stage stage) { scene.getStylesheets().addAll( getClass().getResource("/app/drydock/ui/app.css").toExternalForm(), getClass().getResource("/app/drydock/ui/theme-dark.css").toExternalForm()); - stage.setScene(scene); - stage.show(); + TestStages.show(stage, scene); stage.toFront(); stage.requestFocus(); } diff --git a/app/src/test/java/app/drydock/ui/review/ReviewDiffRowsTest.java b/app/src/test/java/app/drydock/ui/review/ReviewDiffRowsTest.java index 137f6c92..22b17554 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewDiffRowsTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewDiffRowsTest.java @@ -1,10 +1,13 @@ package app.drydock.ui.review; import app.drydock.git.UnifiedDiff; +import app.drydock.review.ReadingPath; +import app.drydock.review.ReviewIntent; import org.junit.jupiter.api.Test; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.OptionalInt; import java.util.Set; @@ -225,6 +228,100 @@ void lineRowsCarryTheStableAnchorKey() { assertFalse(rows.isEmpty()); } + /** + * A hunk with a link in {@code linksByHunk} gets a footer row after its + * body, closing the card -- the one row a reader who just finished this + * hunk sees before moving on. + */ + @Test + void aHunkWithALinkGetsAFooterRowThatClosesTheCard() { + UnifiedDiff diff = diff(file("A.java", hunk(add(1)))); + ReadingPath.Link link = link("calls", "B.java", 0, "B.java:helper"); + ReviewDiffRows.Options options = withLinks(Map.of(ReviewIntent.hunkId("A.java", 0), List.of(link))); + + List rows = ReviewDiffRows.build(diff, options); + + assertEquals(3, rows.size(), "header, one line, one link row"); + ReviewDiffRow.LinkRow footer = (ReviewDiffRow.LinkRow) rows.get(2); + assertEquals(link, footer.link()); + assertEquals(ReviewDiffRow.Edge.BOTTOM, footer.edge(), + "the link row is now the last row, so it must close the card"); + assertEquals(ReviewDiffRow.Edge.BODY, rows.get(1).edge(), + "the line above it must lose BOTTOM now that something follows it"); + } + + /** A hunk absent from {@code linksByHunk} gets no footer row at all -- not an empty one. */ + @Test + void aHunkWithNoEntryInLinksByHunkGetsNoFooterRow() { + UnifiedDiff diff = diff(file("A.java", hunk(add(1)))); + ReviewDiffRows.Options options = withLinks(Map.of( + ReviewIntent.hunkId("SOMETHING_ELSE.java", 0), List.of(link("calls", "B.java", 0, "x")))); + + List rows = ReviewDiffRows.build(diff, options); + + assertTrue(rows.stream().noneMatch(ReviewDiffRow.LinkRow.class::isInstance), + "a hunk this map says nothing about must render no footer"); + } + + /** Each hunk's own footer is keyed off ITS hunk id, not off the file's first hunk. */ + @Test + void eachHunkGetsOnlyItsOwnLinks() { + UnifiedDiff diff = diff(file("A.java", hunk(add(1)), hunk(add(2)))); + ReadingPath.Link linkOnSecond = link("called by", "B.java", 0, "B.java:x"); + ReviewDiffRows.Options options = withLinks(Map.of(ReviewIntent.hunkId("A.java", 1), List.of(linkOnSecond))); + + List rows = ReviewDiffRows.build(diff, options); + + long footers = rows.stream().filter(ReviewDiffRow.LinkRow.class::isInstance).count(); + assertEquals(1, footers, "only the second hunk carries a link"); + int firstCardEnd = indexOfSecondHeader(rows); + assertTrue(rows.subList(0, firstCardEnd).stream() + .noneMatch(ReviewDiffRow.LinkRow.class::isInstance), + "the FIRST hunk's card must carry no footer of its own"); + } + + /** More than one link on a hunk becomes more than one footer row, in the order supplied. */ + @Test + void multipleLinksBecomeMultipleFooterRowsInOrder() { + UnifiedDiff diff = diff(file("A.java", hunk(add(1)))); + ReadingPath.Link first = link("calls", "B.java", 0, "B.java:x"); + ReadingPath.Link second = link("same concept", "C.java", 0, "C.java: shared y"); + ReviewDiffRows.Options options = + withLinks(Map.of(ReviewIntent.hunkId("A.java", 0), List.of(first, second))); + + List rows = ReviewDiffRows.build(diff, options); + + List footers = rows.stream() + .filter(ReviewDiffRow.LinkRow.class::isInstance) + .map(row -> ((ReviewDiffRow.LinkRow) row).link()) + .toList(); + assertEquals(List.of(first, second), footers); + assertEquals(ReviewDiffRow.Edge.BODY, rows.get(rows.size() - 2).edge(), + "only the LAST link row closes the card"); + assertEquals(ReviewDiffRow.Edge.BOTTOM, rows.get(rows.size() - 1).edge()); + } + + private static int indexOfSecondHeader(List rows) { + int seen = 0; + for (int i = 0; i < rows.size(); i++) { + if (rows.get(i) instanceof ReviewDiffRow.HunkHeader) { + if (seen == 1) { + return i; + } + seen++; + } + } + throw new AssertionError("expected two hunk headers in " + rows); + } + + private static ReviewDiffRows.Options withLinks(Map> linksByHunk) { + return new ReviewDiffRows.Options(true, Set.of(), 3000, ReviewDiffRows.HunkFilter.ALL, linksByHunk); + } + + private static ReadingPath.Link link(String kind, String targetFile, int targetHunkIndex, String label) { + return new ReadingPath.Link(kind, ReviewIntent.hunkId(targetFile, targetHunkIndex), label); + } + // ---- fixtures ----------------------------------------------------------- private static UnifiedDiff diff(UnifiedDiff.FileDiff... files) { diff --git a/app/src/test/java/app/drydock/ui/review/ReviewFanInPopoverTest.java b/app/src/test/java/app/drydock/ui/review/ReviewFanInPopoverTest.java new file mode 100644 index 00000000..1099e6f3 --- /dev/null +++ b/app/src/test/java/app/drydock/ui/review/ReviewFanInPopoverTest.java @@ -0,0 +1,764 @@ +package app.drydock.ui.review; + +import app.drydock.ui.TestStages; +import app.drydock.git.DiffService; +import app.drydock.git.UnifiedDiff; +import app.drydock.process.ProcessRunner; +import app.drydock.review.ReviewScope; +import app.drydock.review.ReviewScopeRegistry; +import app.drydock.review.SessionReviewScopes; + +import javafx.scene.Node; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.scene.control.Labeled; +import javafx.scene.input.KeyCode; +import javafx.stage.PopupWindow; +import javafx.stage.Stage; +import javafx.stage.Window; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.testfx.framework.junit5.ApplicationTest; +import org.testfx.util.WaitForAsyncUtils; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.function.BooleanSupplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The out-of-diff fan-in count, as an affordance rather than a statistic + * (spec §7.4): "called from 4 places outside the change" opens the same + * occurrence popover the symbol lens uses, on a third source, and every row + * names the file and line a reviewer would otherwise have to go and grep + * for. + * + *

A REAL scan, over a real repository. Before this task + * {@code OutOfDiffFanIn.scan} had zero production callers -- every site + * hardcoded an "unavailable" placeholder -- so a count was structurally + * always absent and a popover over it could not exist. Nothing here is + * stubbed for that reason: the board is pointed at a git repository this + * test builds, and the counts come from the {@code git grep} the real board + * spawns.

+ * + *

Two changed files, three changed symbols in one of them. + * Not decoration. {@link #ZETA} declares three symbols with outside callers + * and {@link #ALPHA} declares one with none, which is what makes three + * distinct things assertable at all: that {@code bySymbol} comes out in the + * graph's SORTED order rather than a hash order (fix round 1, item 2 -- with + * a single symbol every map type iterates identically, so the determinism + * test could not fail); that the scan REORDERS the reading path, since + * fan-in is its first rank term; and that the reorder does not move the + * reader (item 1).

+ * + *

Absent is not zero. Three scan outcomes are covered: + * one that found callers, one that ran and found none, and one that could + * not run. The middle and the last must not render the same, which is + * exactly what a test asserting only "no zero is shown" would fail to + * notice.

+ */ +class ReviewFanInPopoverTest extends ApplicationTest { + + /** The changed file with three changed declarations, all used from outside. */ + private static final String ZETA = "src/Zeta.java"; + + /** The changed file whose one declaration nothing outside uses. */ + private static final String ALPHA = "src/Alpha.java"; + + /** + * A third file, added mid-test only to displace {@link #ALPHA} as the + * path's entry point -- so it has to sort BEFORE it. That is the shape + * the defect needs: {@code togglePathMode} resets the cursor to 0 before + * the refresh, so a stale re-anchor looks up the remembered ENTRY POINT, + * and only notices if that hunk has moved. + */ + private static final String AARDVARK = "src/Aardvark.java"; + + /** + * {@link #ZETA}'s declarations, in the order the popover must list them: + * the graph's own sorted order. Chosen so a {@code HashMap} iterates + * them DIFFERENTLY ({@code Astrolabe, Sextant, Compass}) -- otherwise + * swapping the ordered map for a hashed one would leave every assertion + * green, which is what happened when the fixture had one symbol. + */ + private static final List ZETA_SYMBOLS = List.of("Astrolabe", "Compass", "Sextant"); + + /** Declared by {@link #ALPHA}; referenced nowhere outside the change. */ + private static final String ALPHA_SYMBOL = "AlphaOnly"; + + /** Declared by a one-file diff, and referenced nowhere in the repository at all. */ + private static final String LONELY_SYMBOL = "TotallyAbsentSymbolXyz"; + + private final ReviewScopeRegistry registry = new ReviewScopeRegistry(); + private final DiffService diffService = new DiffService(); + + private FakeReviewHost host; + private SessionReviewView view; + private Path repo; + private Path notARepo; + private ReviewScope scope; + + @Override + public void start(Stage stage) { + try { + host = new FakeReviewHost(Files.createTempDirectory("drydock-fanin") + .resolve("annotations.json")); + Path parent = Files.createTempDirectory("drydock-fanin-repo"); + repo = initRepoWithOutsideCallers(parent); + notARepo = Files.createDirectories(parent.resolve("plain-directory")); + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + throw new UncheckedIOException(new IOException(e)); + } + view = new SessionReviewView(host, diffService, null); + Scene scene = new Scene(view, 1400, 900); + scene.getStylesheets().addAll( + getClass().getResource("/app/drydock/ui/app.css").toExternalForm(), + getClass().getResource("/app/drydock/ui/theme-dark.css").toExternalForm()); + TestStages.show(stage, scene); + } + + @AfterEach + void tearDown() { + interact(view::close); + diffService.close(); + host.store.close(); + } + + // ---- the reading path moves; the reader must not ------------------------ + + /** + * The "before" this test class's reorder rests on, pinned without a + * race: pointed at a directory git cannot grep, no fan-in ever arrives, + * and {@link app.drydock.review.ReadingPath}'s remaining tie-breaks fall + * through to the path -- so {@link #ALPHA} is step 1. + */ + @Test + void withNoFanInThePathFallsBackToPathOrder() { + showRichBoard(notARepo); + + assertTrue(railTexts().get(0).contains(ALPHA), + "with no fan-in the path order is alphabetical: " + railTexts()); + } + + /** + * CRITICAL (fix round 1, item 1). Fan-in is {@code ReadingPath.rank}'s + * FIRST term, so a scan landing mid-read re-sorts the rail under the + * reader. {@code pathIndex} is a POSITION: left to clamping alone, the + * cursor stays on index 0 while index 0 becomes a different hunk, the + * diff column is re-narrowed to it, and -- since {@code settleUnit()} is + * {@code PATH_STEP} unconditionally in this mode -- the reader's next + * {@code a} approves a hunk they were never shown. That is the third + * occurrence on this branch of one defect family: a gesture whose scope + * silently stops matching what the reader sees. + * + *

The reader starts on step 1 ({@link #ALPHA}, per the test above). + * The scan puts {@link #ZETA} first. They must still be on {@link + * #ALPHA}.

+ */ + @Test + void aScanThatReordersThePathKeepsTheReaderOnTheHunkTheyWereReading() { + showRichBoard(repo); + + await("the scan to re-sort the path", () -> railTexts().get(0).contains(ZETA)); + + List rows = railTexts(); + assertEquals(2, rows.size(), "both changed files must be on the rail: " + rows); + assertTrue(rows.get(view.selectedPathStepForTest()).contains(ALPHA), + "the reader was reading " + ALPHA + "; after the re-sort the cursor is on row " + + view.selectedPathStepForTest() + " of " + rows); + } + + /** + * Fix round 3, item 1. The round-1 re-anchor introduced the very defect + * it was written to prevent, one gesture over: {@code lastPathSteps} + * survives LEAVING path mode (only {@code refreshReviewState}'s + * {@code pathMode} branch writes it), so a path that changed while the + * reader was away made {@code p}'s deliberate "start at the beginning" + * lose to a re-anchor onto wherever the remembered hunk had gone -- the + * cursor on row 3 with row 1 labelled START HERE. + * + *

Driven by a second DIFF rather than by a late scan, so nothing here + * is a race: {@code requestGraph} is kicked from diff resolution + * regardless of mode, which is the other way in, and this board's + * worktree is one git cannot grep so no fan-in ever arrives to reorder + * anything behind the test's back.

+ */ + @Test + void reEnteringPathModeStartsAtTheEntryPointEvenIfThePathMovedWhileAway() { + showRichBoard(notARepo); + assertTrue(railTexts().get(0).contains(ALPHA), "the entry point starts as " + ALPHA); + + press(KeyCode.P).release(KeyCode.P); + WaitForAsyncUtils.waitForFxEvents(); + assertEquals(ReviewIntentRail.Mode.INTENTS, view.railMode(), "the reader left PATH mode"); + + // A file that sorts FIRST lands while they are away, so the entry + // point is no longer the hunk the stale memory holds -- which is + // exactly what a re-anchor would chase, and where it would leave the + // cursor while row 1 said START HERE. + UnifiedDiff moved = new UnifiedDiff(List.of( + oneHunkFile(AARDVARK, List.of("class AardvarkOnly { }")), + oneHunkFile(ALPHA, List.of("class " + ALPHA_SYMBOL + " { }")), + oneHunkFile(ZETA, ZETA_SYMBOLS.stream().map(n -> "class " + n + " { }").toList()))); + host.diff = moved; + interact(() -> view.diagShowDiff(scope, moved)); + WaitForAsyncUtils.waitForFxEvents(); + + press(KeyCode.P).release(KeyCode.P); + await("the re-entered path to populate", () -> view.pathRowTextsForTest().size() == 3); + + assertEquals(0, view.selectedPathStepForTest(), + "p means start at the beginning: " + railTexts()); + assertTrue(railTexts().get(view.selectedPathStepForTest()).contains("START HERE"), + "the cursor must be on the row the rail calls START HERE: " + railTexts()); + } + + // ---- the popover -------------------------------------------------------- + + @Test + void clickingTheFanInCountListsTheCallersWithFileAndLine() { + showRichBoard(repo); + awaitFanInCount(); + + int reading = view.selectedPathStepForTest(); + clickOn(".review-fanin-count"); + WaitForAsyncUtils.waitForFxEvents(); + + // Asking to see the callers is not asking to move the cursor: the + // fan-in ActionEvent BUBBLES to the row Button that contains it, and + // un-consumed it selects that row and re-narrows the diff column + // under the reader. + assertEquals(reading, view.selectedPathStepForTest(), + "opening the popover must not move the reading cursor"); + List texts = popoverTexts(); + assertTrue(texts.stream().anyMatch(text -> text.matches("src/Caller\\.java:\\d+")), + "the popover must name the caller's file AND line: " + texts); + assertTrue(texts.stream().anyMatch(text -> text.matches("src/More\\.java:\\d+")), + "every caller, not just the first: " + texts); + assertTrue(texts.stream().noneMatch(text -> text.startsWith(ZETA + ":")), + "the changed file is not OUTSIDE the change: " + texts); + } + + /** No new interaction is invented: it is the same popover on a third source. */ + @Test + void thePopoverOffersUsagesAndAskTheAgent() { + showRichBoard(repo); + awaitFanInCount(); + + clickOn(".review-fanin-count"); + WaitForAsyncUtils.waitForFxEvents(); + + List texts = popoverTexts(); + assertTrue(texts.stream().anyMatch(text -> text.contains("usages")), + "the list IS the usages view and says so: " + texts); + assertTrue(texts.stream().anyMatch(text -> text.contains("agent")), + "a lexical list cannot say whether a caller breaks; the reader must be one " + + "click from the party that can: " + texts); + // The button says what it DOES. A reviewer who is not told finds a + // review comment they did not knowingly write. + assertTrue(texts.stream().anyMatch(text -> text.contains("agent") && text.contains("comment")), + "the ask button must say it files a comment: " + texts); + } + + /** + * The ask is routed through the two seams that already exist -- the + * comment store and the bound session -- and the question names the file, + * so the agent is not left to guess which one. + */ + @Test + void askingTheAgentPostsAQuestionPointedAtTheRightFile() { + showRichBoard(repo); + awaitFanInCount(); + clickOn(".review-fanin-count"); + WaitForAsyncUtils.waitForFxEvents(); + + clickOn(".review-fanin-ask"); + WaitForAsyncUtils.waitForFxEvents(); + + assertEquals(1, host.findings(scope).size(), "the question must become a real thread"); + String body = host.findings(scope).get(0).thread().get(0).text(); + assertTrue(body.contains(ZETA), "the question must name the file: " + body); + assertTrue(ZETA_SYMBOLS.stream().allMatch(body::contains), + "the question must name the symbols: " + body); + assertEquals(ZETA, host.findings(scope).get(0).file()); + assertEquals(1, host.handedOffPrompts.size(), + "the question must reach the bound session, not just the store"); + assertFalse(popoverShowing(), "a hand-off that worked closes the popover"); + } + + /** + * Fix round 1, item 5. {@code askAgentToFix} was {@code void} and the + * boolean under it was discarded: with no bound session the popover + * closed, a persistent OPEN comment authored as "You" was filed, nothing + * was sent, and the reviewer was told nothing. That is the shape Ruling 1 + * legislated against for the Explorer jump, on the second button. + */ + @Test + void anAskWithNoBoundSessionSaysSoRatherThanClosingOnSilence() { + host.sessionBound = false; + showRichBoard(repo); + awaitFanInCount(); + clickOn(".review-fanin-count"); + WaitForAsyncUtils.waitForFxEvents(); + + clickOn(".review-fanin-ask"); + WaitForAsyncUtils.waitForFxEvents(); + + assertTrue(host.handedOffPrompts.isEmpty(), "nothing can be sent with no session"); + assertTrue(popoverShowing(), "the popover must stay open to report it"); + assertTrue(popoverTexts().stream().anyMatch(text -> text.contains("nothing was sent")), + "the reviewer must be told nothing was sent: " + popoverTexts()); + } + + /** Escape unwinds the topmost thing; this popover is now the topmost thing. */ + @Test + void escapeClosesTheFanInPopover() { + showRichBoard(repo); + awaitFanInCount(); + clickOn(".review-fanin-count"); + WaitForAsyncUtils.waitForFxEvents(); + assertTrue(popoverShowing()); + + boolean[] unwound = new boolean[1]; + interact(() -> unwound[0] = view.unwindOne()); + + assertTrue(unwound[0], "Escape must be handled by the open fan-in popover"); + assertFalse(popoverShowing()); + } + + /** + * The Explorer lives inside a session's tab, so the jump can legitimately + * fail. It must say so: a row that reports nothing when it does nothing + * is the silent-failure shape this branch has already had to fix twice. + */ + @Test + void aRefusedExplorerJumpSaysSoInThePopover() { + host.explorerAvailable = false; + showRichBoard(repo); + awaitFanInCount(); + clickOn(".review-fanin-count"); + WaitForAsyncUtils.waitForFxEvents(); + + clickOn(".review-lens-line"); + WaitForAsyncUtils.waitForFxEvents(); + + assertTrue(popoverTexts().stream().anyMatch(text -> text.startsWith("Could not open ")), + "a refused jump must be reported: " + popoverTexts()); + assertTrue(popoverShowing(), "the popover stays open to carry the message"); + } + + @Test + void anAcceptedExplorerJumpOpensTheOutsideFile() { + host.explorerAvailable = true; + showRichBoard(repo); + awaitFanInCount(); + clickOn(".review-fanin-count"); + WaitForAsyncUtils.waitForFxEvents(); + + clickOn(".review-lens-line"); + WaitForAsyncUtils.waitForFxEvents(); + + assertEquals(1, host.explorerJumps.size(), "the jump must reach the Explorer"); + assertTrue(host.explorerJumps.get(0).toString().startsWith("src/"), + "and at the file the row names: " + host.explorerJumps); + assertFalse(popoverShowing(), "a jump that worked closes the popover"); + } + + /** + * The rail row is a card, and a card is tens of pixels tall. The fan-in + * control wraps the reason Label inside the row Button, which is exactly + * the nesting that once made a wrapping label measure itself at zero + * width and report the height of a column of single characters (see + * {@code ReviewIntentRailCardHeightTest}). + */ + @Test + void theFanInRowStaysCardSized() { + showRichBoard(repo); + awaitFanInCount(); + + double height = lookup(".review-fanin-count").query().getScene().getRoot() + .lookupAll(".review-intent-card").stream() + .filter(node -> !node.lookupAll(".review-fanin-count").isEmpty()) + .mapToDouble(node -> node.getBoundsInParent().getHeight()) + .max() + .orElse(0); + assertTrue(height > 0 && height < 220, + "the fan-in row is " + Math.round(height) + "px tall; rows are tens of pixels"); + } + + /** + * Fix round 1, item 2 (folded-in M1). {@code diagPathRowTexts} recursed + * {@code getChildrenUnmodifiable()}, but a fan-in row's reason Label is + * now the GRAPHIC of a nested Button, and a Labeled's graphic becomes one + * of its children only once its skin exists -- a layout pulse away. The + * reviewer caught the window live: a row rendered with no reason text at + * all for ~80ms. Every rail-text assertion is timing-dependent while that + * is true, and an {@code assertFalse(anyMatch(...))} can pass because the + * text has not been parented yet rather than because it is absent. + * + *

Read inside the SAME {@code interact} that rebuilds the rail, so no + * layout pulse can intervene: this is the worst case by construction + * rather than by luck.

+ */ + @Test + void theRailAccessorReadsAFanInRowsReasonInThePulseItIsBuilt() { + showRichBoard(repo); + awaitFanInCount(); + + List freshlyBuilt = new ArrayList<>(); + interact(() -> { + view.refreshReviewState(); + freshlyBuilt.addAll(view.pathRowTextsForTest()); + }); + + assertTrue(freshlyBuilt.stream().anyMatch(row -> row.contains("places outside the change")), + "a fan-in row's reason must be readable the moment the row exists: " + freshlyBuilt); + } + + /** + * The reason WRAPS inside the fan-in control rather than being cut to one + * line and ellipsized. + * + *

Found by a screenshot of the running app, not by a test: the rail's + * only row read "file called from 16 places outside the…". A wrapping + * Label wraps at the width it is given, and as a Button's GRAPHIC it is + * given its own one-line preferred width instead of the card's -- so the + * button cut it and the Label rendered an ellipsis. This project has + * shipped that truncation once already ("R..", "...").

+ * + *

Geometry, not computed CSS: a sentence this long cannot occupy one + * line at the rail's width, so a single-line height IS the defect.

+ */ + @Test + void theFanInReasonWrapsInsteadOfBeingCutToOneLine() { + showRichBoard(repo); + awaitFanInCount(); + // Narrowed on purpose. At the rail's full width this particular + // sentence happens to fit on one line (189px of a 190px slot), and a + // test that only ever measures the case that fits cannot see the + // defect at all -- which is exactly why the running app showed it + // first and this test did not. + interact(() -> view.getScene().getWindow().setWidth(1050)); + WaitForAsyncUtils.waitForFxEvents(); + + Node reason = lookup(".review-fanin-count").query().lookup(".review-path-reason"); + double height = reason.getBoundsInLocal().getHeight(); + double lineHeight = ((Labeled) reason).getFont().getSize(); + assertTrue(height > lineHeight * 1.6, + "the reason is " + Math.round(height) + "px tall at a " + Math.round(lineHeight) + + "px font -- one line, so it was cut rather than wrapped: \"" + + ((Labeled) reason).getText() + "\""); + } + + // ---- absent is not zero ------------------------------------------------- + + /** + * The distinction the whole affordance rests on. Both scopes below show + * no count -- but only one of them may claim the outside is quiet. + * + *

This is the test that cannot be written vacuously: it fails if the + * scan is not wired (a never-run scan reports unknown, so the "ran and + * found nothing" assertion below never comes true and this times out), + * and it fails if {@code unavailable()} is folded into "zero" (both + * scopes would then read identically).

+ */ + @Test + void aScanThatRanAndFoundNothingIsNotAnUnavailableScan() { + showLonelyBoard(repo); + await("the scan to report an empty-but-available answer", + () -> railTexts().stream().noneMatch(text -> text.contains("outside callers unknown"))); + + List ran = railTexts(); + assertTrue(ran.stream().anyMatch(text -> text.contains("nothing in the change references it")), + "a scan that ran and found nothing still says the change is self-contained: " + ran); + assertTrue(lookup(".review-fanin-count").queryAll().isEmpty(), + "zero places outside is no affordance, not a zero-count button"); + } + + @Test + void anUnavailableScanShowsNoCountRatherThanZero() { + showLonelyBoard(notARepo); + await("the scan to fail against a directory git cannot grep", + () -> railTexts().stream().anyMatch(text -> text.contains("outside callers unknown"))); + + List texts = railTexts(); + assertFalse(texts.stream().anyMatch(text -> text.contains("0 places outside")), + "a scan that could not run must not render as a measured zero: " + texts); + assertFalse(texts.stream().anyMatch(text -> text.contains("places outside the change")), + "nor as any count at all: " + texts); + assertTrue(lookup(".review-fanin-count").queryAll().isEmpty(), + "and there is nothing to click, because nothing was measured"); + } + + // ---- the scan itself ---------------------------------------------------- + + /** + * {@code OutOfDiffFanIn.scan} spawns a {@code git grep} and waits up to + * thirty seconds for it. Asserted, not assumed: {@code Sections.of} on + * the FX thread already froze this board for ~2.7 seconds once, and a + * subprocess there would be far worse. + */ + @Test + void theScanNeverRunsOnTheFxThread() { + showRichBoard(repo); + awaitFanInCount(); + + assertEquals("drydock-section-graph", view.diagFanInScanThread(), + "the scan must run on the section-graph executor, never on the FX thread"); + } + + /** + * Determinism is a requirement on this branch, not a property (spec + * §9.5). The popover walks the graph's SORTED declarations rather than + * the scan's own map, so the same scan renders the same list in the same + * order every time -- across runs and across processes, not merely + * twice in one. + * + *

{@link #ZETA_SYMBOLS} is asserted as a LIST, and its members are + * chosen so a {@code HashMap} would iterate them as {@code Astrolabe, + * Sextant, Compass}. That is what makes this test able to fail: with the + * one-symbol fixture it started life with, every map type iterated + * identically and swapping the ordered map for a hashed one left the + * whole suite green.

+ */ + @Test + void thePopoverListsEverySymbolInTheGraphsSortedOrder() { + showRichBoard(repo); + awaitFanInCount(); + + clickOn(".review-fanin-count"); + WaitForAsyncUtils.waitForFxEvents(); + List first = symbolRows(); + List firstRows = whereRows(); + interact(view::unwindOne); + + clickOn(".review-fanin-count"); + WaitForAsyncUtils.waitForFxEvents(); + + assertEquals(ZETA_SYMBOLS, first, + "the popover must list every changed symbol of this file, in sorted order"); + assertEquals(first, symbolRows(), "and identically on a second opening"); + assertEquals(firstRows, whereRows(), "occurrence rows too"); + assertFalse(firstRows.isEmpty(), "there is nothing to compare if nothing rendered"); + } + + // ---- board --------------------------------------------------------------- + + /** + * The two-file board: {@link #ALPHA} (one declaration, no outside users) + * and {@link #ZETA} (three declarations, all used from outside). + */ + private void showRichBoard(Path worktree) { + List zetaLines = ZETA_SYMBOLS.stream().map(name -> "class " + name + " { }").toList(); + showBoard(worktree, new UnifiedDiff(List.of( + oneHunkFile(ALPHA, List.of("class " + ALPHA_SYMBOL + " { }")), + oneHunkFile(ZETA, zetaLines)))); + } + + /** A one-file board declaring a symbol nothing in the repository references. */ + private void showLonelyBoard(Path worktree) { + showBoard(worktree, new UnifiedDiff(List.of( + oneHunkFile(ALPHA, List.of("class " + LONELY_SYMBOL + " { }"))))); + } + + /** + * Shows a board whose scope is checked out at {@code worktree}, then + * enters PATH mode -- where the reading path's reasons, and so the fan-in + * count, live. + */ + private void showBoard(Path worktree, UnifiedDiff diff) { + // Asserted, not assumed: TestFX's primary stage is shared by every + // class in this JVM, and a class that leaves it at the code column's + // floor (ReviewVerdictBarFitTest does, deliberately) collapses this + // rail before the first test here even runs -- the fan-in control is + // then present and invisible, which reads as a broken lookup. + interact(() -> { + view.getScene().getWindow().setWidth(1400); + view.getScene().getWindow().setHeight(900); + }); + WaitForAsyncUtils.waitForFxEvents(); + scope = registry.mint(ReviewScopeRegistry.spec(ReviewScope.Kind.WORKING_TREE, + worktree, Optional.of(worktree), "main", "HEAD", + Optional.empty(), Optional.empty())); + host.diff = diff; + interact(() -> view.showScopes(new SessionReviewScopes.Scopes(scope, Optional.empty()), + SessionReviewScopes.Choice.LOCAL)); + // PATH mode BEFORE the diff, deliberately, and this ordering is what + // makes the re-sort test deterministic rather than a race: the + // reader is already in PATH mode when the graph lands, so the rail + // necessarily renders the pre-scan order first (the scan is only + // KICKED OFF by the graph's own completion) and the scan's own + // refresh is necessarily the second one. Publishing the diff first + // let both land before `p` was ever pressed, and the test then + // asserted against a cursor that had never been anywhere. + press(KeyCode.P).release(KeyCode.P); + WaitForAsyncUtils.waitForFxEvents(); + interact(() -> view.diagShowDiff(scope, diff)); + WaitForAsyncUtils.waitForFxEvents(); + await("PATH mode to populate its rows", () -> !view.pathRowTextsForTest().isEmpty()); + } + + private static UnifiedDiff.FileDiff oneHunkFile(String path, List added) { + List lines = new ArrayList<>(); + int number = 1; + for (String text : added) { + lines.add(new UnifiedDiff.Line(UnifiedDiff.Line.Kind.ADD, OptionalInt.empty(), + OptionalInt.of(number++), text)); + } + return new UnifiedDiff.FileDiff(path, "M", added.size(), 0, false, false, + List.of(new UnifiedDiff.Hunk("@@ -1 +1 @@", lines))); + } + + private List railTexts() { + return view.pathRowTextsForTest(); + } + + private void awaitFanInCount() { + await("the fan-in scan to land a clickable count", + () -> !lookup(".review-fanin-count").queryAll().isEmpty()); + } + + /** + * Polls wall time, as {@code ReviewPathModeTest.awaitPathReady} does: the + * graph build and the {@code git grep} behind it both run on virtual + * threads, and how long they take depends on whether this JVM has + * already warmed the tree-sitter grammar. + */ + private void await(String what, BooleanSupplier condition) { + long start = System.nanoTime(); + while (!condition.getAsBoolean()) { + if (System.nanoTime() - start > 60_000_000_000L) { + throw new AssertionError("timed out waiting for " + what + + "; rail said: " + railTexts()); + } + sleep(50); + } + } + + // ---- the popover, as rendered ------------------------------------------- + + private boolean popoverShowing() { + boolean[] showing = new boolean[1]; + interact(() -> showing[0] = openPopups().findAny().isPresent()); + return showing[0]; + } + + /** + * Every {@link Labeled}'s text in the open popover. Read off the popup's + * own scene root rather than {@code PopupWindow.getContent()}, which is + * not public outside {@code javafx.stage}. + */ + private List popoverTexts() { + List texts = new ArrayList<>(); + interact(() -> openPopups().forEach(popup -> { + if (popup.getScene() != null) { + collectText(popup.getScene().getRoot(), texts, null); + } + })); + return texts; + } + + /** Just the symbol headings, in rendered order. */ + private List symbolRows() { + List texts = new ArrayList<>(); + interact(() -> openPopups().forEach(popup -> { + if (popup.getScene() != null) { + collectText(popup.getScene().getRoot(), texts, "review-fanin-symbol"); + } + })); + return texts; + } + + /** Just the {@code file:line} rows, in rendered order. */ + private List whereRows() { + List texts = new ArrayList<>(); + interact(() -> openPopups().forEach(popup -> { + if (popup.getScene() != null) { + collectText(popup.getScene().getRoot(), texts, "review-lens-where"); + } + })); + return texts; + } + + private java.util.stream.Stream openPopups() { + return Window.getWindows().stream() + .filter(PopupWindow.class::isInstance) + .map(PopupWindow.class::cast) + .filter(Window::isShowing); + } + + /** Depth-first, so the collected order is the rendered order. */ + private static void collectText(Node node, List into, String styleClass) { + if (node instanceof Labeled labeled && labeled.getText() != null + && !labeled.getText().isBlank() + && (styleClass == null || labeled.getStyleClass().contains(styleClass))) { + into.add(labeled.getText()); + } + if (node instanceof Labeled labeled && labeled.getGraphic() != null) { + collectText(labeled.getGraphic(), into, styleClass); + } + if (node instanceof Parent parent) { + for (Node child : parent.getChildrenUnmodifiable()) { + if (!(node instanceof Labeled labeled) || child != labeled.getGraphic()) { + collectText(child, into, styleClass); + } + } + } + } + + // ---- a real repository --------------------------------------------------- + + /** + * A committed repository where {@link #ZETA}'s three declarations are + * used from two files the diff does not touch -- the shape the whole + * feature exists for: a public-API change whose callers are invisible to + * a diff-scoped graph. {@link #ALPHA}'s one declaration is used nowhere + * outside, so exactly one of the two rail rows gets a fan-in control. + */ + private static Path initRepoWithOutsideCallers(Path parent) + throws IOException, InterruptedException { + Path repo = Files.createDirectories(parent.resolve("repo")); + Files.createDirectories(repo.resolve("src")); + Files.writeString(repo.resolve(ALPHA), "class " + ALPHA_SYMBOL + " { }\n"); + Files.writeString(repo.resolve(ZETA), ZETA_SYMBOLS.stream() + .map(name -> "class " + name + " { }\n") + .reduce("", String::concat)); + StringBuilder caller = new StringBuilder("class Caller {\n"); + for (String symbol : ZETA_SYMBOLS) { + caller.append(" void use").append(symbol).append("() { new ") + .append(symbol).append("(); }\n"); + } + Files.writeString(repo.resolve("src/Caller.java"), caller.append("}\n").toString()); + // A second caller of exactly one symbol, so the popover has a symbol + // with two occurrences beside two with one -- a count that is not + // simply "one per symbol". + Files.writeString(repo.resolve("src/More.java"), + "class More {\n void again() { new " + ZETA_SYMBOLS.get(2) + "(); }\n}\n"); + runGit(repo, "init", "-b", "main"); + runGit(repo, "config", "user.name", "Test"); + runGit(repo, "config", "user.email", "test@example.com"); + runGit(repo, "add", "-A"); + runGit(repo, "commit", "-m", "seed"); + return repo; + } + + private static void runGit(Path repo, String... args) throws IOException, InterruptedException { + List command = new ArrayList<>(); + command.add("git"); + command.addAll(List.of(args)); + ProcessRunner.run(command, repo, Duration.ofSeconds(30)); + } +} diff --git a/app/src/test/java/app/drydock/ui/review/ReviewFindingsAndVerdictsTest.java b/app/src/test/java/app/drydock/ui/review/ReviewFindingsAndVerdictsTest.java index be4c9516..252587ff 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewFindingsAndVerdictsTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewFindingsAndVerdictsTest.java @@ -1,9 +1,12 @@ package app.drydock.ui.review; +import app.drydock.ui.TestStages; import app.drydock.git.DiffService; import app.drydock.git.UnifiedDiff; import app.drydock.review.AnnotationStatus; +import app.drydock.review.BaseMove; import app.drydock.review.Confidence; +import app.drydock.review.HunkDigest; import app.drydock.review.ReviewAnnotation; import app.drydock.review.ReviewIntent; import app.drydock.review.ReviewScope; @@ -35,6 +38,7 @@ import java.util.Locale; import java.util.Map; import java.util.Optional; +import java.util.TreeSet; import java.util.OptionalInt; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -74,8 +78,7 @@ public void start(Stage stage) { scene.getStylesheets().addAll( getClass().getResource("/app/drydock/ui/app.css").toExternalForm(), getClass().getResource("/app/drydock/ui/theme-dark.css").toExternalForm()); - stage.setScene(scene); - stage.show(); + TestStages.show(stage, scene); } @AfterEach @@ -268,7 +271,7 @@ void approvingAnIntentRecordsAVerdict() { type(KeyCode.A); assertEquals(ReviewVerdict.Decision.APPROVED, - host.store.verdict(scope.id(), "auto:change:src").orElseThrow().decision()); + host.store.verdict(scope.id(), digestOfMain()).orElseThrow().decision()); } /** Spec §4.6: approval is refused while a blocking finding of the intent is open. */ @@ -278,7 +281,7 @@ void approvalIsRefusedWhileABlockingFindingIsOpen() { type(KeyCode.A); - assertTrue(host.store.verdict(scope.id(), "auto:change:src").isEmpty(), + assertTrue(host.store.verdict(scope.id(), digestOfMain()).isEmpty(), "an open blocking finding must refuse approval"); assertFalse(lookup(".review-verdict-refusal").queryAll().isEmpty(), "the refusal must be visible, not silent"); @@ -288,14 +291,14 @@ void approvalIsRefusedWhileABlockingFindingIsOpen() { void resolvingTheBlockerLetsTheApprovalThrough() { seed(finding("f1", Severity.BLOCKING)); type(KeyCode.A); - assertTrue(host.store.verdict(scope.id(), "auto:change:src").isEmpty()); + assertTrue(host.store.verdict(scope.id(), digestOfMain()).isEmpty()); host.store.mutate(new ReviewAnnotation.Key(scope.id(), "f1"), current -> current.withStatus(AnnotationStatus.RESOLVED)); interact(view::refreshReviewState); type(KeyCode.A); - assertTrue(host.store.verdict(scope.id(), "auto:change:src").isPresent()); + assertTrue(host.store.verdict(scope.id(), digestOfMain()).isPresent()); } /** A human downgrade after a discussion is the other way past a blocker. */ @@ -306,7 +309,7 @@ void downgradingTheSeverityAlsoLetsTheApprovalThrough() { interact(() -> fire(".review-card-action", "Downgrade")); type(KeyCode.A); - assertTrue(host.store.verdict(scope.id(), "auto:change:src").isPresent()); + assertTrue(host.store.verdict(scope.id(), digestOfMain()).isPresent()); assertEquals(Severity.BLOCKING, host.store.byId(scope.id(), "f1").orElseThrow().severity(), "the reviewer's original opinion is kept alongside the override"); } @@ -317,10 +320,10 @@ void requestChangesAndUndoRoundTrip() { type(KeyCode.R); assertEquals(ReviewVerdict.Decision.CHANGES, - host.store.verdict(scope.id(), "auto:change:src").orElseThrow().decision()); + host.store.verdict(scope.id(), digestOfMain()).orElseThrow().decision()); type(KeyCode.U); - assertTrue(host.store.verdict(scope.id(), "auto:change:src").isEmpty()); + assertTrue(host.store.verdict(scope.id(), digestOfMain()).isEmpty()); } @Test @@ -561,6 +564,194 @@ private ReviewScope seedWithNoDiffInTheColumn(ReviewScope minted, DiffOutcome ou return minted; } + /** + * Fix round 2. "Ask the agent to fix it" hands the intent's open findings + * to the bound session -- and with no session bound it hands over + * NOTHING while looking exactly as though it worked. That is the defect + * ruling 1 legislated against for the Explorer jump and round 1 fixed on + * the fan-in popover; this is the same defect on the verdict bar, and it + * is a button a reader can press all day for no effect and no word. + */ + @Test + void askingTheAgentWithNoBoundSessionSaysSoOnTheBar() { + seed(finding("f1", Severity.NIT)); + host.sessionBound = false; + + clickAskAgent(); + + assertTrue(host.handedOffPrompts.isEmpty(), "nothing can be sent with no session"); + assertEquals("⚠ " + ReviewVerdictBar.NOTHING_TO_SEND, askRefusal(), + "a click that handed nothing over must say so"); + } + + /** The other half: a hand-off that WORKED must not leave a refusal on the bar. */ + @Test + void askingTheAgentWithASessionBoundReportsNoRefusal() { + seed(finding("f1", Severity.NIT)); + host.sessionBound = true; + + clickAskAgent(); + + assertEquals(1, host.handedOffPrompts.size(), "the findings must reach the session"); + assertEquals("", askRefusal(), "a hand-off that worked must say nothing"); + } + + /** + * The refusal describes ONE CLICK, not a state, so anything that + * re-renders the bar supersedes it -- otherwise a message about a click + * the reader has long moved on from sits there looking current. + */ + @Test + void theAskRefusalIsClearedByTheNextBarUpdate() { + seed(finding("f1", Severity.NIT)); + host.sessionBound = false; + clickAskAgent(); + assertFalse(askRefusal().isBlank()); + + type(KeyCode.CLOSE_BRACKET); + + assertEquals("", askRefusal(), "moving to another intent must clear it"); + } + + /** + * Round 3, item 3, widened in round 5 to all four paths. + * + *

Driven through the REAL view, because the bar-only fit fixture + * measures the bar at the window's full width and production does not + * give it that: at a 560px window the real bar is 525px, so a loop over + * the four strings there over-states the room by 35px -- about six + * characters, which is exactly the margin two of these strings live + * in. Each of the four is raised through the code path that actually + * raises it, so nothing here rests on substituting one message into + * another's layout.

+ */ + @Test + void theNeedsVerdictRefusalFitsAtTheCodeColumnFloor() { + seed(); + atTheFloor(); + + type(KeyCode.ENTER); + + assertSubmitRefusalFits(SessionReviewView.NEEDS_VERDICT); + } + + @Test + void theFailedDiffRefusalFitsAtTheCodeColumnFloor() { + seedWithNoDiffInTheColumn(mintPrScope(), new DiffOutcome.Failed("Could not diff /wt/feat")); + atTheFloor(); + + type(KeyCode.ENTER); + + assertSubmitRefusalFits(SessionReviewView.DIFF_FAILED); + } + + @Test + void theStillLoadingRefusalFitsAtTheCodeColumnFloor() { + seedWithNoDiffInTheColumn(mintPrScope(), new DiffOutcome.Diffing()); + atTheFloor(); + + type(KeyCode.ENTER); + + assertSubmitRefusalFits(SessionReviewView.DIFF_LOADING); + } + + /** Everything settled, but against a base that has since moved. */ + @Test + void theStaleBaseRefusalFitsAtTheCodeColumnFloor() { + seed(); + type(KeyCode.A); + type(KeyCode.CLOSE_BRACKET); + type(KeyCode.A); + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of("src/Main.java"))); + host.baseCommit = "9".repeat(40); + atTheFloor(); + + type(KeyCode.ENTER); + + assertSubmitRefusalFits(SessionReviewView.STALE_BASE); + } + + /** + * What keeps {@code ReviewVerdictBarFitTest}'s own fixture honest: it + * builds the bar at {@code BAR_WIDTH_AT_FLOOR}, a number taken FROM this + * measurement, and a bar-only fixture wider than production would let a + * string pass there and truncate here. If the view's chrome ever takes + * more room, this fails and that constant has to follow. + */ + @Test + void theRealBarIsNoNarrowerThanTheFitFixtureAssumes() { + seed(); + atTheFloor(); + + double[] barWidth = new double[1]; + interact(() -> barWidth[0] = lookup(".review-verdict-bar").query().getBoundsInLocal().getWidth()); + assertTrue(barWidth[0] >= ReviewVerdictBarFitTest.BAR_WIDTH_AT_FLOOR, + "the real bar is " + Math.round(barWidth[0]) + "px at a " + + (int) RailLayout.CODE_MIN_WIDTH + "px window, but the bar-only fit " + + "fixture assumes " + (int) ReviewVerdictBarFitTest.BAR_WIDTH_AT_FLOOR + + "px -- every string it clears would truncate in production"); + } + + /** + * Narrows the window to the code column's floor -- the width at which + * every rail is collapsed and the bar is the only surface left. {@link + * #seed} puts it back for the next test; nothing outside this class + * depends on the width it is left at, since every rendering class now + * takes its own through {@code TestStages.show}. + */ + private void atTheFloor() { + interact(() -> view.getScene().getWindow().setWidth(RailLayout.CODE_MIN_WIDTH)); + WaitForAsyncUtils.waitForFxEvents(); + } + + /** The refusal on screen is {@code expected}, and it is not truncated. */ + private void assertSubmitRefusalFits(SessionReviewView.SubmitRefusal expected) { + interact(() -> view.getScene().getRoot().layout()); + WaitForAsyncUtils.waitForFxEvents(); + + Node label = lookup(".review-verdict-submit-refusal").queryAll().stream() + .filter(Node::isVisible) + .findFirst() + .orElseThrow(() -> new AssertionError("no submit refusal is showing")); + assertEquals("⚠ " + expected.reason(), ((Label) label).getText(), + "the rendered text must be the PRODUCTION constant, not a copy kept in step by hand"); + double got = label.getBoundsInLocal().getWidth(); + double wanted = ((Label) label).prefWidth(-1); + assertTrue(got + 0.5 >= wanted, "'" + ((Label) label).getText() + "' got " + + Math.round(got) + " of " + Math.round(wanted) + "px at the " + + (int) RailLayout.CODE_MIN_WIDTH + "px floor"); + // The primary action is charged the same rent: a refusal that fits by + // taking Submit's last character has not fitted. + Node submit = lookup(".review-verdict-action").queryAll().stream() + .map(Button.class::cast) + .filter(button -> button.getText().startsWith("Submit")) + .findFirst() + .orElseThrow(() -> new AssertionError("no Submit button")); + assertTrue(submit.getBoundsInLocal().getWidth() + 0.5 >= ((Button) submit).prefWidth(-1), + "'" + ((Button) submit).getText() + "' got " + + Math.round(submit.getBoundsInLocal().getWidth()) + " of " + + Math.round(((Button) submit).prefWidth(-1)) + "px beside that refusal"); + } + + private void clickAskAgent() { + interact(() -> lookup(".review-verdict-action").queryAll().stream() + .map(Button.class::cast) + .filter(button -> "Ask the agent to fix it".equals(button.getText())) + .findFirst() + .orElseThrow(() -> new AssertionError("no Ask-the-agent button found")) + .fire()); + WaitForAsyncUtils.waitForFxEvents(); + } + + /** The text of the verdict bar's ask-refusal label; blank when it is not showing. */ + private String askRefusal() { + return lookup(".review-verdict-ask-refusal").queryAll().stream() + .filter(Node::isVisible) + .map(node -> ((Label) node).getText()) + .findFirst() + .orElse(""); + } + /** The text of the verdict bar's submit-refusal label; blank when it is not showing. */ private String submitRefusal() { return lookup(".review-verdict-submit-refusal").queryAll().stream() @@ -591,6 +782,14 @@ private static UnifiedDiff fileWithALongUnchangedRun() { /** Shows the board on one scope and seeds the store with {@code findings}. */ private void seed(ReviewAnnotation... findings) { + // The stage is shared across classes and across tests, and tests + // here narrow it deliberately -- so start every board from a known + // width rather than from whatever the last one left. + interact(() -> { + view.getScene().getWindow().setWidth(1400); + view.getScene().getWindow().setHeight(900); + }); + WaitForAsyncUtils.waitForFxEvents(); ReviewScope minted = mintScope(); for (ReviewAnnotation finding : findings) { host.store.upsert(finding); @@ -622,6 +821,15 @@ private ReviewAnnotation finding(String id, Severity severity) { Optional.empty(), AnnotationStatus.OPEN, Optional.empty(), false); } + /** + * The digest {@code src/Main.java}'s only hunk is approved under -- what + * a verdict is keyed by now that sections may overlap. The intent id + * ({@code auto:change:src}) keys nothing. + */ + private static String digestOfMain() { + return HunkDigest.of("src/Main.java", file("src/Main.java").hunks().get(0)); + } + private static UnifiedDiff.FileDiff file(String path) { return new UnifiedDiff.FileDiff(path, "M", 1, 0, false, false, List.of(new UnifiedDiff.Hunk("@@", List.of(new UnifiedDiff.Line( diff --git a/app/src/test/java/app/drydock/ui/review/ReviewHunkProgressTest.java b/app/src/test/java/app/drydock/ui/review/ReviewHunkProgressTest.java new file mode 100644 index 00000000..9c8c6f2c --- /dev/null +++ b/app/src/test/java/app/drydock/ui/review/ReviewHunkProgressTest.java @@ -0,0 +1,500 @@ +package app.drydock.ui.review; + +import app.drydock.ui.TestStages; +import app.drydock.git.DiffService; +import app.drydock.git.UnifiedDiff; +import app.drydock.review.BaseMove; +import app.drydock.review.HunkDigest; +import app.drydock.review.ReviewIntent; +import app.drydock.review.ReviewScope; +import app.drydock.review.ReviewScopeRegistry; +import app.drydock.review.ReviewVerdict; +import app.drydock.review.SessionReviewScopes; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.input.KeyCode; +import javafx.stage.Stage; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.testfx.framework.junit5.ApplicationTest; +import org.testfx.util.WaitForAsyncUtils; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.TreeSet; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * What the rail and the verdict bar actually RENDER once progress is counted + * in hunks (spec §5.6): the progress label, the settled card, the marker for + * a hunk settled in a neighbouring section, the stale banner, and the + * keyboard and Submit paths that walk sections. + * + *

The derivation behind all of it -- merge rules, counts, staleness, + * adrift groupings -- is {@link SectionStatesTest}, which needs no {@code + * Stage}. What is here is only what needs a rendered board.

+ * + *

This also re-pins the two assertions {@code ReviewCarriedOverVerdictTest} + * held before it was deleted with its subject: that a settled card carries + * the rail's {@code settled} style class, and that the verdict bar's progress + * label reads the count out. Both were the only coverage of their surface.

+ */ +class ReviewHunkProgressTest extends ApplicationTest { + + private final DiffService diffService = new DiffService(); + private final ReviewScopeRegistry registry = new ReviewScopeRegistry(); + private FakeReviewHost host; + private SessionReviewView view; + private ReviewScope scope; + + /** Three files, one hunk each, so a digest is addressable by its file alone. */ + private static final String GUARDS_H = "src/guards.h"; + private static final String GUARDS_CPP = "src/guards.cpp"; + private static final String PROFILER = "src/profiler.cpp"; + + @Override + public void start(Stage stage) { + try { + host = new FakeReviewHost(Files.createTempDirectory("drydock-progress") + .resolve("annotations.json")); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + host.diff = new UnifiedDiff(List.of( + file(GUARDS_H, "class JmpCtxScope;"), + file(GUARDS_CPP, "void install();"), + file(PROFILER, "resolve();"))); + view = new SessionReviewView(host, diffService, null); + Scene scene = new Scene(view, 1400, 900); + scene.getStylesheets().addAll( + getClass().getResource("/app/drydock/ui/app.css").toExternalForm(), + getClass().getResource("/app/drydock/ui/theme-dark.css").toExternalForm()); + TestStages.show(stage, scene); + } + + @AfterEach + void tearDown() { + diffService.close(); + host.store.close(); + } + + // ---- the verdict bar counts distinct hunks ------------------------------ + + /** + * Two sections that share {@code guards.h}: four section slots over three + * hunks. Anything summing section sizes reads 4 here. + */ + @Test + void progressCountsDistinctHunksNotSectionSlots() { + showOverlappingSections(); + + assertEquals("0/3 hunks reviewed", progressText()); + } + + @Test + void settlingASharedHunkAdvancesProgressExactlyOnce() { + showOverlappingSections(); + + approve(GUARDS_H); + + assertEquals("1/3 hunks reviewed", progressText(), + "a hunk in two sections is one flag, not two"); + } + + @Test + void everyHunkSettledReadsAsComplete() { + showOverlappingSections(); + + approve(GUARDS_H); + approve(GUARDS_CPP); + approve(PROFILER); + + assertEquals("3/3 hunks reviewed", progressText()); + } + + // ---- re-pinned: the rail's settled card --------------------------------- + + /** + * The {@code settled} style class is what dims a card. Deleted along with + * {@code ReviewCarriedOverVerdictTest}; nothing else asserts it. + */ + @Test + void aSettledSectionDimsItsCard() { + showOverlappingSections(); + assertEquals(0, settledCardCount(), "nothing is settled before a verdict"); + + approve(GUARDS_H); + approve(GUARDS_CPP); + + assertEquals(1, settledCardCount(), + "the section whose every hunk is settled dims; the other does not"); + } + + /** + * Settling section ① settles a hunk section ② also contains. Without + * saying where, ②'s count changes with no visible cause. + */ + @Test + void aHunkSettledElsewhereSaysWhereItWasSettled() { + showOverlappingSections(); + + approve(GUARDS_H); + approve(GUARDS_CPP); + + assertTrue(railText().contains("✓ reviewed in ①"), + "the rail must name the section that settled it, got: " + railText()); + } + + /** A settled card explains itself with its own verdict; the marker would be noise. */ + @Test + void aFullySettledCardDoesNotAlsoPointElsewhere() { + showOverlappingSections(); + + approve(GUARDS_H); + approve(GUARDS_CPP); + + assertFalse(railText().contains("✓ reviewed in ②"), + "settled section ① must not point at ②, got: " + railText()); + } + + // ---- verdicts are keyed by a real digest -------------------------------- + + @Test + void approvingASectionRecordsOneVerdictPerHunkKeyedByItsDigest() { + showOverlappingSections(); + + clickOn(".review-verdict-action"); + WaitForAsyncUtils.waitForFxEvents(); + + assertTrue(host.store.verdict(scope.id(), digestOf(GUARDS_H)).isPresent(), + "a verdict must be keyed by the hunk's content digest"); + assertTrue(host.store.verdict(scope.id(), digestOf(GUARDS_CPP)).isPresent()); + assertTrue(host.store.verdict(scope.id(), "section-1").isEmpty(), + "no verdict may be keyed by an intent id"); + } + + /** {@code u} undoes every hunk of the section it settled, not just one. */ + @Test + void undoingASectionClearsEveryHunkItSettled() { + showOverlappingSections(); + press(KeyCode.A).release(KeyCode.A); + WaitForAsyncUtils.waitForFxEvents(); + + press(KeyCode.U).release(KeyCode.U); + WaitForAsyncUtils.waitForFxEvents(); + + assertTrue(host.store.verdictsFor(scope.id()).isEmpty(), + "undo must clear the whole section it settled"); + } + + // ---- the stale banner --------------------------------------------------- + + @Test + void aBaseMoveTouchingTheSectionBannersIt() { + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of(GUARDS_H))); + showOverlappingSections(); + recordAgainstBase(GUARDS_H, "0".repeat(40)); + recordAgainstBase(GUARDS_CPP, "0".repeat(40)); + + assertEquals(SectionStates.Staleness.MOVED, view.diagSectionState(0).staleness()); + assertTrue(railLabels(".review-intent-stale").contains("⚠ base moved — confirm")); + } + + /** + * While the delta is still being computed -- or the old base can no + * longer be diffed -- nothing is known, and nothing may be claimed. A + * confirm-me banner on every settled card of a review nobody touched is + * worse than no banner: it trains the reader to click it reflexively. + */ + @Test + void anUnresolvableDeltaSaysNothingRatherThanWarning() { + host.baseDelta = new BaseMove.Delta(true, new TreeSet<>()); + showOverlappingSections(); + recordAgainstBase(GUARDS_H, "0".repeat(40)); + recordAgainstBase(GUARDS_CPP, "0".repeat(40)); + + assertEquals(SectionStates.Staleness.UNKNOWN, view.diagSectionState(0).staleness(), + "an unanswered question is not a finding"); + assertTrue(railLabels(".review-intent-stale").isEmpty(), + "no card may warn about a move nothing established"); + } + + /** + * A stale verdict does not count toward "everything settled" (spec + * §9.2): Submit must refuse it rather than post a decision nobody has + * actually confirmed against the code as it stands now. + */ + @Test + void submitRefusesWhileTheCurrentSectionIsStale() { + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of(GUARDS_H))); + showOverlappingSections(); + recordAgainstBase(GUARDS_H, "0".repeat(40)); + recordAgainstBase(GUARDS_CPP, "0".repeat(40)); + + press(KeyCode.ENTER).release(KeyCode.ENTER); + WaitForAsyncUtils.waitForFxEvents(); + + assertTrue(host.submittedScopes.isEmpty(), "a stale approval must not be posted silently"); + // The PRODUCTION constant, not a phrase copied out of it: this + // assertion went stale the moment the message was shortened to fit + // the bar's real width, which is the drift SubmitRefusal exists to + // stop. + assertTrue(labels(".review-verdict-submit-refusal").stream() + .anyMatch(text -> text.contains(SessionReviewView.STALE_BASE.reason())), + "the reader must be told why submit did nothing"); + } + + /** + * "Confirm still good" keeps the decision and rewrites its recorded + * base, so the section reads fresh again without a second read. + */ + @Test + void confirmStillGoodRewritesTheBaseAndClearsTheBanner() { + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of(GUARDS_H))); + showOverlappingSections(); + recordAgainstBase(GUARDS_H, "0".repeat(40)); + recordAgainstBase(GUARDS_CPP, "0".repeat(40)); + assertEquals(SectionStates.Staleness.MOVED, view.diagSectionState(0).staleness()); + + interact(() -> ((Button) lookup(".review-verdict-confirm-stale").query()).fire()); + WaitForAsyncUtils.waitForFxEvents(); + + assertEquals(SectionStates.Staleness.FRESH, view.diagSectionState(0).staleness()); + assertTrue(host.store.verdict(scope.id(), digestOf(GUARDS_H)) + .map(v -> v.decision() == ReviewVerdict.Decision.APPROVED).orElse(false), + "confirm still good must keep the decision, not clear it"); + } + + /** "Re-review" is the other answer: it clears the stale verdicts entirely. */ + @Test + void reReviewClearsTheStaleVerdicts() { + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of(GUARDS_H))); + showOverlappingSections(); + recordAgainstBase(GUARDS_H, "0".repeat(40)); + recordAgainstBase(GUARDS_CPP, "0".repeat(40)); + + interact(() -> ((Button) lookup(".review-verdict-re-review").query()).fire()); + WaitForAsyncUtils.waitForFxEvents(); + + assertTrue(host.store.verdict(scope.id(), digestOf(GUARDS_H)).isEmpty(), + "re-review must clear the stale verdict so the section can be read again"); + assertTrue(host.store.verdict(scope.id(), digestOf(GUARDS_CPP)).isEmpty()); + } + + /** + * The progress line and the submit gate must not disagree: a stale hunk + * does not count as settled in either one, or the reader is told "all + * settled -- ⏎ submits" one keystroke before Submit refuses it. + */ + @Test + void theProgressLineExcludesAStaleHunk() { + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of(GUARDS_H))); + showOverlappingSections(); + recordAgainstBase(GUARDS_H, "0".repeat(40)); + approve(GUARDS_CPP); + approve(PROFILER); + + assertEquals("2/3 hunks reviewed", progressText(), + "the stale GUARDS_H verdict must not read as reviewed"); + assertFalse(navHintText().contains("all settled"), + "the hint must not claim done while a stale hunk would refuse Submit"); + } + + private String navHintText() { + return labels(".review-verdict-hint").stream() + .filter(text -> !text.equals("press ? for shortcuts")) + .findFirst().orElse(""); + } + + /** + * Same bug as {@link #theProgressLineExcludesAStaleHunk}, one layer up + * (coordinator's review): {@link SectionStates#stateOf} used to count a + * stale verdict toward its OWN section's "n/total", so a card could + * read fully settled while the verdict bar's global progress line, for + * the identical hunks, read one short of it. + */ + @Test + void theRailCardsOwnCountExcludesAStaleHunkTooNotJustTheBar() { + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of(GUARDS_H))); + showOverlappingSections(); + recordAgainstBase(GUARDS_H, "0".repeat(40)); + approve(GUARDS_CPP); + + SectionStates.SectionState state = view.diagSectionState(0); + assertEquals(1, state.settledHunks(), + "section ①'s own count must exclude the stale GUARDS_H verdict, same as the bar's"); + assertEquals(2, state.totalHunks()); + } + + // ---- a grouping that drifted off the diff ------------------------------- + + /** + * Hunk ids are positional ({@code h__}), so an agent's + * grouping can name hunks a later diff does not have. Such a section can + * never be settled; counting it toward progress refuses Submit forever + * and jumps to the one card that cannot be settled. + */ + @Test + void aSectionWhoseHunksLeftTheDiffSaysSoAndIsNotCounted() { + showSectionsWithOneAdrift(); + + assertTrue(view.diagSectionState(1).hunksMissing(), + "a section naming hunks the diff does not have is adrift, not unread"); + assertEquals("0/2 hunks reviewed", progressText(), + "only the resolvable section's hunks may be counted"); + assertTrue(railLabels(".review-intent-adrift") + .contains("hunks are no longer in this diff"), + "the card has to say why it can never be settled"); + } + + /** With every countable hunk settled, Submit must go through. */ + @Test + void anAdriftSectionDoesNotDeadlockSubmit() { + showSectionsWithOneAdrift(); + approve(GUARDS_H); + approve(GUARDS_CPP); + + press(KeyCode.ENTER).release(KeyCode.ENTER); + WaitForAsyncUtils.waitForFxEvents(); + + assertEquals(List.of(scope.id()), host.submittedScopes, + "a section with nothing to settle must not hold the review hostage"); + } + + /** {@code n} must not park the cursor on a card that can never be settled. */ + @Test + void nextUnsettledSkipsAnAdriftSection() { + showSectionsWithOneAdrift(); + approve(GUARDS_H); + approve(GUARDS_CPP); + + press(KeyCode.N).release(KeyCode.N); + WaitForAsyncUtils.waitForFxEvents(); + + assertFalse(intentLabel().startsWith("2 "), + "n must not land on the adrift section, got: " + intentLabel()); + } + + // ---- helpers ------------------------------------------------------------ + + /** + * Section ① covers guards.h and guards.cpp; section ② covers guards.h + * again and profiler.cpp. Three hunks, four slots. + */ + private void showOverlappingSections() { + mintScope(); + host.intents.set(scope.id(), List.of( + section("section-1", "Guards", GUARDS_H, GUARDS_CPP), + section("section-2", "Profiler", GUARDS_H, PROFILER))); + show(); + } + + /** + * Section ① covers both guards files; section ② names a hunk index that + * file does not have, which is what a stale positional id looks like. + */ + private void showSectionsWithOneAdrift() { + mintScope(); + ReviewIntent adrift = new ReviewIntent("section-2", 0, "Profiler", + ReviewIntent.Kind.CHANGE, ReviewIntent.Risk.MED, "", + List.of(ReviewIntent.hunkId(PROFILER, 7)), Optional.empty(), false); + host.intents.set(scope.id(), List.of( + section("section-1", "Guards", GUARDS_H, GUARDS_CPP), adrift)); + show(); + } + + private void mintScope() { + scope = registry.mint(ReviewScopeRegistry.spec(ReviewScope.Kind.WORKING_TREE, + Path.of("/tmp/nowhere"), Optional.of(Path.of("/tmp/nowhere")), "main", "main", + Optional.empty(), Optional.empty())); + } + + private void show() { + interact(() -> view.showScopes(new SessionReviewScopes.Scopes(scope, Optional.empty()), + SessionReviewScopes.Choice.LOCAL)); + interact(() -> view.diagShowDiff(scope, host.diff)); + WaitForAsyncUtils.waitForFxEvents(); + } + + private static ReviewIntent section(String id, String title, String... files) { + List hunkIds = new ArrayList<>(); + for (String file : files) { + hunkIds.add(ReviewIntent.hunkId(file, 0)); + } + return new ReviewIntent(id, 0, title, ReviewIntent.Kind.CHANGE, ReviewIntent.Risk.MED, + "", hunkIds, Optional.empty(), false); + } + + private void approve(String file) { + put(file, ReviewVerdict.Decision.APPROVED, host.baseCommit); + } + + private void recordAgainstBase(String file, String base) { + put(file, ReviewVerdict.Decision.APPROVED, base); + } + + private void put(String file, ReviewVerdict.Decision decision, String base) { + host.store.putVerdict(new ReviewVerdict(scope.id(), digestOf(file), decision, + Optional.empty(), Instant.EPOCH, base, host.headCommit)); + interact(() -> view.refreshReviewState()); + WaitForAsyncUtils.waitForFxEvents(); + } + + private String digestOf(String file) { + return host.diff.files().stream() + .filter(candidate -> candidate.path().equals(file)) + .findFirst() + .map(candidate -> HunkDigest.of(file, candidate.hunks().get(0))) + .orElseThrow(); + } + + private String progressText() { + return labels(".review-verdict-progress-label").stream() + .findFirst().orElse(""); + } + + private long settledCardCount() { + List cards = new ArrayList<>(); + interact(() -> cards.addAll(lookup(".review-intent-card").queryAll())); + return cards.stream().filter(card -> card.getStyleClass().contains("settled")).count(); + } + + /** The texts of every label the rail drew under {@code selector}. */ + private List railLabels(String selector) { + return labels(selector); + } + + private String intentLabel() { + return labels(".review-verdict-intent").stream().findFirst().orElse(""); + } + + private String railText() { + return String.join(" ", labels(".review-intent-settled-elsewhere")); + } + + private List labels(String selector) { + List nodes = new ArrayList<>(); + interact(() -> nodes.addAll(lookup(selector).queryAll())); + return nodes.stream().filter(Label.class::isInstance) + .map(node -> ((Label) node).getText()).toList(); + } + + private static UnifiedDiff.FileDiff file(String path, String text) { + return new UnifiedDiff.FileDiff(path, "M", 1, 0, false, false, List.of( + new UnifiedDiff.Hunk("@@ -1 +1 @@", List.of( + new UnifiedDiff.Line(UnifiedDiff.Line.Kind.ADD, OptionalInt.empty(), + OptionalInt.of(1), text))))); + } +} diff --git a/app/src/test/java/app/drydock/ui/review/ReviewIntentFallbackTest.java b/app/src/test/java/app/drydock/ui/review/ReviewIntentFallbackTest.java index 3b2668ad..8f42fcc8 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewIntentFallbackTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewIntentFallbackTest.java @@ -1,5 +1,6 @@ package app.drydock.ui.review; +import app.drydock.ui.TestStages; import app.drydock.git.DiffService; import app.drydock.review.ReviewScope; import app.drydock.review.ReviewScopeRegistry; @@ -57,8 +58,7 @@ public void start(Stage stage) { scene.getStylesheets().addAll( getClass().getResource("/app/drydock/ui/app.css").toExternalForm(), getClass().getResource("/app/drydock/ui/theme-dark.css").toExternalForm()); - stage.setScene(scene); - stage.show(); + TestStages.show(stage, scene); } @AfterEach diff --git a/app/src/test/java/app/drydock/ui/review/ReviewIntentRailCardHeightTest.java b/app/src/test/java/app/drydock/ui/review/ReviewIntentRailCardHeightTest.java index 725e3c79..f6e3628c 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewIntentRailCardHeightTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewIntentRailCardHeightTest.java @@ -1,6 +1,9 @@ package app.drydock.ui.review; +import app.drydock.ui.TestStages; +import app.drydock.review.Provenance; import app.drydock.review.ReviewIntent; +import app.drydock.review.ReviewVerdict; import javafx.scene.Scene; import javafx.scene.control.Button; @@ -57,8 +60,7 @@ public void start(Stage stage) { scene.getStylesheets().addAll( getClass().getResource("/app/drydock/ui/app.css").toExternalForm(), getClass().getResource("/app/drydock/ui/theme-dark.css").toExternalForm()); - stage.setScene(scene); - stage.show(); + TestStages.show(stage, scene); } @Test @@ -95,10 +97,88 @@ void oneIntentIsOneCardTall() { "the only card is " + Math.round(height) + "px tall; " + diagCard()); } + // ---- Task 6's three new card elements, at the narrow rail width -------- + + /** + * The gate that closed Phase 1 found these three -- the settled- + * elsewhere marker, the adrift message and the stale banner -- with + * presence coverage but no fit coverage at either rail width. All three + * are {@code wrapText} labels, so "fit" here means what this class + * already measures: a sane card height, not the hundreds of pixels a + * label wrapped at zero width produces (see the class javadoc). + */ + @Test + void theSettledElsewhereMarkerNamingSeveralSectionsFitsAtNarrowWidth() { + narrow(); + rail.setSectionStateLookup(intent -> new SectionStates.SectionState( + Optional.empty(), 1, 1, 3, SectionStates.Staleness.FRESH, + List.of("①", "②", "③", "④", "⑤"), false, Provenance.MEASURED)); + showIntents(List.of(intent(1, "guards.h", ReviewIntent.Kind.CHANGE, "shared hunk"))); + + assertSaneHeight(cardHeights().get(0)); + } + + /** + * The agent-asserted banner is LONGER than the measured one ("⚠ agent: + * base moved — confirm"), and this rail has truncated before -- Task 18 + * shipped an illegible one. A longer string on the narrowest card is + * exactly where that recurs. + */ + @Test + void theClaimedStaleBannerFitsAtNarrowWidth() { + narrow(); + rail.setSectionStateLookup(intent -> new SectionStates.SectionState( + Optional.of(ReviewVerdict.Decision.APPROVED), 2, 2, 2, + SectionStates.Staleness.MOVED, List.of(), false, Provenance.CLAIMED)); + showIntents(List.of(intent(1, "guards.h", ReviewIntent.Kind.CHANGE, ""))); + + assertSaneHeight(cardHeights().get(0)); + } + + @Test + void theAdriftMessageFitsAtNarrowWidth() { + narrow(); + rail.setSectionStateLookup(intent -> SectionStates.SectionState.notInDiff()); + showIntents(List.of(intent(1, "profiler.cpp", ReviewIntent.Kind.CHANGE, ""))); + + assertSaneHeight(cardHeights().get(0)); + } + + @Test + void theStaleBannerFitsAtNarrowWidth() { + narrow(); + rail.setSectionStateLookup(intent -> new SectionStates.SectionState( + Optional.of(ReviewVerdict.Decision.APPROVED), 2, 2, 2, + SectionStates.Staleness.MOVED, List.of(), false, Provenance.MEASURED)); + showIntents(List.of(intent(1, "guards.h", ReviewIntent.Kind.CHANGE, ""))); + + assertSaneHeight(cardHeights().get(0)); + } + + /** + * Sets the rail's resolved width directly to {@link + * ReviewIntentRail#NARROW_WIDTH} rather than through {@code setNarrow}, + * whose collapse/expand path animates over 160ms -- this needs the + * width in place before the very first layout, not 160ms after it. + */ + private void narrow() { + interact(() -> { + rail.setMinWidth(ReviewIntentRail.NARROW_WIDTH); + rail.setPrefWidth(ReviewIntentRail.NARROW_WIDTH); + rail.setMaxWidth(ReviewIntentRail.NARROW_WIDTH); + }); + } + + private void assertSaneHeight(double height) { + assertTrue(height > 0 && height < SANE_CARD_HEIGHT, + "card is " + Math.round(height) + "px tall; cards are tens of pixels, not hundreds"); + } + // ---- helpers -------------------------------------------------------- private void showIntents(List intents) { - interact(() -> rail.setIntents(intents, intents.get(0).id(), ReviewIntentRail.Empty.NONE)); + interact(() -> rail.setIntents(intents, intents.get(0).id(), ReviewIntentRail.Empty.NONE, + Provenance.MEASURED)); WaitForAsyncUtils.waitForFxEvents(); // Heights are only real once a layout pass has run over the shown scene. interact(() -> rail.getScene().getRoot().layout()); diff --git a/app/src/test/java/app/drydock/ui/review/ReviewIntentRailEmptyStateTest.java b/app/src/test/java/app/drydock/ui/review/ReviewIntentRailEmptyStateTest.java index 0461b0bc..c081fed4 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewIntentRailEmptyStateTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewIntentRailEmptyStateTest.java @@ -1,5 +1,6 @@ package app.drydock.ui.review; +import app.drydock.ui.TestStages; import app.drydock.git.DiffService; import app.drydock.git.UnifiedDiff; import app.drydock.review.ReviewScope; @@ -55,8 +56,7 @@ public void start(Stage stage) throws Exception { scene.getStylesheets().addAll( getClass().getResource("/app/drydock/ui/app.css").toExternalForm(), getClass().getResource("/app/drydock/ui/theme-dark.css").toExternalForm()); - stage.setScene(scene); - stage.show(); + TestStages.show(stage, scene); Path repo = Files.createDirectories( Files.createTempDirectory("drydock-empty-reason-repo").resolve("repo")); diff --git a/app/src/test/java/app/drydock/ui/review/ReviewIntentScopeIsolationTest.java b/app/src/test/java/app/drydock/ui/review/ReviewIntentScopeIsolationTest.java index 4ff3254c..a6651364 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewIntentScopeIsolationTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewIntentScopeIsolationTest.java @@ -1,5 +1,6 @@ package app.drydock.ui.review; +import app.drydock.ui.TestStages; import app.drydock.git.DiffService; import app.drydock.review.ReviewAnnotation; import app.drydock.review.ReviewScope; @@ -52,8 +53,7 @@ public void start(Stage stage) { scene.getStylesheets().addAll( getClass().getResource("/app/drydock/ui/app.css").toExternalForm(), getClass().getResource("/app/drydock/ui/theme-dark.css").toExternalForm()); - stage.setScene(scene); - stage.show(); + TestStages.show(stage, scene); } @AfterEach diff --git a/app/src/test/java/app/drydock/ui/review/ReviewLandsOnFirstIntentTest.java b/app/src/test/java/app/drydock/ui/review/ReviewLandsOnFirstIntentTest.java index 02a1f984..8af26420 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewLandsOnFirstIntentTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewLandsOnFirstIntentTest.java @@ -1,5 +1,6 @@ package app.drydock.ui.review; +import app.drydock.ui.TestStages; import app.drydock.git.DiffService; import app.drydock.review.ReviewScope; import app.drydock.review.ReviewScopeRegistry; @@ -71,8 +72,7 @@ public void start(Stage stage) { scene.getStylesheets().addAll( getClass().getResource("/app/drydock/ui/app.css").toExternalForm(), getClass().getResource("/app/drydock/ui/theme-dark.css").toExternalForm()); - stage.setScene(scene); - stage.show(); + TestStages.show(stage, scene); } @AfterEach diff --git a/app/src/test/java/app/drydock/ui/review/ReviewLinkFooterWiringTest.java b/app/src/test/java/app/drydock/ui/review/ReviewLinkFooterWiringTest.java new file mode 100644 index 00000000..13af0d6e --- /dev/null +++ b/app/src/test/java/app/drydock/ui/review/ReviewLinkFooterWiringTest.java @@ -0,0 +1,262 @@ +package app.drydock.ui.review; + +import app.drydock.ui.TestStages; +import app.drydock.git.DiffService; +import app.drydock.git.UnifiedDiff; +import app.drydock.review.ReviewIntent; +import app.drydock.review.ReviewScope; +import app.drydock.review.ReviewScopeRegistry; +import app.drydock.review.SessionReviewScopes; + +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.stage.Stage; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.testfx.framework.junit5.ApplicationTest; +import org.testfx.util.WaitForAsyncUtils; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.OptionalInt; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Proves the WIRING, not the row model or the column's own rendering -- + * both already have direct tests ({@code ReviewDiffRowsTest}, + * {@link ReviewLinkRowTest}). What only an end-to-end test can catch: that + * {@link SessionReviewView#refreshReviewState} actually reaches into a real + * {@link app.drydock.review.ReadingPath.Path}, computed from a real {@link + * app.drydock.review.ChangeGraph} over genuinely cross-referencing code, and + * hands the result to {@link ReviewDiffColumn#setLinks} -- rather than the + * column rendering correctly from data nobody ever supplies it in the real + * app. + * + *

No reviewer grouping is installed on {@link #host} (unlike {@link + * ReviewViewFixture}'s shared board): {@code Host#hasReviewerGrouping} + * false is what makes {@link SessionReviewView} request a {@link + * app.drydock.review.ChangeGraph} on its own, off the FX thread, the moment + * the diff resolves -- the same trigger PATH mode already relies on (see + * {@code ReviewPathModeTest}'s own javadoc) -- so no keypress is needed to + * exercise it here.

+ */ +class ReviewLinkFooterWiringTest extends ApplicationTest { + + private static final String DECLARING_FILE = "src/guards.cpp"; + private static final String REFERENCING_FILE = "src/profiler.cpp"; + + private final ReviewScopeRegistry registry = new ReviewScopeRegistry(); + private final DiffService diffService = new DiffService(); + private FakeReviewHost host; + private SessionReviewView view; + private ReviewScope scope; + + @Override + public void start(Stage stage) { + try { + host = new FakeReviewHost(Files.createTempDirectory("drydock-link-wiring") + .resolve("annotations.json")); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + // A real cross-file call: REFERENCING_FILE constructs a symbol + // DECLARING_FILE declares, so ChangeGraph.of finds a genuine "called + // by" edge between their two hunks -- the same shape ReadingPathTest + // uses to pin ReadingPath itself, reused here to pin that this view + // actually reaches that machinery. A large, unrelated filler file + // sits between the two in the DIFF'S OWN order (which is what the + // rendered column follows, unlike ReadingPath's reordered steps), so + // REFERENCING_FILE starts below the fold and clicking the footer has + // somewhere real to scroll to. + host.diff = new UnifiedDiff(List.of( + oneLineFile(DECLARING_FILE, "class JmpCtxScope { };"), + fillerFile("src/filler.cpp"), + oneLineFile(REFERENCING_FILE, "void go() { new JmpCtxScope(); }"))); + view = new SessionReviewView(host, diffService, null); + Scene scene = new Scene(view, 1400, 900); + scene.getStylesheets().addAll( + getClass().getResource("/app/drydock/ui/app.css").toExternalForm(), + getClass().getResource("/app/drydock/ui/theme-dark.css").toExternalForm()); + TestStages.show(stage, scene); + } + + @AfterEach + void tearDown() { + diffService.close(); + host.store.close(); + } + + @Test + void theRealReadingPathsLinksReachTheDiffColumnWithNoPathModeNeeded() { + scope = registry.mint(ReviewScopeRegistry.spec(ReviewScope.Kind.WORKING_TREE, + Path.of("/tmp/nowhere"), Optional.of(Path.of("/tmp/nowhere")), "main", "main", + Optional.empty(), Optional.empty())); + // Deliberately no host.intents.set(...): hasReviewerGrouping stays + // false, which is what makes the graph -- and therefore the links -- + // build without PATH mode ever being entered. + interact(() -> view.showScopes(new SessionReviewScopes.Scopes(scope, Optional.empty()), + SessionReviewScopes.Choice.LOCAL)); + // The control half of Task 19 round 2's pin (see the grouped test + // below): with no reviewer grouping, the rail's own "refining + // grouping…" banner must be up the instant the build starts, or the + // grouped test's "banner never shown" assertion would pass just as + // well with a banner that is simply broken and never renders at all. + assertTrue(showDiffAndSampleBanner(), + "control: with no reviewer grouping, \"refining grouping…\" must show the moment " + + "the graph starts building, or the grouped test's absence assertion is vacuous"); + WaitForAsyncUtils.waitForFxEvents(); + + List footers = awaitLinkFooters(); + assertTrue(footers.stream().anyMatch(text -> text.contains("called by")), + "expected a real called-by footer once the graph lands; rendered " + footers); + assertTrue(footers.stream().anyMatch(text -> text.contains("profiler.cpp")), + "the footer must name the referencing file; rendered " + footers); + assertFalse(footers.stream().anyMatch(text -> text.contains("h_")), + "no rendered footer may leak a raw hunk id; rendered " + footers); + + // The click mechanism itself (raw hunk id -> revealHunk) already has + // a precise, controlled proof in ReviewLinkRowTest -- this test's own + // job is the DATA, not re-proving the scroll. What is worth checking + // here is the round trip through REAL production code: the target id + // this button carries was minted by ReadingPath.linksFrom via the + // real ReviewIntent.hunkId, not by a test fixture, so firing it must + // still resolve and must not throw. + Button link = footerButtonContaining("called by"); + interact(link::fire); + WaitForAsyncUtils.waitForFxEvents(); + + assertTrue(renderedHunkFiles().contains(REFERENCING_FILE), + "the target file must still be reachable after the click resolves its real hunk id"); + } + + /** + * The pin for Task 19 round 2's fix at {@code SessionReviewView.java:676}: + * a reviewer grouping ({@code host.intents.set(...)}, unlike the test + * above) must not stop the diff column's link footers from rendering -- + * that configuration is the PRIMARY one this feature ships in -- and the + * rail's OWN "refining grouping…" banner must never flash for a + * grouping that is already final, even while the same graph builds + * purely to feed those footers (the fix at {@code :1088-1089}). + */ + @Test + void linkFootersStillRenderUnderAReviewerGroupingWithoutFlashingTheBanner() { + scope = registry.mint(ReviewScopeRegistry.spec(ReviewScope.Kind.WORKING_TREE, + Path.of("/tmp/nowhere"), Optional.of(Path.of("/tmp/nowhere")), "main", "main", + Optional.empty(), Optional.empty())); + host.intents.set(scope.id(), List.of(new ReviewIntent("agent-1", 1, "Reviewed", + ReviewIntent.Kind.CHANGE, ReviewIntent.Risk.HIGH, "", + List.of(ReviewIntent.hunkId(DECLARING_FILE, 0), ReviewIntent.hunkId(REFERENCING_FILE, 0)), + Optional.empty(), false))); + + interact(() -> view.showScopes(new SessionReviewScopes.Scopes(scope, Optional.empty()), + SessionReviewScopes.Choice.LOCAL)); + // Same synchronous sample as the ungrouped test's control, but here + // hasReviewerGrouping is true: the banner must be down at this exact + // instant, not just eventually -- see showDiffAndSampleBanner's + // javadoc for why this moment, and only this moment, is race-free. + assertFalse(showDiffAndSampleBanner(), + "a reviewer's already-final grouping must never flash \"refining grouping…\" just " + + "because a graph is building in the background for link footers"); + WaitForAsyncUtils.waitForFxEvents(); + + List footers = awaitLinkFooters(); + assertTrue(footers.stream().anyMatch(text -> text.contains("called by")), + "expected a real called-by footer under a reviewer's own grouping too; rendered " + + footers); + assertTrue(footers.stream().anyMatch(text -> text.contains("profiler.cpp")), + "the footer must name the referencing file; rendered " + footers); + } + + /** Building the graph runs on a virtual thread; poll rather than trust one FX pulse. */ + private List awaitLinkFooters() { + long start = System.nanoTime(); + while (System.nanoTime() - start < 30_000_000_000L) { + List texts = linkRowTexts(); + if (!texts.isEmpty()) { + return texts; + } + WaitForAsyncUtils.waitForFxEvents(); + sleep(50); + } + throw new AssertionError("no link footer ever rendered"); + } + + /** + * Calls {@code diagShowDiff} and, in the very same FX-thread task, + * samples whether the rail's "refining grouping…" banner ({@code + * .review-intent-pending}, per {@link ReviewIntentRail}) is visible -- + * the same accessor {@code SectionRailSwapTest} already uses for this + * state, since {@link ReviewIntentRail} exposes no test-visible {@code + * groupingPending} getter of its own. + * + *

{@code diagShowDiff} -> {@code onDiffResolved} -> {@code + * requestGraph} -> {@code refreshReviewState} all run synchronously, + * on the FX thread, before this method's own lambda returns -- the + * graph itself only starts building on a SEPARATE thread as part of + * {@code requestGraph}. Sampling the banner in that same lambda, before + * control ever returns to the test thread, is the one moment guaranteed + * to reflect what the diff's arrival itself set, rather than racing + * however fast the background build happens to finish (which, for a + * fixture this small, can beat even a single subsequent {@code + * waitForFxEvents} call).

+ */ + private boolean showDiffAndSampleBanner() { + List sample = new ArrayList<>(); + interact(() -> { + view.diagShowDiff(scope, host.diff); + sample.add(lookup(".review-intent-pending").queryAll().stream().anyMatch(Node::isVisible)); + }); + return sample.get(0); + } + + private List linkRowTexts() { + List texts = new ArrayList<>(); + interact(() -> lookup(".review-link-row").queryAll() + .forEach(node -> texts.add(((Button) node).getText()))); + return texts; + } + + private Button footerButtonContaining(String text) { + List found = new ArrayList<>(); + interact(() -> found.addAll(lookup(".review-link-row").queryAll())); + return found.stream() + .map(Button.class::cast) + .filter(button -> button.getText().contains(text)) + .findFirst() + .orElseThrow(() -> new AssertionError("no footer contains \"" + text + "\"")); + } + + private List renderedHunkFiles() { + List files = new ArrayList<>(); + interact(() -> lookup(".review-hunk-file").queryAll() + .forEach(node -> files.add(((Label) node).getText()))); + return files; + } + + private static UnifiedDiff.FileDiff oneLineFile(String path, String text) { + UnifiedDiff.Hunk hunk = new UnifiedDiff.Hunk("@@ -0,0 +1 @@", + List.of(new UnifiedDiff.Line(UnifiedDiff.Line.Kind.ADD, OptionalInt.empty(), + OptionalInt.of(1), text))); + return new UnifiedDiff.FileDiff(path, "M", 1, 0, false, false, List.of(hunk)); + } + + /** 150 unrelated added lines, purely to push whatever follows it below a 900px viewport. */ + private static UnifiedDiff.FileDiff fillerFile(String path) { + List lines = new ArrayList<>(); + for (int i = 1; i <= 150; i++) { + lines.add(new UnifiedDiff.Line(UnifiedDiff.Line.Kind.ADD, OptionalInt.empty(), + OptionalInt.of(i), "int field" + i + " = " + i + ";")); + } + return new UnifiedDiff.FileDiff(path, "M", 150, 0, false, false, + List.of(new UnifiedDiff.Hunk("@@ -0,0 +1,150 @@", lines))); + } +} diff --git a/app/src/test/java/app/drydock/ui/review/ReviewLinkRowTest.java b/app/src/test/java/app/drydock/ui/review/ReviewLinkRowTest.java new file mode 100644 index 00000000..3e0ee2e5 --- /dev/null +++ b/app/src/test/java/app/drydock/ui/review/ReviewLinkRowTest.java @@ -0,0 +1,341 @@ +package app.drydock.ui.review; + +import app.drydock.ui.TestStages; +import app.drydock.git.DiffService; +import app.drydock.git.UnifiedDiff; +import app.drydock.review.ReadingPath; +import app.drydock.review.ReviewIntent; +import app.drydock.review.ReviewScope; +import app.drydock.review.ReviewScopeRegistry; + +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.stage.Stage; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.testfx.framework.junit5.ApplicationTest; +import org.testfx.util.WaitForAsyncUtils; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalInt; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * "What does this hunk have to do with the one I just read" (spec §7.2), + * rendered where the question is asked -- a footer row under the hunk it + * belongs to, not a file-level note and not a parallel rendering path. The + * row model itself ({@link ReviewDiffRow.LinkRow}) already has headless + * pinning tests in {@link ReviewDiffRowsTest}; what is worth testing here, + * the same way {@link ReviewDiffColumnTest} draws that line for the rest of + * the column, is the WIRING: a real {@link ReviewDiffColumn#setLinks} call + * renders a clickable row whose label names files and symbols, and clicking + * it drives the column's existing {@link ReviewDiffColumn#revealHunk} scroll + * path to the labelled target -- never a target the label does not name. + * + *

Links are injected directly through {@link ReviewDiffColumn#setLinks} + * rather than produced by a real {@link app.drydock.review.ChangeGraph}: that + * pipeline (a hunk's symbols to a {@link ReadingPath.Link}) already has its + * own headless tests in {@code ReadingPathTest}, so reproducing it here would + * pin the same behaviour twice under a heavier, git-backed harness.

+ */ +class ReviewLinkRowTest extends ApplicationTest { + + private static final String FILE_A = "src/guards.h"; + private static final String FILE_B = "src/guards.cpp"; + private static final String FILE_C = "src/unrelated.cpp"; + + private final DiffService diffService = new DiffService(); + private final ReviewScopeRegistry registry = new ReviewScopeRegistry(); + private ReviewDiffColumn column; + + @Override + public void start(Stage stage) { + column = new ReviewDiffColumn(diffService, (scope, file, line) -> false); + Scene scene = new Scene(column, 1000, 700); + scene.getStylesheets().addAll( + getClass().getResource("/app/drydock/ui/app.css").toExternalForm(), + getClass().getResource("/app/drydock/ui/theme-dark.css").toExternalForm()); + TestStages.show(stage, scene); + } + + @AfterEach + void tearDown() { + diffService.close(); + } + + @Test + void aHunkWithALinkGetsAFooterRowBeneathIt() { + showTwoFileDiff(); + String targetHunkId = ReviewIntent.hunkId(FILE_B, 0); + setLinks(Map.of(ReviewIntent.hunkId(FILE_A, 0), + List.of(new ReadingPath.Link(ReadingPath.CALLED_BY, targetHunkId, "guards.cpp:forCheckout")))); + + assertTrue(linkRowTexts().stream().anyMatch(text -> text.contains("called by")), + "expected a footer naming the relationship; rendered " + linkRowTexts()); + } + + @Test + void aLinkNamesItsTargetFileAndSymbolNotARawHunkId() { + showTwoFileDiff(); + String targetHunkId = ReviewIntent.hunkId(FILE_B, 0); + setLinks(Map.of(ReviewIntent.hunkId(FILE_A, 0), + List.of(new ReadingPath.Link(ReadingPath.CALLED_BY, targetHunkId, "guards.cpp:forCheckout")))); + + List texts = linkRowTexts(); + assertTrue(texts.stream().anyMatch(text -> text.contains("guards.cpp"))); + assertTrue(texts.stream().noneMatch(text -> text.contains(targetHunkId)), + "the raw hunk id must never leak into the label: " + texts); + assertTrue(texts.stream().noneMatch(text -> text.contains("h_")), + "no rendered text may carry the h_ hunk-id prefix: " + texts); + } + + @Test + void aHunkWithNoLinksGetsNoFooterRow() { + showTwoFileDiff(); + setLinks(Map.of(ReviewIntent.hunkId(FILE_A, 0), + List.of(new ReadingPath.Link(ReadingPath.CALLS, ReviewIntent.hunkId(FILE_B, 0), "guards.cpp:x")))); + + assertEquals(1, linkRowTexts().size(), + "only FILE_A's hunk carries a link; FILE_B and FILE_C carry none"); + } + + /** Clicking a link must select exactly the hunk its own label names -- see the class javadoc. */ + @Test + void clickingALinkScrollsToTheLabelledTargetHunk() { + showTwoFilesFarApart(); + assertFalse(renderedHunkFiles().contains(FILE_B), + "the fixture must start with the target file below the fold"); + String targetHunkId = ReviewIntent.hunkId(FILE_B, 0); + setLinks(Map.of(ReviewIntent.hunkId(FILE_A, 0), + List.of(new ReadingPath.Link(ReadingPath.CALLS, targetHunkId, "guards.cpp:x")))); + + Button link = (Button) lookup(".review-link-row").query(); + interact(link::fire); + WaitForAsyncUtils.waitForFxEvents(); + + assertTrue(renderedHunkFiles().contains(FILE_B), + "clicking the link must scroll to the hunk it names; rendered " + renderedHunkFiles()); + } + + /** + * The graph a link map is computed from lands asynchronously (spec's own + * note: well after the diff itself rendered), so an open comment + * composer and an incoming {@code setLinks} call race by construction -- + * a reader can always be mid-comment when it lands. {@code rebuild()} + * re-inserts the composer row after rebuilding {@code rows}; {@code + * setLinks} must do the same or every graph completion silently erases + * whatever the reader was typing. + */ + @Test + void setLinksDoesNotDiscardAnOpenCommentComposer() { + showTwoFileDiff(); + clickGutterForLine("1"); + assertEquals(1, composerCount(), "the gutter click must open a composer to begin with"); + + setLinks(Map.of(ReviewIntent.hunkId(FILE_A, 0), + List.of(new ReadingPath.Link(ReadingPath.CALLS, ReviewIntent.hunkId(FILE_B, 0), "guards.cpp:x")))); + + assertEquals(1, composerCount(), + "an async graph landing (setLinks) must not silently drop an open comment composer"); + } + + /** No footer row is focus-traversable garbage: it must be reachable by keyboard like the rest of the card. */ + @Test + void aLinkRowIsFocusTraversable() { + showTwoFileDiff(); + setLinks(Map.of(ReviewIntent.hunkId(FILE_A, 0), + List.of(new ReadingPath.Link(ReadingPath.CALLS, ReviewIntent.hunkId(FILE_B, 0), "guards.cpp:x")))); + + Button link = (Button) lookup(".review-link-row").query(); + assertTrue(link.isFocusTraversable()); + } + + /** + * PATH mode narrows the column to a synthetic one-hunk intent + * ({@code SessionReviewView.pathStepAsIntent}) before any footer's click + * can even fire -- a link is cross-file by construction (spec §7.2), so + * its target is routinely a hunk that narrow filter does not show at + * all. Left unfixed, the click fires {@link ReviewDiffColumn#revealHunk} + * against a row list that never contained the target, which silently + * does nothing -- exactly the display/action divergence the brief + * warns about. + */ + @Test + void clickingALinkFilteredOutOfTheCurrentViewWidensAndReachesItsTarget() { + showTwoFilesFarApart(); + ReviewIntent onlyFileA = new ReviewIntent("path:only-a", 1, FILE_A, ReviewIntent.Kind.CHANGE, + ReviewIntent.Risk.NONE, "", List.of(ReviewIntent.hunkId(FILE_A, 0)), Optional.empty(), false); + interact(() -> column.setIntent(onlyFileA)); + assertFalse(renderedHunkFiles().contains(FILE_B), + "the narrowed filter must exclude the link's target file up front"); + + String targetHunkId = ReviewIntent.hunkId(FILE_B, 0); + setLinks(Map.of(ReviewIntent.hunkId(FILE_A, 0), + List.of(new ReadingPath.Link(ReadingPath.CALLS, targetHunkId, "guards.cpp:x")))); + + Button link = (Button) lookup(".review-link-row").query(); + interact(link::fire); + WaitForAsyncUtils.waitForFxEvents(); + + assertTrue(renderedHunkFiles().contains(FILE_B), + "a cross-file link must widen out of a one-hunk filter to reach its target, the " + + "way PATH mode narrows the column before every footer click; rendered " + + renderedHunkFiles()); + } + + /** + * Three hunks in one file: a tiny one (index 0, excluded by the + * filter), a GIANT one (index 1, included -- pushes index 2 below the + * fold), and a tiny one (index 2, included). {@link + * ReviewDiffColumn#revealHunk} used to count RENDERED headers in order + * rather than match the real hunk index carried on {@link + * ReviewDiffRow.HunkHeader#hunkIndex()}, so asking for real hunk 2 (the + * second and last rendered header once hunk 0 is filtered out) fell + * through that off-by-one onto hunk 1 -- the FIRST rendered header -- + * while still reporting success. + */ + @Test + void revealHunkLandsOnTheRealHunkIndexNotThePositionAmongRenderedHeaders() { + UnifiedDiff diff = new UnifiedDiff(List.of(threeHunkFile())); + interact(() -> column.showDiff(scope(), diff)); + WaitForAsyncUtils.waitForFxEvents(); + + ReviewIntent excludeFirstHunk = new ReviewIntent("only-1-and-2", 1, FILE_A, ReviewIntent.Kind.CHANGE, + ReviewIntent.Risk.NONE, "", + List.of(ReviewIntent.hunkId(FILE_A, 1), ReviewIntent.hunkId(FILE_A, 2)), Optional.empty(), false); + interact(() -> column.setIntent(excludeFirstHunk)); + + assertFalse(renderedRangeLabels().contains("L300"), + "hunk 2 must start below the fold, behind the giant hunk 1; rendered " + + renderedRangeLabels()); + + boolean[] reached = new boolean[1]; + interact(() -> reached[0] = column.revealHunk(FILE_A, 2)); + WaitForAsyncUtils.waitForFxEvents(); + + assertTrue(reached[0]); + assertTrue(renderedRangeLabels().contains("L300"), + "revealHunk(file, 2) must land on the REAL hunk 2 (\"L300\"), not on hunk 1 -- the " + + "first RENDERED header, and the old counting bug's target; rendered " + + renderedRangeLabels()); + } + + // ---- helpers -------------------------------------------------------------- + + private void setLinks(Map> byHunkId) { + interact(() -> column.setLinks(byHunkId)); + WaitForAsyncUtils.waitForFxEvents(); + } + + private List linkRowTexts() { + List texts = new ArrayList<>(); + interact(() -> lookup(".review-link-row").queryAll() + .forEach(node -> texts.add(((Button) node).getText()))); + return texts; + } + + /** Direct handler dispatch, not {@code clickOn}: see {@code ReviewCommentComposerTest} for why. */ + private void clickGutterForLine(String lineNumber) { + List gutters = new ArrayList<>(); + interact(() -> gutters.addAll(lookup(".review-code-gutter").queryAll())); + Node gutter = gutters.stream() + .filter(node -> node.getOnMouseClicked() != null) + .filter(node -> lineNumber.equals(((Label) node).getText())) + .findFirst() + .orElseThrow(() -> new AssertionError("no clickable gutter for line " + lineNumber)); + interact(() -> gutter.getOnMouseClicked().handle(new javafx.scene.input.MouseEvent( + javafx.scene.input.MouseEvent.MOUSE_CLICKED, 0, 0, 0, 0, + javafx.scene.input.MouseButton.PRIMARY, 1, + false, false, false, false, true, false, false, true, false, false, null))); + WaitForAsyncUtils.waitForFxEvents(); + } + + private int composerCount() { + List found = new ArrayList<>(); + interact(() -> found.addAll(lookup(".review-composer").queryAll())); + return found.size(); + } + + private List renderedHunkFiles() { + List files = new ArrayList<>(); + interact(() -> lookup(".review-hunk-file").queryAll() + .forEach(node -> files.add(((Label) node).getText()))); + return files; + } + + private List renderedRangeLabels() { + List labels = new ArrayList<>(); + interact(() -> lookup(".review-hunk-range").queryAll() + .forEach(node -> labels.add(((Label) node).getText()))); + return labels; + } + + /** See {@link #revealHunkLandsOnTheRealHunkIndexNotThePositionAmongRenderedHeaders}. */ + private static UnifiedDiff.FileDiff threeHunkFile() { + UnifiedDiff.Hunk hunk0 = new UnifiedDiff.Hunk("@@ -0,0 +1 @@", + List.of(new UnifiedDiff.Line(UnifiedDiff.Line.Kind.ADD, OptionalInt.empty(), + OptionalInt.of(1), "void a();"))); + List giant = new ArrayList<>(); + for (int i = 100; i < 250; i++) { + giant.add(new UnifiedDiff.Line(UnifiedDiff.Line.Kind.ADD, OptionalInt.empty(), + OptionalInt.of(i), "int f" + i + ";")); + } + UnifiedDiff.Hunk hunk1 = new UnifiedDiff.Hunk("@@ -0,0 +100,150 @@", giant); + UnifiedDiff.Hunk hunk2 = new UnifiedDiff.Hunk("@@ -0,0 +300 @@", + List.of(new UnifiedDiff.Line(UnifiedDiff.Line.Kind.ADD, OptionalInt.empty(), + OptionalInt.of(300), "void c();"))); + return new UnifiedDiff.FileDiff(FILE_A, "M", 152, 0, false, false, List.of(hunk0, hunk1, hunk2)); + } + + private void showTwoFileDiff() { + UnifiedDiff diff = new UnifiedDiff(List.of( + oneLineFile(FILE_A, "void foo();"), + oneLineFile(FILE_B, "void bar();"), + oneLineFile(FILE_C, "void baz();"))); + interact(() -> column.showDiff(scope(), diff)); + WaitForAsyncUtils.waitForFxEvents(); + } + + /** + * FILE_A stays a single line -- its own footer must render near the top, + * not fall off the bottom of a huge card of its own -- with a large + * unrelated filler file between it and FILE_B, so FILE_B's header starts + * below a 700px viewport without FILE_A's card growing at all. + */ + private void showTwoFilesFarApart() { + List fillerLines = new ArrayList<>(); + for (int i = 1; i <= 150; i++) { + fillerLines.add(new UnifiedDiff.Line(UnifiedDiff.Line.Kind.ADD, OptionalInt.empty(), + OptionalInt.of(i), "int field" + i + " = " + i + ";")); + } + UnifiedDiff.Hunk fillerHunk = new UnifiedDiff.Hunk("@@ -0,0 +1,150 @@", fillerLines); + UnifiedDiff diff = new UnifiedDiff(List.of( + oneLineFile(FILE_A, "void foo();"), + new UnifiedDiff.FileDiff("src/filler.cpp", "M", 150, 0, false, false, List.of(fillerHunk)), + oneLineFile(FILE_B, "void bar();"))); + interact(() -> column.showDiff(scope(), diff)); + WaitForAsyncUtils.waitForFxEvents(); + } + + private ReviewScope scope() { + return registry.mint(ReviewScopeRegistry.spec(ReviewScope.Kind.WORKING_TREE, + Path.of("/tmp/nowhere"), Optional.of(Path.of("/tmp/nowhere")), "main", "main", + Optional.empty(), Optional.empty())); + } + + private static UnifiedDiff.FileDiff oneLineFile(String path, String text) { + UnifiedDiff.Hunk hunk = new UnifiedDiff.Hunk("@@ -0,0 +1 @@", + List.of(new UnifiedDiff.Line(UnifiedDiff.Line.Kind.ADD, OptionalInt.empty(), + OptionalInt.of(1), text))); + return new UnifiedDiff.FileDiff(path, "M", 1, 0, false, false, List.of(hunk)); + } +} diff --git a/app/src/test/java/app/drydock/ui/review/ReviewPathModeTest.java b/app/src/test/java/app/drydock/ui/review/ReviewPathModeTest.java new file mode 100644 index 00000000..74cbed8a --- /dev/null +++ b/app/src/test/java/app/drydock/ui/review/ReviewPathModeTest.java @@ -0,0 +1,413 @@ +package app.drydock.ui.review; + +import app.drydock.review.AnnotationStatus; +import app.drydock.review.Confidence; +import app.drydock.review.HunkDigest; +import app.drydock.review.ReviewAnnotation; +import app.drydock.review.ReviewVerdict; +import app.drydock.review.Severity; +import app.drydock.ui.ShortcutsOverlay; + +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.input.KeyCode; +import org.junit.jupiter.api.Test; +import org.testfx.util.WaitForAsyncUtils; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * {@code p} gives the rail a second mode (spec §7.1): {@code PATH} lists one + * row per hunk in reading order, across section boundaries, rather than + * today's per-intent cards. It is a mode of the rail, not a fourth column -- + * the width budget that ruled out a concept map rules out a new column just + * as firmly, and {@code RailLayout} is untouched by this task. + * + *

{@link ReviewViewFixture}'s board supplies a REVIEWER grouping ({@code + * host.intents.set(...)}), which is exactly the case that used to skip + * building a {@link app.drydock.review.ChangeGraph} at all ({@code + * Host#hasReviewerGrouping}) -- PATH mode needs one regardless, so entering + * it for the first time kicks a build off lazily. That build runs on a + * virtual thread, off the FX thread, so every test below that needs real + * steps waits for {@link #awaitPathReady()} rather than trusting {@link + * WaitForAsyncUtils#waitForFxEvents()} alone to have let it finish.

+ */ +class ReviewPathModeTest extends ReviewViewFixture { + + @Test + void pTogglesTheRailBetweenIntentsAndPath() { + assertEquals(ReviewIntentRail.Mode.INTENTS, view.railMode()); + + pressP(); + assertEquals(ReviewIntentRail.Mode.PATH, view.railMode()); + + pressP(); + assertEquals(ReviewIntentRail.Mode.INTENTS, view.railMode()); + } + + /** + * One key, not a parallel set: {@code [} / {@code ]} step whatever the + * rail is currently listing -- sections in INTENTS mode, hunks in PATH + * mode. + */ + @Test + void bracketsStepHunksInPathModeAndSectionsInIntentsMode() { + pressP(); + awaitPathReady(); + + assertEquals(0, view.selectedPathStepForTest(), "PATH mode starts on the entry point"); + + press(KeyCode.CLOSE_BRACKET).release(KeyCode.CLOSE_BRACKET); + WaitForAsyncUtils.waitForFxEvents(); + + assertEquals(1, view.selectedPathStepForTest()); + } + + /** + * {@code n} keeps meaning "next unsettled", a property of hunks + * regardless of what the rail shows -- not "next row". Three of this + * board's four hunks are pre-settled here, leaving exactly one + * ({@link #FILE_C}'s) unsettled, so a plain "move by one" would land + * somewhere else than a real unsettled-hunk search: this is only green + * if {@code n} actually skips the settled rows to find it. + */ + @Test + void nStillWalksUnsettledWorkInPathMode() { + settle(FILE_A, 0); + settle(FILE_A, 1); + settle(FILE_B, 0); + // FILE_C's one hunk is deliberately left unsettled. + + pressP(); + awaitPathReady(); + + press(KeyCode.N).release(KeyCode.N); + WaitForAsyncUtils.waitForFxEvents(); + + int selected = view.selectedPathStepForTest(); + List rows = view.pathRowTextsForTest(); + assertTrue(selected >= 0 && selected < rows.size(), "n must land on a real row"); + assertTrue(rows.get(selected).contains(FILE_C), + "the only unsettled hunk is in " + FILE_C + "; n must find it rather than just " + + "advancing by one"); + } + + @Test + void everyPathRowStatesItsReason() { + pressP(); + awaitPathReady(); + + List rows = view.pathRowTextsForTest(); + assertTrue(rows.size() >= 4, "this board's four hunks must all appear as rows"); + assertTrue(rows.stream().noneMatch(String::isBlank)); + // Not merely non-blank: each row must carry the word this rail uses + // to scope the reason to the FILE rather than the hunk (this task's + // own correction -- reason is file-level, and a row that dropped the + // word would read as a claim about the hunk itself). + assertTrue(rows.stream().allMatch(row -> row.contains("file ")), + "every row must state why its FILE sits where it does: " + rows); + } + + /** Advertised and bound must match (AGENTS.md). */ + @Test + void theShortcutsOverlayAdvertisesP() { + assertTrue(ShortcutsOverlay.reviewShortcutKeys().contains("p")); + } + + /** + * CRITICAL fix, mutation-verified below: {@code a} in PATH mode must + * settle exactly the selected row's one hunk, never the whole INTENTS + * section that hunk happens to also belong to. {@link ReviewViewFixture}'s + * board groups {@link #FILE_A}'s two hunks and {@link #FILE_B}'s one into + * "section-1" -- if {@code a} still settled by section (the bug a real + * screenshot caught: the verdict bar read "Approve (section)" with a + * PATH row selected), approving row 0 would silently record THREE + * verdicts instead of one. + */ + @Test + void aInPathModeSettlesOnlyTheSelectedRowNotTheWholeSection() { + pressP(); + awaitPathReady(); + assertEquals(0, view.selectedPathStepForTest()); + String selectedRow = view.pathRowTextsForTest().get(0); + + press(KeyCode.A).release(KeyCode.A); + WaitForAsyncUtils.waitForFxEvents(); + + List allDigests = List.of(digestOf(FILE_A, 0), digestOf(FILE_A, 1), + digestOf(FILE_B, 0), digestOf(FILE_C, 0)); + long settledCount = allDigests.stream().filter(d -> host.verdict(scope, d).isPresent()).count(); + assertEquals(1, settledCount, "row 0 is " + selectedRow + "; exactly its one hunk must be " + + "settled, not the whole section: " + allDigests.stream() + .map(d -> host.verdict(scope, d).isPresent()).toList()); + } + + /** + * {@code u} undoes exactly what PATH mode's {@code a} last recorded -- + * the same one-hunk precision the settle side needs, mirrored on undo. + */ + @Test + void uInPathModeUndoesOnlyWhatAJustSettled() { + pressP(); + awaitPathReady(); + + press(KeyCode.A).release(KeyCode.A); + WaitForAsyncUtils.waitForFxEvents(); + List allDigests = List.of(digestOf(FILE_A, 0), digestOf(FILE_A, 1), + digestOf(FILE_B, 0), digestOf(FILE_C, 0)); + assertEquals(1, allDigests.stream().filter(d -> host.verdict(scope, d).isPresent()).count()); + + press(KeyCode.U).release(KeyCode.U); + WaitForAsyncUtils.waitForFxEvents(); + + assertTrue(allDigests.stream().noneMatch(d -> host.verdict(scope, d).isPresent()), + "u must clear the one verdict a just recorded, leaving nothing settled"); + } + + /** + * The verdict bar's own Undo button must act on the SAME target the + * settle actions do -- the row on screen -- not the intents cursor. + * Reproduces the coordinator's own trace: settle every hunk via + * INTENTS mode's {@code a},{@code a} (settleUnit SECTION, since focus + * stays on the rail), switch to PATH mode (selected row is {@link + * #FILE_B}'s own hunk, the entry point), click Undo. Before the fix, + * the bar rendered off the intents cursor regardless of mode, so Undo + * cleared "section-2"'s two hunks -- neither of them the visible row -- + * and left the visible row's own verdict untouched. + */ + @Test + void theVerdictBarsUndoButtonClearsOnlyTheSelectedRow() { + press(KeyCode.A).release(KeyCode.A); + WaitForAsyncUtils.waitForFxEvents(); + press(KeyCode.A).release(KeyCode.A); + WaitForAsyncUtils.waitForFxEvents(); + String onScreen = digestOf(FILE_B, 0); + List offScreen = List.of(digestOf(FILE_A, 0), digestOf(FILE_A, 1), digestOf(FILE_C, 0)); + assertTrue(host.verdict(scope, onScreen).isPresent(), "setup: guards.cpp must start settled"); + assertTrue(offScreen.stream().allMatch(d -> host.verdict(scope, d).isPresent()), + "setup: a,a in INTENTS mode must settle every hunk on this board"); + + pressP(); + awaitPathReady(); + assertEquals(0, view.selectedPathStepForTest()); + assertTrue(view.pathRowTextsForTest().get(0).contains(FILE_B), + "row 0 must be " + FILE_B + "'s own hunk for this test to mean anything"); + + clickUndoButton(); + + assertFalse(host.verdict(scope, onScreen).isPresent(), + "Undo must clear the row actually on screen (guards.cpp)"); + assertTrue(offScreen.stream().allMatch(d -> host.verdict(scope, d).isPresent()), + "Undo must NOT touch hunks nowhere near the selected row: " + offScreen.stream() + .map(d -> host.verdict(scope, d).isPresent()).toList()); + } + + /** + * The verdict bar's own ‹/› buttons must step the SAME thing {@code [}/ + * {@code ]} do -- PATH rows, not the (invisible) intents cursor. Before + * this fix, {@code VerdictHost.previousIntent}/{@code nextIntent} called + * {@code moveIntent} unconditionally. + */ + @Test + void theVerdictBarsNextButtonStepsPathRowsInPathMode() { + pressP(); + awaitPathReady(); + assertEquals(0, view.selectedPathStepForTest()); + + interact(() -> ((Button) lookup(".review-verdict-next").queryAll().iterator().next()).fire()); + WaitForAsyncUtils.waitForFxEvents(); + + assertEquals(1, view.selectedPathStepForTest(), + "the bar's own next button must move the PATH cursor, not the intents one"); + } + + /** + * CRITICAL fix: a blocking finding attributed to a REAL intent must + * still refuse {@code a} in PATH mode. {@code pathStepAsIntent}'s + * synthetic {@code "path:" + hunkId} can never equal a finding's named + * {@code intentId}, so asking {@code blockingFindingOpen} about the + * synthetic id directly (the bug: proved by execution, INTENTS mode + * refused the identical finding while PATH mode approved anyway, with + * the bar simultaneously reading "a blocking finding is still open") + * would silently let this through. {@code "section-1"} is the real + * intent {@link ReviewViewFixture} already groups {@link #FILE_B} into, + * and PATH mode's entry point (index 0) is {@link #FILE_B}'s own hunk. + */ + @Test + void aInPathModeIsRefusedByABlockingFindingNamingTheRealSection() { + host.store.upsert(new ReviewAnnotation(scope.id(), "f1", Optional.of("section-1"), FILE_B, + "n1", "n1", Severity.BLOCKING, Confidence.HIGH, Optional.of("blocker"), "Claude", + Instant.EPOCH, List.of(), Optional.empty(), Optional.empty(), List.of(), List.of(), + Optional.empty(), AnnotationStatus.OPEN, Optional.empty(), false)); + + pressP(); + awaitPathReady(); + assertEquals(0, view.selectedPathStepForTest()); + assertTrue(view.pathRowTextsForTest().get(0).contains(FILE_B), + "row 0 must be " + FILE_B + "'s own hunk for this test to mean anything: " + + view.pathRowTextsForTest()); + + press(KeyCode.A).release(KeyCode.A); + WaitForAsyncUtils.waitForFxEvents(); + + assertFalse(host.verdict(scope, digestOf(FILE_B, 0)).isPresent(), + "a blocking finding naming section-1 (the REAL section this hunk belongs to) must " + + "refuse approval in PATH mode exactly as it does in INTENTS mode"); + } + + /** + * The eighth "correct behaviour shipped with no test that could catch + * its loss" in this plan, per the coordinator: {@code + * renderVerdictBarForPathStep}'s dispatch had no regression test at + * all. Settling section-1 (via one {@code a} with the rail focused, + * SECTION unit) auto-advances the INTENTS cursor to section-2 + * ("Profiler", still unsettled -- its own {@link #FILE_C} hunk is + * unread) while ALSO settling {@link #FILE_B}'s hunk as a side effect + * (it is section-1's own third hunk). PATH mode's row 0 is exactly + * that now-settled {@link #FILE_B} hunk, so the two states genuinely + * disagree: a bar still reading off the intents cursor would show + * "2 · Profiler", unsettled; a bar reading the selected row shows + * {@link #FILE_B}'s own name, settled. + */ + @Test + void theVerdictBarNamesAndSettlesOffTheSelectedRowNotTheIntentsCursor() { + press(KeyCode.A).release(KeyCode.A); + WaitForAsyncUtils.waitForFxEvents(); + assertTrue(host.verdict(scope, digestOf(FILE_B, 0)).isPresent(), + "setup: guards.cpp's hunk must be settled as part of section-1's approval"); + + pressP(); + awaitPathReady(); + assertEquals(0, view.selectedPathStepForTest()); + assertTrue(view.pathRowTextsForTest().get(0).contains(FILE_B), + "row 0 must be " + FILE_B + "'s own hunk for this test to mean anything"); + + String label = intentLabelText(); + assertTrue(label.contains(FILE_B), + "the bar's label must name the SELECTED ROW's file (" + FILE_B + "): " + label); + assertFalse(label.contains("Profiler"), + "the bar must not still show the intents cursor's title ('Profiler', section-2, " + + "which is still unsettled): " + label); + assertFalse(lookup(".review-verdict-settled").queryAll().isEmpty(), + "the bar must render SETTLED, matching the selected row's own state -- the " + + "intents cursor (section-2) is still unsettled, so a bar reading that " + + "instead would show Approve/Request-changes buttons here, not a decision"); + } + + /** + * {@code askAgentToFix} resolved through the step's REAL covering + * intents (shared with the blocking-finding fix via {@code + * intentsCoveringPathStep}), so it must hand off ONLY the findings + * belonging to {@link #FILE_C}'s own section (section-2), not every + * finding on the board. Two findings on section-1, one on section-2, + * PATH mode selecting {@link #FILE_C}'s row. + */ + @Test + void askAgentToFixInPathModeHandsOffOnlyTheSelectedRowsFindings() { + addFinding("f1", "section-1", FILE_A); + addFinding("f2", "section-1", FILE_B); + addFinding("f3", "section-2", FILE_C); + + pressP(); + awaitPathReady(); + // Steps: FILE_B (entry), FILE_A#0, FILE_A#1, FILE_C#0 -- walk to FILE_C's row. + press(KeyCode.CLOSE_BRACKET).release(KeyCode.CLOSE_BRACKET); + press(KeyCode.CLOSE_BRACKET).release(KeyCode.CLOSE_BRACKET); + press(KeyCode.CLOSE_BRACKET).release(KeyCode.CLOSE_BRACKET); + WaitForAsyncUtils.waitForFxEvents(); + assertTrue(view.pathRowTextsForTest().get(view.selectedPathStepForTest()).contains(FILE_C), + "the walk above must land on " + FILE_C + "'s row: " + view.pathRowTextsForTest()); + + clickAskAgentButton(); + + assertTrue(host.handedOffPrompts.stream().anyMatch(entry -> entry.endsWith(": 1 findings")), + "PATH mode must hand off exactly the SELECTED ROW's one finding: " + + host.handedOffPrompts); + } + + // ---- helpers -------------------------------------------------------------- + + private void pressP() { + press(KeyCode.P).release(KeyCode.P); + WaitForAsyncUtils.waitForFxEvents(); + } + + /** + * Polls wall time for PATH mode's rows to be populated: entering PATH + * mode kicks off a {@link app.drydock.review.ChangeGraph} build on a + * virtual thread the first time (see this class's own javadoc), and how + * long that takes depends on whether this JVM has already warmed the + * tree-sitter grammar -- the same non-guarantee {@code + * SectionRailSwapTest.awaitCardCount} documents for the intents rail's + * own computed-grouping swap. + */ + private void awaitPathReady() { + long start = System.nanoTime(); + while (view.pathRowTextsForTest().isEmpty()) { + if (System.nanoTime() - start > 30_000_000_000L) { + throw new AssertionError("PATH mode never populated any rows"); + } + sleep(50); + } + } + + private void settle(String file, int hunkIndex) { + host.store.putVerdict(new ReviewVerdict(scope.id(), digestOf(file, hunkIndex), + ReviewVerdict.Decision.APPROVED, Optional.empty(), Instant.EPOCH, + host.baseCommit, host.headCommit)); + } + + private String digestOf(String file, int hunkIndex) { + return host.diff.files().stream() + .filter(candidate -> candidate.path().equals(file)) + .findFirst() + .map(candidate -> HunkDigest.of(file, candidate.hunks().get(hunkIndex))) + .orElseThrow(); + } + + /** + * Fires the verdict bar's own Undo button -- {@code undoButton}'s text + * is {@code "change"}, and it shares {@code .review-verdict-action} + * with several other buttons, so it is found by text rather than by + * style class alone. + */ + private void clickUndoButton() { + interact(() -> lookup(".review-verdict-action").queryAll().stream() + .map(Button.class::cast) + .filter(button -> "change".equals(button.getText())) + .findFirst() + .orElseThrow(() -> new AssertionError("no Undo button found")) + .fire()); + WaitForAsyncUtils.waitForFxEvents(); + } + + private void clickAskAgentButton() { + interact(() -> lookup(".review-verdict-action").queryAll().stream() + .map(Button.class::cast) + .filter(button -> "Ask the agent to fix it".equals(button.getText())) + .findFirst() + .orElseThrow(() -> new AssertionError("no Ask-the-agent button found")) + .fire()); + WaitForAsyncUtils.waitForFxEvents(); + } + + private String intentLabelText() { + String[] text = new String[1]; + interact(() -> text[0] = ((Label) lookup(".review-verdict-intent").queryAll().iterator().next()) + .getText()); + return text[0]; + } + + private void addFinding(String id, String intentId, String file) { + host.store.upsert(new ReviewAnnotation(scope.id(), id, Optional.of(intentId), file, + "n1", "n1", Severity.NIT, Confidence.HIGH, Optional.empty(), "Claude", + Instant.EPOCH, List.of(), Optional.empty(), Optional.empty(), List.of(), List.of(), + Optional.empty(), AnnotationStatus.OPEN, Optional.empty(), false)); + } +} diff --git a/app/src/test/java/app/drydock/ui/review/ReviewPathOrderTest.java b/app/src/test/java/app/drydock/ui/review/ReviewPathOrderTest.java new file mode 100644 index 00000000..308ee4fd --- /dev/null +++ b/app/src/test/java/app/drydock/ui/review/ReviewPathOrderTest.java @@ -0,0 +1,146 @@ +package app.drydock.ui.review; + +import app.drydock.ui.TestStages; +import app.drydock.git.DiffService; +import app.drydock.git.UnifiedDiff; +import app.drydock.review.ReviewScope; +import app.drydock.review.ReviewScopeRegistry; +import app.drydock.review.SessionReviewScopes; + +import javafx.scene.Scene; +import javafx.scene.input.KeyCode; +import javafx.stage.Stage; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.testfx.framework.junit5.ApplicationTest; +import org.testfx.util.WaitForAsyncUtils; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.OptionalInt; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The single most likely way this task goes wrong (per its own corrections): + * rendering the grouping's own section order while numbering off the path's + * puts {@code START HERE} on the wrong card. This pins the opposite -- the + * rail's PATH-mode entry point is exactly the reading path's, not {@link + * app.drydock.review.Sections#of}'s own topological order -- with a fixture + * built so the two genuinely disagree (mirrors {@code + * ReadingPathTest.theWiderFoundationIsReadFirst}, which is where this + * disagreement was first pinned at the model layer): {@code zbase.cpp} is + * referenced by two files and sorts LAST; {@code mid.cpp} is referenced by + * only one and sorts FIRST. {@code Sections.of}'s own topological order (no + * entry-point rank, alphabetical tie-break among files ready at each step) + * puts {@code mid.cpp}'s section first; {@link + * app.drydock.review.ReadingPath}'s rank puts {@code zbase.cpp}'s section + * first, because in-degree outranks the alphabetical tie-break. A rail that + * rendered {@code Sections.of}'s own order (correction 2 of this task) would + * show {@code mid.cpp} at row 0 with the entry-point badge; this fails loudly + * if it does. + */ +class ReviewPathOrderTest extends ApplicationTest { + + private final DiffService diffService = new DiffService(); + private final ReviewScopeRegistry registry = new ReviewScopeRegistry(); + private FakeReviewHost host; + private SessionReviewView view; + + @Override + public void start(Stage stage) { + try { + host = new FakeReviewHost(Files.createTempDirectory("drydock-path-order") + .resolve("annotations.json")); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + view = new SessionReviewView(host, diffService, null); + Scene scene = new Scene(view, 1400, 900); + scene.getStylesheets().addAll( + getClass().getResource("/app/drydock/ui/app.css").toExternalForm(), + getClass().getResource("/app/drydock/ui/theme-dark.css").toExternalForm()); + TestStages.show(stage, scene); + } + + @AfterEach + void tearDown() { + diffService.close(); + host.store.close(); + } + + /** + * {@code zbase.cpp} carries in-degree 2 (referenced by both {@code + * u1.cpp} and {@code u2.cpp}); {@code mid.cpp} carries in-degree 1 and + * sorts first alphabetically. Identical to the model-layer fixture that + * first pinned "the wider foundation is read first". + */ + private static UnifiedDiff widerFoundationDiff() { + List files = new ArrayList<>(); + files.add(file("src/mid.cpp", "class Mid { };")); + files.add(file("src/u1.cpp", "void u1() { new Base(); new Mid(); }")); + files.add(file("src/u2.cpp", "void u2() { new Base(); }")); + files.add(file("src/zbase.cpp", "class Base { };")); + return new UnifiedDiff(files); + } + + private static UnifiedDiff.FileDiff file(String path, String line) { + return new UnifiedDiff.FileDiff(path, "M", 1, 0, false, false, List.of( + new UnifiedDiff.Hunk("@@", List.of(new UnifiedDiff.Line( + UnifiedDiff.Line.Kind.ADD, OptionalInt.empty(), OptionalInt.of(1), line))))); + } + + @Test + void startHereSitsOnTheEntryPointsRowNotTheAlphabeticallyFirstFile() { + UnifiedDiff diff = widerFoundationDiff(); + host.diff = diff; + ReviewScope scope = registry.mint(ReviewScopeRegistry.spec(ReviewScope.Kind.WORKING_TREE, + Path.of("/tmp/nowhere"), Optional.of(Path.of("/tmp/nowhere")), "main", "main", + Optional.empty(), Optional.empty())); + + interact(() -> view.showScopes(new SessionReviewScopes.Scopes(scope, Optional.empty()), + SessionReviewScopes.Choice.LOCAL)); + interact(() -> view.diagShowDiff(scope, diff)); + + press(KeyCode.P).release(KeyCode.P); + WaitForAsyncUtils.waitForFxEvents(); + awaitPathReady(); + + List rows = view.pathRowTextsForTest(); + assertTrue(rows.size() >= 4, "all four hunks must render: " + rows); + + String firstRow = rows.get(0); + assertTrue(firstRow.contains("START HERE"), "row 0 must carry the entry-point badge: " + firstRow); + assertTrue(firstRow.contains("src/zbase.cpp"), + "the entry point is zbase.cpp (in-degree 2 outranks mid.cpp's alphabetical lead): " + + firstRow); + assertFalse(firstRow.contains("src/mid.cpp"), + "Sections.of's OWN topological order puts mid.cpp first (ready immediately, sorts " + + "before zbase.cpp) -- if this ever contains mid.cpp, the rail rendered " + + "that order instead of the reading path's: " + firstRow); + + // The badge numbers off path.sections() -- the entry point's OWN + // section is always reordered to index 0 there (ReadingPath's own + // guarantee), so its marker must be circled-1, never mid.cpp's + // Sections.of position (which would be circled-1 there instead). + assertTrue(firstRow.contains("①"), + "the entry point's section must be numbered ① against the PATH's own section " + + "order, not Sections.of's: " + firstRow); + } + + private void awaitPathReady() { + long start = System.nanoTime(); + while (view.pathRowTextsForTest().isEmpty()) { + if (System.nanoTime() - start > 30_000_000_000L) { + throw new AssertionError("PATH mode never populated any rows"); + } + sleep(50); + } + } +} diff --git a/app/src/test/java/app/drydock/ui/review/ReviewPathRowContrastTest.java b/app/src/test/java/app/drydock/ui/review/ReviewPathRowContrastTest.java new file mode 100644 index 00000000..6b23d487 --- /dev/null +++ b/app/src/test/java/app/drydock/ui/review/ReviewPathRowContrastTest.java @@ -0,0 +1,166 @@ +package app.drydock.ui.review; + +import app.drydock.ui.TestStages; +import app.drydock.review.ReadingPath; +import app.drydock.review.Provenance; +import app.drydock.review.ReviewIntent; + +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.layout.StackPane; +import javafx.scene.paint.Color; +import javafx.stage.Stage; +import org.testfx.framework.junit5.ApplicationTest; +import org.testfx.util.WaitForAsyncUtils; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * A PATH row is a {@code Button} whose text lives on child {@link Label}s + * (badge, file, reason, links), styled by {@code .review-path-badge}/ + * {@code -file}/{@code -reason}/{@code -links} in {@code app.css} -- rebuilt + * that way specifically because {@code Button.setText} alone has no {@code + * -fx-text-fill} of its own here, and modena's default button-face text + * colour measured 1.13:1 contrast on a SELECTED row in a real screenshot + * (worse than the 1.70:1 unselected rows still failed at, because the + * lighter {@code :selected} background made a light-on-light problem + * worse). + * + *

Rather than hard-coding hex values from {@code theme-dark.css} (which + * would silently stop meaning anything the day the palette changes), this + * pins PATH rows against the ALREADY-SHIPPED reference this task deliberately + * reused: an intents card's own {@code .review-intent-title}/{@code -number} + * resolve to identical colours, selected and unselected both, because + * {@code app.css} gives {@code .review-path-file}/{@code -badge} the exact + * same tokens. A regression back to {@code Button.setText} (no fill at all, + * so {@link Label#getTextFill()} would come back as modena's default rather + * than matching) or a copy-paste of the wrong token both fail this.

+ */ +class ReviewPathRowContrastTest extends ApplicationTest { + + private ReviewIntentRail rail; + + @Override + public void start(Stage stage) { + rail = new ReviewIntentRail(); + StackPane root = new StackPane(rail); + Scene scene = new Scene(root, 400, 900); + scene.getStylesheets().addAll( + getClass().getResource("/app/drydock/ui/app.css").toExternalForm(), + getClass().getResource("/app/drydock/ui/theme-dark.css").toExternalForm()); + TestStages.show(stage, scene); + } + + @Test + void aSelectedPathRowsFileNameMatchesAnIntentCardsSelectedTitle() { + Color intentTitleSelected = titleFill(true); + Color intentTitleUnselected = titleFill(false); + Color pathFileSelected = fileFill(true); + Color pathFileUnselected = fileFill(false); + + assertEquals(intentTitleSelected, pathFileSelected, + "a selected PATH row's file name must be exactly as legible as a selected intent " + + "card's title -- both are meant to use -drydock-text"); + assertEquals(intentTitleUnselected, pathFileUnselected, + "an unselected PATH row's file name must match an unselected intent card's title"); + assertNotEquals(pathFileUnselected, pathFileSelected, + "selecting a row must actually change its text colour, not just its background"); + } + + @Test + void theSelectedRowIsNeverTheHardestToRead() { + // The measured defect, restated as an assertion: a screenshot found + // the SELECTED row's own contrast (1.13:1) BELOW the unselected + // rows' (1.70:1) -- selecting made it worse, not better. Luminance + // is a monotonic stand-in for contrast against the same dark + // background both rows sit on, so "selected is at least as bright" + // is the same claim as "selected is at least as legible". + double unselected = relativeLuminance(fileFill(false)); + double selected = relativeLuminance(fileFill(true)); + + assertTrue(selected >= unselected, + "selected file text (luminance " + selected + ") must not be DARKER than " + + "unselected (" + unselected + ") -- that is exactly the regression a " + + "real screenshot caught"); + // And both must clear a floor that is trivially true for the + // reused -drydock-text/-drydock-text-dim tokens, but would catch a + // return to an unstyled Button's near-black default. + assertTrue(selected > 0.3, "selected text is too dark to read: luminance " + selected); + } + + // ---- helpers -------------------------------------------------------------- + + private Color titleFill(boolean selected) { + List intents = List.of( + new ReviewIntent("a", 1, "alpha", ReviewIntent.Kind.CHANGE, ReviewIntent.Risk.LOW, + "", List.of(), Optional.empty(), false), + new ReviewIntent("b", 2, "beta", ReviewIntent.Kind.CHANGE, ReviewIntent.Risk.LOW, + "", List.of(), Optional.empty(), false)); + // "a" is always selected; asking for the UNselected fill reads "b"'s + // card instead, so both renders always have exactly one of each. + interact(() -> rail.setIntents(intents, "a", ReviewIntentRail.Empty.NONE, Provenance.MEASURED)); + WaitForAsyncUtils.waitForFxEvents(); + interact(() -> rail.getScene().getRoot().layout()); + WaitForAsyncUtils.waitForFxEvents(); + return labelFill(".review-intent-title", selected); + } + + private Color fileFill(boolean selected) { + ReadingPath.Step first = new ReadingPath.Step("h_a_0", "src/a.txt", 1, "builds on nothing", + List.of(), true); + ReadingPath.Step second = new ReadingPath.Step("h_b_0", "src/b.txt", 2, "builds on nothing", + List.of(), false); + interact(() -> rail.showPath(List.of(first, second), "h_a_0", ReviewIntentRail.Empty.NONE)); + WaitForAsyncUtils.waitForFxEvents(); + interact(() -> rail.getScene().getRoot().layout()); + WaitForAsyncUtils.waitForFxEvents(); + return labelFill(".review-path-file", selected); + } + + /** The matching Label's resolved text fill, from whichever of the two rendered cards is (un)selected. */ + private Color labelFill(String styleClass, boolean selected) { + Color[] found = new Color[1]; + interact(() -> lookup(styleClass).queryAll().stream() + .map(Node.class::cast) + .filter(node -> node instanceof Label) + .map(Label.class::cast) + .filter(label -> isSelected(label) == selected) + .findFirst() + .ifPresentOrElse(label -> found[0] = (Color) label.getTextFill(), + () -> { + throw new AssertionError("no " + (selected ? "selected" : "unselected") + + " " + styleClass + " found"); + })); + return found[0]; + } + + /** Walks up from a row's Label to the Button card and reads its own :selected pseudo-class. */ + private static boolean isSelected(Node node) { + for (Node n = node; n != null; n = n.getParent()) { + if (n instanceof Button button && button.getStyleClass().contains("review-intent-card")) { + return button.getPseudoClassStates().stream() + .anyMatch(pc -> pc.getPseudoClassName().equals("selected")); + } + } + return false; + } + + /** WCAG relative luminance (sRGB), so "brighter" has a single number to compare. */ + private static double relativeLuminance(Color color) { + return 0.2126 * linearize(color.getRed()) + + 0.7152 * linearize(color.getGreen()) + + 0.0722 * linearize(color.getBlue()); + } + + private static double linearize(double channel) { + return channel <= 0.03928 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4); + } +} diff --git a/app/src/test/java/app/drydock/ui/review/ReviewProvenanceTest.java b/app/src/test/java/app/drydock/ui/review/ReviewProvenanceTest.java new file mode 100644 index 00000000..a7ff2eb1 --- /dev/null +++ b/app/src/test/java/app/drydock/ui/review/ReviewProvenanceTest.java @@ -0,0 +1,186 @@ +package app.drydock.ui.review; + +import app.drydock.review.BaseMove; +import app.drydock.review.Provenance; +import app.drydock.review.RecheckAssessment; +import app.drydock.review.ReviewVerdict; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.Labeled; +import javafx.scene.layout.Border; +import javafx.scene.layout.Region; +import javafx.scene.paint.Paint; +import javafx.scene.input.KeyCode; +import org.junit.jupiter.api.Test; +import org.testfx.util.WaitForAsyncUtils; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.TreeSet; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * A measured order and a claimed one fail differently (spec §6.5), and a + * reviewer deciding how hard to squint at "③ depends on ①" has to know which + * they are holding. A measured edge fails as a false unique-name match and is + * checkable on the spot by looking; a claimed one fails as a plausible + * fabrication and is checkable only against the code the agent says it read. + * + *

Spec §8 puts the distinction on the RAIL, which has three order + * sources -- {@code reads}, the agent's array order, and {@link + * app.drydock.review.ReadingPath} -- of which the first two are claimed and + * the third measured. It is deliberately NOT on a path row: §6.4 says + * {@code ReadingPath} orders the computed grouping only, so a path row is + * measured by construction.

+ */ +class ReviewProvenanceTest extends ReviewViewFixture { + + /** The fixture's board is an agent grouping -- {@code IntentGrouping.set}. */ + @Test + void anAgentSuppliedGroupingIsMarkedClaimed() { + assertTrue(railCardStyleClasses().stream() + .anyMatch(classes -> classes.contains("provenance-claimed")), + "the agent asserted this order; the rail has to say so"); + } + + /** The distinction is only a distinction if the ordinary case is unmarked. */ + @Test + void aComputedGroupingIsNotMarkedClaimed() { + dropTheReviewerGrouping(); + + assertTrue(railCardStyleClasses().stream() + .noneMatch(classes -> classes.contains("provenance-claimed")), + "drydock measured this order itself"); + } + + /** + * §6.4: {@code ReadingPath} orders the computed grouping only, so a PATH + * row can never be the agent's claim -- even on a board whose INTENTS + * grouping is. + */ + @Test + void aPathRowIsNeverMarkedClaimed() { + pressP(); + awaitPathReady(); + + assertTrue(railCardStyleClasses().stream() + .noneMatch(classes -> classes.contains("provenance-claimed"))); + } + + /** + * Visible, not merely classed. Review found that deleting + * the whole CSS rule left every test green: they all asserted on style + * CLASS STRINGS, and a class nothing renders says nothing. This reads the + * resolved Border off the live scene, so the rule has to actually apply. + */ + @Test + void theClaimedRowRendersDifferentlyFromTheMeasuredOne() { + Border claimed = borderOfFirstCard(); + assertNotNull(claimed, "the claimed card must resolve a border at all"); + assertFalse(claimed.getStrokes().get(0).getTopStyle().getDashArray().isEmpty(), + "a claimed row is dashed"); + Paint claimedPaint = claimed.getStrokes().get(0).getTopStroke(); + + dropTheReviewerGrouping(); + + Border measured = borderOfFirstCard(); + assertTrue(measured.getStrokes().get(0).getTopStyle().getDashArray().isEmpty(), + "a measured row is solid"); + assertNotEquals(claimedPaint, measured.getStrokes().get(0).getTopStroke(), + "dashing alone was not legible against a 1.26:1 hairline: the claimed " + + "border must also differ in colour"); + } + + /** The other visible carrier. Dropping the label left the suite green too. */ + @Test + void theCardTooltipNamesTheWarrant() { + assertTrue(tooltipOfFirstCard().contains("claimed")); + + dropTheReviewerGrouping(); + + assertTrue(tooltipOfFirstCard().contains("measured")); + } + + /** + * §9.7's warrant has to reach the SCREEN too. The chip is the only place + * a reviewer learns that a hunk is stale because an agent said so rather + * than because drydock's own filter found the move. + */ + @Test + void theStaleChipSaysWhenTheAgentIsTheOneClaimingIt() { + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of("docs/README.md"))); + host.store.putVerdict(new ReviewVerdict(scope.id(), digestOfFirstHunkOfFileA(), + ReviewVerdict.Decision.APPROVED, Optional.empty(), Instant.EPOCH, + "0".repeat(40), host.headCommit)); + host.store.putAssessment(new RecheckAssessment(scope.id(), digestOfFirstHunkOfFileA(), + "0".repeat(40), host.baseCommit, true, "the guard moved", Instant.EPOCH)); + + interact(() -> view.refreshReviewState()); + WaitForAsyncUtils.waitForFxEvents(); + + assertTrue(railTexts().stream().anyMatch(text -> text.contains("agent:")), + "an agent-asserted staleness must not read identically to a measured one: " + + railTexts()); + } + + private List railTexts() { + return lookup(".review-intent-stale").queryAll().stream() + .map(node -> ((Labeled) node).getText()) + .toList(); + } + + private Border borderOfFirstCard() { + Node card = lookup(".review-intent-card").queryAll().iterator().next(); + interact(() -> { + card.getScene().getRoot().applyCss(); + card.getScene().getRoot().layout(); + }); + WaitForAsyncUtils.waitForFxEvents(); + return ((Region) card).getBorder(); + } + + private String tooltipOfFirstCard() { + Node card = lookup(".review-intent-card").queryAll().iterator().next(); + return ((Button) card).getTooltip().getText(); + } + + private void pressP() { + press(KeyCode.P).release(KeyCode.P); + WaitForAsyncUtils.waitForFxEvents(); + } + + /** PATH mode builds a ChangeGraph on first entry; poll for its rows. */ + private void awaitPathReady() { + long start = System.nanoTime(); + while (view.pathRowTextsForTest().isEmpty()) { + if (System.nanoTime() - start > 30_000_000_000L) { + throw new AssertionError("PATH mode never populated any rows"); + } + sleep(50); + } + } + + private List> railCardStyleClasses() { + return lookup(".review-intent-card").queryAll().stream() + .map(Node::getStyleClass) + .map(List::copyOf) + .toList(); + } + + /** + * Drops the reviewer's grouping on THIS scope rather than switching to a + * fresh one: the rail keeps rendering the selected scope, so a second + * scope would leave the first one's cards on screen and the assertion + * would read them instead. + */ + private void dropTheReviewerGrouping() { + interact(() -> host.intents.clear(scope.id())); + interact(() -> view.refreshReviewState()); + WaitForAsyncUtils.waitForFxEvents(); + } +} diff --git a/app/src/test/java/app/drydock/ui/review/ReviewRecheckDispatchTest.java b/app/src/test/java/app/drydock/ui/review/ReviewRecheckDispatchTest.java new file mode 100644 index 00000000..bce444ca --- /dev/null +++ b/app/src/test/java/app/drydock/ui/review/ReviewRecheckDispatchTest.java @@ -0,0 +1,74 @@ +package app.drydock.ui.review; + +import app.drydock.review.BaseMove; +import app.drydock.review.HunkDigest; +import app.drydock.review.ReviewVerdict; +import org.junit.jupiter.api.Test; +import org.testfx.util.WaitForAsyncUtils; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.TreeSet; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The automatic recheck as a FEATURE, not as parts. + * + *

{@link SectionStatesTest} drives {@code requestRechecks} directly, which + * leaves the wiring untested: deleting the call from {@link + * SessionReviewView#refreshReviewState} left the whole review-UI suite green, + * so the feature could be made completely inert without a single failure. + * These tests go through a real render.

+ */ +class ReviewRecheckDispatchTest extends ReviewViewFixture { + + private static final String OLD_BASE = "0".repeat(40); + + /** The render pass must actually ask. Nothing else pins that it is called. */ + @Test + void aRenderDispatchesTheRecheckForAStaleApproval() { + approveFileAAtAnOlderBase(); + + render(); + + assertEquals(List.of(OLD_BASE + "->" + host.baseCommit), host.recheckDispatches); + } + + /** One claim per move, however many times the board re-renders. */ + @Test + void manyRendersInsideOneMoveAskOnce() { + approveFileAAtAnOlderBase(); + + render(); + render(); + render(); + + assertEquals(1, host.recheckDispatches.size()); + } + + /** Spec §9.7, through the render: an inline harness is never asked. */ + @Test + void anInlineHarnessIsNeverAskedByARender() { + host.supportsAutomaticRecheck = false; + approveFileAAtAnOlderBase(); + + render(); + + assertTrue(host.recheckDispatches.isEmpty()); + } + + private void approveFileAAtAnOlderBase() { + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of(FILE_A))); + host.store.putVerdict(new ReviewVerdict(scope.id(), digestOfFirstHunkOfFileA(), + ReviewVerdict.Decision.APPROVED, Optional.empty(), Instant.EPOCH, + OLD_BASE, host.headCommit)); + } + + private void render() { + interact(() -> view.refreshReviewState()); + WaitForAsyncUtils.waitForFxEvents(); + } +} diff --git a/app/src/test/java/app/drydock/ui/review/ReviewSettleActionsTest.java b/app/src/test/java/app/drydock/ui/review/ReviewSettleActionsTest.java new file mode 100644 index 00000000..9e265fcd --- /dev/null +++ b/app/src/test/java/app/drydock/ui/review/ReviewSettleActionsTest.java @@ -0,0 +1,161 @@ +package app.drydock.ui.review; + +import javafx.scene.control.Button; +import javafx.scene.input.KeyCode; +import javafx.scene.input.MouseButton; +import org.junit.jupiter.api.Test; +import org.testfx.util.WaitForAsyncUtils; + +import java.util.concurrent.TimeoutException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Reading is per hunk; settling usually is not (spec §9.6). The unit follows + * focus rather than adding a parallel key set -- the same rule {@code [} and + * {@code ]} already follow -- and the bar names the unit, because a key whose + * target depends on focus must say what it is about to do. + */ +class ReviewSettleActionsTest extends ReviewViewFixture { + + @Test + void withTheRailFocusedApproveSettlesTheWholeSection() { + focusRail(); + press(KeyCode.A).release(KeyCode.A); + WaitForAsyncUtils.waitForFxEvents(); + + SectionStates.SectionState state = view.diagSectionState(0); + assertEquals(state.totalHunks(), state.settledHunks()); + } + + @Test + void withTheDiffColumnFocusedApproveSettlesOneHunk() throws TimeoutException { + focusDiffColumn(); + String afterFocus = view.diagFocusSnapshot(); + press(KeyCode.A).release(KeyCode.A); + WaitForAsyncUtils.waitForFxEvents(); + String afterPress = view.diagFocusSnapshot(); + + assertEquals(1, view.diagSectionState(0).settledHunks(), + () -> "after focusDiffColumn(): " + afterFocus + " | after a-press: " + afterPress); + } + + @Test + void shiftApproveSettlesEveryHunkOfTheCurrentFile() throws TimeoutException { + focusDiffColumn(); + press(KeyCode.SHIFT).press(KeyCode.A).release(KeyCode.A).release(KeyCode.SHIFT); + WaitForAsyncUtils.waitForFxEvents(); + + assertEquals(hunkCountOfCurrentFile(), view.diagSectionState(0).settledHunks()); + } + + /** Settling a shared hunk has to be visible where it lands. */ + @Test + void settlingASectionShowsItsSharedHunksSettledInTheOtherSection() { + focusRail(); + press(KeyCode.A).release(KeyCode.A); + WaitForAsyncUtils.waitForFxEvents(); + + assertTrue(view.diagSectionState(1).settledElsewhere().contains("①"), + "section ② must name ① as where its shared hunk was settled, got: " + + view.diagSectionState(1).settledElsewhere()); + } + + /** + * With the diff column acting AND a gutter selection open, {@code a} + * must settle the hunk under the cursor -- not always the section's + * first hunk. {@code FILE_A}'s second hunk is what gets selected, so + * settling "hunk one, not the anchor" a second time (in a section + * still holding an unsettled first hunk) is the one outcome that would + * pass if HUNK mode quietly fell back to the anchor regardless of the + * open selection. + * + *

A bare press, not a full click: {@link ReviewDiffColumn}'s gutter + * finalizes a completed click by OPENING THE COMMENT COMPOSER and + * moving real keyboard focus into its text field, which then swallows + * {@code a} as a typed character rather than a shortcut ({@code + * handleShortcut} explicitly declines while the event target is a + * {@code TextInputControl}). {@code setOnMousePressed} alone already + * paints the selection (see {@code extendSelection}), so a press with + * no matching release proves the wiring end to end without also + * hitting that focus steal -- which is a genuine seam this task found + * and did not close: there is no discovered way, with the composer + * unchanged, to both hold a gutter selection AND have {@code a}/ + * {@code r} read as shortcuts immediately afterward from the mouse + * alone. Reported rather than worked around by loosening the + * {@code TextInputControl} guard, which exists to keep the SAME key + * from typing into an open composer.

+ */ + @Test + void withAGutterSelectionOpenApproveSettlesTheSelectedHunkNotTheAnchor() { + moveTo(gutterForFileASecondHunk()); + press(MouseButton.PRIMARY); + try { + press(KeyCode.A).release(KeyCode.A); + WaitForAsyncUtils.waitForFxEvents(); + + assertEquals(1, view.diagSectionState(0).settledHunks()); + assertTrue(host.store.verdict(scope.id(), digestOfSecondHunkOfFileA()).isPresent(), + "the SELECTED hunk must be the one settled"); + assertTrue(host.store.verdict(scope.id(), digestOfFirstHunkOfFileA()).isEmpty(), + "the anchor hunk must be untouched -- a selection was open"); + } finally { + release(MouseButton.PRIMARY); + } + } + + /** + * Asserts the RENDERED Approve button, not {@code view.settleUnit()}: + * an assertion on the model alone shipped once already while the bar + * itself still read "acts on: section" after a diff-column click, + * because nothing re-rendered it -- a test that cannot catch the bug it + * was written for is worse than no test. + */ + @Test + void theBarNamesTheUnitAnActionWillHit() throws TimeoutException { + focusRail(); + assertEquals("Approve (section)", approveButtonText()); + + focusDiffColumn(); + WaitForAsyncUtils.waitForFxEvents(); + assertEquals("Approve (next unread hunk)", approveButtonText()); + } + + /** + * A real mouse press on a focusable {@code Button} requests focus on + * press (see {@code app.css}'s {@code .review-verdict-action:focused}), + * which moves Scene focus off the diff column onto the button itself + * BEFORE the button's own action fires on release -- so if the acting + * unit were re-read at release time, "Approve (next unread hunk)" would + * settle the whole section instead, silently, because the reader's + * focus change (into the button they are pressing) looks identical to + * a genuine "I clicked the rail" to {@code settleUnit()}. Only a real + * press-then-release ({@code clickOn}, not {@code Button.fire()}) + * reproduces this: {@code fire()} never presses at all, so it never + * moves focus and could not have caught the bug. + */ + @Test + void aRealMousePressCapturesTheUnitBeforeTheFocusChangeItCauses() throws TimeoutException { + focusDiffColumn(); + assertEquals("Approve (next unread hunk)", approveButtonText()); + + clickOn(".review-verdict-action"); + WaitForAsyncUtils.waitForFxEvents(); + + assertEquals(1, view.diagSectionState(0).settledHunks(), + "a real button press must settle what the button showed when pressed, not " + + "whatever settleUnit() became after the press moved focus onto it"); + } + + private String approveButtonText() { + String[] text = new String[1]; + interact(() -> text[0] = lookup(".review-verdict-action").queryAll().stream() + .map(Button.class::cast) + .map(Button::getText) + .filter(t -> t.startsWith("Approve (")) + .findFirst() + .orElse("")); + return text[0]; + } +} diff --git a/app/src/test/java/app/drydock/ui/review/ReviewSubmitSheetTest.java b/app/src/test/java/app/drydock/ui/review/ReviewSubmitSheetTest.java index b818d7dc..f67685ab 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewSubmitSheetTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewSubmitSheetTest.java @@ -1,5 +1,6 @@ package app.drydock.ui.review; +import app.drydock.ui.TestStages; import app.drydock.github.GitHubLineAnchor.Anchor; import app.drydock.github.GitHubLineAnchor.Side; import app.drydock.github.GitHubReviewRequest.Comment; @@ -62,8 +63,7 @@ public void start(Stage stage) { scene.getStylesheets().addAll( getClass().getResource("/app/drydock/ui/app.css").toExternalForm(), getClass().getResource("/app/drydock/ui/theme-dark.css").toExternalForm()); - stage.setScene(scene); - stage.show(); + TestStages.show(stage, scene); } /** diff --git a/app/src/test/java/app/drydock/ui/review/ReviewVerdictBarFitTest.java b/app/src/test/java/app/drydock/ui/review/ReviewVerdictBarFitTest.java index b370ee79..b6ff5b35 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewVerdictBarFitTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewVerdictBarFitTest.java @@ -1,11 +1,16 @@ package app.drydock.ui.review; +import app.drydock.ui.TestStages; import app.drydock.review.ReviewIntent; import app.drydock.review.ReviewVerdict; import javafx.scene.Scene; import javafx.scene.control.Button; +import javafx.scene.Node; +import javafx.scene.Parent; import javafx.scene.control.Label; +import javafx.scene.control.Labeled; +import javafx.scene.layout.Region; import javafx.stage.Stage; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -16,6 +21,7 @@ import java.util.List; import java.util.Optional; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -32,43 +38,70 @@ */ class ReviewVerdictBarFitTest extends ApplicationTest { + /** + * The width the bar ACTUALLY gets at the code column's floor -- not the + * window's 560, which is what this fixture used to hand it. The view's + * own chrome takes 35px, so a bar-only fixture at 560 over-states the + * room by about six characters, and two production strings live inside + * that margin. Measured from the real view and pinned there by + * {@code ReviewFindingsAndVerdictsTest.theRealBarIsNoNarrowerThanTheFitFixtureAssumes}, + * so this number cannot quietly become a fiction again. + */ + static final double BAR_WIDTH_AT_FLOOR = 525; + private ReviewVerdictBar bar; @Override public void start(Stage stage) { bar = new ReviewVerdictBar(new ReviewVerdictBar.Host() { - @Override public void approve(ReviewIntent intent) { } - @Override public void requestChanges(ReviewIntent intent) { } - @Override public void askAgentToFix(ReviewIntent intent) { } + @Override public void approve(ReviewIntent intent, SessionReviewView.SettleUnit unit) { } + @Override public void requestChanges(ReviewIntent intent, SessionReviewView.SettleUnit unit) { } + @Override public boolean askAgentToFix(ReviewIntent intent) { return askSucceeds; } @Override public void undo(ReviewIntent intent) { } + @Override public void confirmStillGood(ReviewIntent intent) { } @Override public void nextUnsettled() { } @Override public void submit() { } @Override public void previousIntent() { } @Override public void nextIntent() { } }); - Scene scene = new Scene(bar, RailLayout.CODE_MIN_WIDTH, 200); + Scene scene = new Scene(bar, BAR_WIDTH_AT_FLOOR, 200); scene.getStylesheets().addAll( getClass().getResource("/app/drydock/ui/app.css").toExternalForm(), getClass().getResource("/app/drydock/ui/theme-dark.css").toExternalForm()); - stage.setScene(scene); - stage.show(); - // The stage outlives the test class, so a scene built at the floor - // width still comes up as wide as whatever ran before it left it -- - // under which every assertion here passes without measuring anything. + TestStages.show(stage, scene); + // Sized through TestStages, like every rendering class: without it a + // scene built at the floor width still came up as wide as whatever + // ran before it, under which every assertion here passes without + // measuring anything. this.stage = stage; atTheFloor(); } private Stage stage; + /** Whether the stub host's hand-off succeeds; false drives the refusal. */ + private boolean askSucceeds = true; + @AfterEach void restoreTheFloor() { + askSucceeds = true; atTheFloor(); } + /** + * Leaves the SHARED primary stage at the floor, deliberately, and that is + * now safe: every class whose rendering can observe an inherited size + * takes its own through {@link app.drydock.ui.TestStages#show}. Round 3 + * tried the opposite -- handing the stage back at 1400 in an + * {@code @AfterAll} -- which merely moved the leak: + * {@code ReviewDiffColumnWidthTest}'s wrap assertion holds at an + * inherited 560 and INVERTS at an inherited 1400, so the "fix" broke it. + * A leaked size is a hazard whatever its value; the value was never the + * thing to get right. + */ private void atTheFloor() { interact(() -> { - stage.setWidth(RailLayout.CODE_MIN_WIDTH); + stage.setWidth(BAR_WIDTH_AT_FLOOR); stage.setHeight(200); }); WaitForAsyncUtils.waitForFxEvents(); @@ -85,8 +118,7 @@ void everyActionIsFullyLegibleAtTheCodeColumnFloor() { @Test void aSettledIntentFitsAsWell() { show(intent(2, "drydock/review · 4 files"), - Optional.of(new ReviewVerdict("rs_x", "auto:2", ReviewVerdict.Decision.APPROVED, - Optional.empty(), java.time.Instant.EPOCH))); + Optional.of(ReviewVerdict.Decision.APPROVED)); assertNothingTruncated(); } @@ -112,17 +144,319 @@ void aLongIntentTitleYieldsInsteadOfTheButtons() { @Test void theHintIsBackAsSoonAsThereIsRoomForIt() { show(intent(2, "drydock/review · 4 files"), Optional.empty()); - assertFalse(hintShowing(), "at the floor the hint has to go"); + assertFalse(navHintShowing(), "at the floor the nav hint has to go"); + + interact(() -> bar.getScene().getWindow().setWidth(1400)); + WaitForAsyncUtils.waitForFxEvents(); + + assertTrue(navHintShowing(), "a wide bar shows the nav hint again"); + } + + /** + * The stale banner (spec §9.2) swaps in a label plus two more buttons, + * "Confirm still good" and "Re-review"; the Phase 1 gate named it as new + * UI with no fit coverage, and the coordinator's review found the gap + * was real TWICE over: {@code assertNothingTruncated} only ever looked + * at {@code .button}, so {@code staleLabel} -- a {@code wrapText} label + * with no {@code minWidth} -- could reflow silently underneath it, and + * nothing asserted the BAR's own height at the floor either. Both are + * folded into {@link #assertNothingTruncated} now, so every caller gets + * them, not just this test. + * + *

Measured, not designed around: at the {@code CODE_MIN_WIDTH} floor + * the banner does NOT read as one line -- {@code "⚠ approved against + * base a1b2c3d · base is now d4e5f6a"} wraps to exactly two, 17px each. + * Nothing is truncated (wrap, not ellipsis -- no character is lost), but + * the floor is real and the verdict bar's row genuinely gets one line + * taller whenever the current section is stale at that width.

+ */ + @Test + void theStaleBannerFitsAtTheCodeColumnFloor() { + show(intent(2, "drydock/review · 4 files"), Optional.of(ReviewVerdict.Decision.APPROVED)); + interact(() -> bar.showStale(Optional.of( + new ReviewVerdictBar.StaleInfo("a1b2c3d4e5f6789", "d4e5f6a1b2c3789")))); + WaitForAsyncUtils.waitForFxEvents(); + interact(() -> bar.getScene().getRoot().layout()); + WaitForAsyncUtils.waitForFxEvents(); + + assertNothingTruncated(); + } + + /** + * The unit (spec §9.6) is named on the button itself now, not a separate + * droppable label: "Approve intent" (the pre-Task-7 text) contradicted + * whatever {@link SessionReviewView#settleUnit()} actually hit, and at + * the floor the acting-unit label the first attempt added was hidden by + * design -- so the ONLY unit statement visible there was the wrong one. + * Naming it on the button is always-visible, which is what makes this + * the fit-relevant surface rather than the (now deleted) label. + */ + @Test + void theApproveButtonNamesTheUnitAndFitsForEveryUnitAtTheFloor() { + for (SessionReviewView.SettleUnit unit : SessionReviewView.SettleUnit.values()) { + show(intent(2, "drydock/review · 4 files"), Optional.empty()); + interact(() -> bar.showActingUnit(unit)); + WaitForAsyncUtils.waitForFxEvents(); + interact(() -> bar.getScene().getRoot().layout()); + WaitForAsyncUtils.waitForFxEvents(); + + assertTrue(approveButtonText().contains(unitWord(unit)), + "the button must name " + unit + ", got: " + approveButtonText()); + assertNothingTruncated(); + } + } + + /** + * Fix round 2's refusal is a FOURTH thing competing for the action row + * at the floor, and this file exists because that row has truncated + * before ("Approv…", "Request c…"). A refusal the reader cannot read is + * no better than the silence it replaced. + */ + @Test + void theAskRefusalFitsAtTheCodeColumnFloor() { + askSucceeds = false; + show(intent(2, "drydock/review · 4 files"), Optional.empty()); + + interact(() -> askButton().fire()); + WaitForAsyncUtils.waitForFxEvents(); + interact(() -> bar.getScene().getRoot().layout()); + WaitForAsyncUtils.waitForFxEvents(); + + assertTrue(lookup(".review-verdict-ask-refusal").queryAll().stream().anyMatch(Node::isVisible), + "the refusal must be showing, or this measures nothing"); + assertNothingTruncated(); + } + + /** + * Round 3, item 3. The blocking refusal is a STATE, not a click, so it + * sits in the action row -- which at the floor has about 25px of slack + * once the four actions have taken their widths. It asked for 146. + * Nothing rendered it in a fit test before, which is the only reason it + * survived the round that added the elision check. + */ + @Test + void theBlockingRefusalFitsAtTheCodeColumnFloor() { + interact(() -> { + bar.update(intent(2, "drydock/review · 4 files"), Optional.empty(), true); + bar.showProgress(1, 7); + }); + WaitForAsyncUtils.waitForFxEvents(); + interact(() -> bar.getScene().getRoot().layout()); + WaitForAsyncUtils.waitForFxEvents(); + + assertTrue(lookup(".review-verdict-refusal").queryAll().stream() + .filter(node -> !node.getStyleClass().contains("review-verdict-ask-refusal")) + .filter(node -> !node.getStyleClass().contains("review-verdict-submit-refusal")) + .anyMatch(Node::isVisible), + "the blocking refusal must be showing, or this measures nothing"); + assertNothingTruncated(); + // What the shortening buys, stated as the reader sees it: the row + // cannot hold the sentence at this width, so the refusal is its + // glyph. Asserted rather than inferred from a width measurement -- + // at the bar's REAL floor the intent title is squeezed to nothing + // either way, so the geometry no longer discriminates and a test + // resting on it (as this one did at a 560px bar) silently stops + // pinning anything. + assertEquals("⚠", blockingRefusalText(), + "at this width the row cannot hold the sentence; the refusal must be its glyph"); + // The title assertion this used to carry ("still > 0px") was + // calibrated against a 560px bar. At the bar's REAL width the title + // is gone either way, so it no longer discriminates -- what does is + // assertNothingTruncated above: the full 146px sentence cannot be + // paid for out of a row this tight without squeezing the BUTTONS, + // which it checks. Verified by re-running the mutation that removes + // the shortening; it still dies, on the buttons instead. + } + + /** And the sentence comes back the moment there is room for it. */ + @Test + void theBlockingRefusalKeepsItsSentenceWhenTheRowCanHoldIt() { + interact(() -> { + bar.update(intent(2, "drydock/review · 4 files"), Optional.empty(), true); + bar.showProgress(1, 7); + bar.getScene().getWindow().setWidth(1400); + }); + WaitForAsyncUtils.waitForFxEvents(); + interact(() -> bar.getScene().getRoot().layout()); + WaitForAsyncUtils.waitForFxEvents(); + + assertEquals("⚠ a blocking finding is still open", blockingRefusalText(), + "a wide bar has room for the reason; shortening it there would be a loss"); + } + + /** The blocking refusal's text -- the one that is neither ask nor submit. */ + private String blockingRefusalText() { + String[] text = new String[1]; + interact(() -> text[0] = lookup(".review-verdict-refusal").queryAll().stream() + .filter(node -> !node.getStyleClass().contains("review-verdict-ask-refusal")) + .filter(node -> !node.getStyleClass().contains("review-verdict-submit-refusal")) + .filter(Node::isVisible) + .map(node -> ((Label) node).getText()) + .findFirst() + .orElse("")); + return text[0]; + } + + /** + * Round 3, item 2. {@code update()} clears both footer refusals, but + * nothing cleared one when the OTHER was raised -- and neither failure + * path calls {@code update()}. Submit refuses, the reader then asks the + * agent on that same intent, and both labels plus {@code Submit} shared + * one row three ways: the primary action read "Sub…". + */ + @Test + void raisingOneFooterRefusalRetiresTheOther() { + show(intent(2, "drydock/review · 4 files"), Optional.empty()); + interact(() -> bar.showSubmitRefused(SessionReviewView.NEEDS_VERDICT.reason(), + SessionReviewView.NEEDS_VERDICT.detail())); + WaitForAsyncUtils.waitForFxEvents(); + assertTrue(refusalShowing("review-verdict-submit-refusal")); + + askSucceeds = false; + interact(() -> askButton().fire()); + WaitForAsyncUtils.waitForFxEvents(); + interact(() -> bar.getScene().getRoot().layout()); + WaitForAsyncUtils.waitForFxEvents(); + + assertTrue(refusalShowing("review-verdict-ask-refusal"), "the newer refusal is the one shown"); + assertFalse(refusalShowing("review-verdict-submit-refusal"), + "two refusals in one footer squeeze Submit to an ellipsis"); + assertNothingTruncated(); + } + + /** + * Round 4, item 2. Every refusal {@code submitReview} can raise, looped + * over the REAL production strings rather than a copy this file holds -- + * a test that covers one of four instances of a defect class is how the + * other three ship, and three of these four were elided at the floor + * ({@code 'the diff is still loading; try again in a moment'} took 206 of + * 211px and cost {@code Submit} its last character). + */ + @Test + void everySubmitRefusalFitsAtTheCodeColumnFloor() { + for (SessionReviewView.SubmitRefusal refusal : SessionReviewView.SUBMIT_REFUSALS) { + show(intent(2, "drydock/review · 4 files"), Optional.empty()); + interact(() -> bar.showSubmitRefused(refusal.reason(), refusal.detail())); + WaitForAsyncUtils.waitForFxEvents(); + interact(() -> bar.getScene().getRoot().layout()); + WaitForAsyncUtils.waitForFxEvents(); + + assertTrue(refusalShowing("review-verdict-submit-refusal"), + "'" + refusal.reason() + "' must be showing, or this measures nothing"); + assertNothingTruncated(); + } + } + + /** + * The other direction, which the test above cannot see and a mutation + * proved it could not: {@code showAskRefused} clearing the submit + * refusal and {@code showSubmitRefused} clearing the ask one are two + * separate lines, and either can be lost on its own. A reader reaches + * this one by asking the agent, being told there is nothing to send, and + * then pressing Submit. + */ + @Test + void raisingTheSubmitRefusalRetiresTheAskRefusalToo() { + show(intent(2, "drydock/review · 4 files"), Optional.empty()); + askSucceeds = false; + interact(() -> askButton().fire()); + WaitForAsyncUtils.waitForFxEvents(); + assertTrue(refusalShowing("review-verdict-ask-refusal")); + interact(() -> bar.showSubmitRefused(SessionReviewView.NEEDS_VERDICT.reason(), + SessionReviewView.NEEDS_VERDICT.detail())); + WaitForAsyncUtils.waitForFxEvents(); + interact(() -> bar.getScene().getRoot().layout()); + WaitForAsyncUtils.waitForFxEvents(); + + assertTrue(refusalShowing("review-verdict-submit-refusal"), "the newer refusal is the one shown"); + assertFalse(refusalShowing("review-verdict-ask-refusal"), + "two refusals in one footer squeeze Submit to an ellipsis"); + assertNothingTruncated(); + } + + /** + * Round 3, item 3. {@code fitFooter} traded the shortcuts hint away for + * a refusal at ANY width -- it never consulted the room it had, unlike + * {@code fitActionRow}. A 1400px bar hid it with hundreds of pixels to + * spare, and no test could see that: both hints carry + * {@code .review-verdict-hint} and the only assertion about "the hint" + * matched navHint's text. + */ + @Test + void aWideBarKeepsTheShortcutHintWhileRefusing() { + show(intent(2, "drydock/review · 4 files"), Optional.empty()); interact(() -> bar.getScene().getWindow().setWidth(1400)); WaitForAsyncUtils.waitForFxEvents(); - assertTrue(hintShowing(), "a wide bar shows the hint again"); + interact(() -> bar.showSubmitRefused(SessionReviewView.NEEDS_VERDICT.reason(), + SessionReviewView.NEEDS_VERDICT.detail())); + WaitForAsyncUtils.waitForFxEvents(); + + assertTrue(refusalShowing("review-verdict-submit-refusal"), "the refusal must be up"); + assertTrue(shortcutHintShowing(), + "a 1400px bar has room for both; the hint is dropped for want of room, not on principle"); + } + + /** And at the floor it still yields, which is what made the trade worth making. */ + @Test + void atTheFloorTheShortcutHintStillYieldsToARefusal() { + show(intent(2, "drydock/review · 4 files"), Optional.empty()); + interact(() -> bar.showSubmitRefused(SessionReviewView.NEEDS_VERDICT.reason(), + SessionReviewView.NEEDS_VERDICT.detail())); + WaitForAsyncUtils.waitForFxEvents(); + interact(() -> bar.getScene().getRoot().layout()); + WaitForAsyncUtils.waitForFxEvents(); + + assertFalse(shortcutHintShowing(), "at the floor the refusal takes the hint's room"); + assertNothingTruncated(); + } + + private boolean refusalShowing(String styleClass) { + boolean[] showing = new boolean[1]; + interact(() -> showing[0] = lookup("." + styleClass).queryAll().stream() + .anyMatch(Node::isVisible)); + return showing[0]; + } + + private Button askButton() { + return lookup(".button").queryAll().stream() + .map(Button.class::cast) + .filter(button -> "Ask the agent to fix it".equals(button.getText())) + .findFirst() + .orElseThrow(() -> new AssertionError("no Ask-the-agent button")); + } + + private static String unitWord(SessionReviewView.SettleUnit unit) { + return switch (unit) { + case HUNK -> "next unread hunk"; + case SECTION -> "section"; + case FILE -> "file"; + case PATH_STEP -> "hunk"; + }; + } + + private String approveButtonText() { + String[] text = new String[1]; + interact(() -> text[0] = lookup(".button").queryAll().stream() + .map(Button.class::cast) + .map(Button::getText) + .filter(t -> t.startsWith("Approve (")) + .findFirst() + .orElse("")); + return text[0]; } // ---- helpers -------------------------------------------------------- - private boolean hintShowing() { + /** + * {@code navHint} -- "3 left · n jumps to the next", the action row's own + * droppable hint. NOT the footer's "press ? for shortcuts": both carry + * {@code .review-verdict-hint}, and this method used to match on text to + * pick one, which meant every assertion about "the hint" was silently + * about the action row only. + */ + private boolean navHintShowing() { boolean[] showing = new boolean[1]; interact(() -> showing[0] = lookup(".review-verdict-hint").queryAll().stream() .anyMatch(node -> node.isManaged() @@ -130,9 +464,20 @@ private boolean hintShowing() { return showing[0]; } + /** The FOOTER's "press ? for shortcuts", found by its own class. */ + private boolean shortcutHintShowing() { + boolean[] showing = new boolean[1]; + interact(() -> showing[0] = lookup(".review-verdict-shortcut-hint").queryAll().stream() + .anyMatch(Node::isManaged)); + return showing[0]; + } - private void show(ReviewIntent intent, Optional verdict) { - interact(() -> bar.update(intent, verdict, false, 1, 7)); + + private void show(ReviewIntent intent, Optional decision) { + interact(() -> { + bar.update(intent, decision, false); + bar.showProgress(1, 7); + }); WaitForAsyncUtils.waitForFxEvents(); interact(() -> bar.getScene().getRoot().layout()); WaitForAsyncUtils.waitForFxEvents(); @@ -144,12 +489,36 @@ private void show(ReviewIntent intent, Optional verdict) { * skin's elided string keeps this independent of the font the CI machine * happens to have. */ + /** + * Generous: a normal one-line row at the floor is under 40px; the + * stale banner's own two-line wrap (see the class's history) adds one + * more line. What this actually guards against is the OTHER failure + * mode this codebase has shipped -- a wrapped label collapsing to a + * column of single characters (see {@code ReviewIntentRailCardHeightTest}) -- + * not the two-line wrap itself, which is real and reported, not hidden. + */ + private static final double SANE_BAR_HEIGHT = 160; + private void assertNothingTruncated() { double[] width = new double[1]; - interact(() -> width[0] = bar.getWidth()); - assertTrue(width[0] <= RailLayout.CODE_MIN_WIDTH + 1, + double[] barPrefHeight = new double[1]; + interact(() -> { + width[0] = bar.getWidth(); + // prefHeight, not getHeight(): the bar is the Scene's ROOT, and + // a Scene resizes its root to fill its own fixed dimensions + // (200px here) regardless of content -- getHeight() would + // therefore always read 200 and this assertion would pass + // without measuring anything, the same trap the width check + // above already guards against. + barPrefHeight[0] = bar.prefHeight(BAR_WIDTH_AT_FLOOR); + }); + assertTrue(width[0] <= BAR_WIDTH_AT_FLOOR + 1, "the bar is " + Math.round(width[0]) + "px, not at the floor -- this assertion " + "would pass without measuring anything"); + assertTrue(barPrefHeight[0] > 0 && barPrefHeight[0] < SANE_BAR_HEIGHT, + "the bar wants " + Math.round(barPrefHeight[0]) + "px tall at the " + + (int) BAR_WIDTH_AT_FLOOR + "px floor; a wrapped label collapsed to " + + "a column of single characters looks exactly like this"); List squeezed = new ArrayList<>(); interact(() -> lookup(".button").queryAll().stream() @@ -162,8 +531,66 @@ private void assertNothingTruncated() { + Math.round(button.getWidth()) + " of " + Math.round(wanted)); } })); - assertTrue(squeezed.isEmpty(), "at " + (int) RailLayout.CODE_MIN_WIDTH - + "px these controls were truncated: " + squeezed); + // Folded in per the coordinator's review: a wrapText label with no + // minWidth (the stale banner) can reflow silently underneath a + // button-only check. Not "one line" -- it measurably is not, at + // this floor (see theStaleBannerFitsAtTheCodeColumnFloor's javadoc) + // -- but it must not wrap past two lines either. + interact(() -> lookup(".review-verdict-stale").queryAll().stream() + .map(Label.class::cast) + .filter(Label::isVisible) + .forEach(label -> { + double oneLine = label.prefHeight(-1); + double actual = label.getHeight(); + if (actual > oneLine * 2 + 1) { + squeezed.add("'" + label.getText() + "' wrapped to roughly " + + Math.round(actual / oneLine) + " lines (" + + Math.round(actual) + "px)"); + } + })); + // A refusal label is NOT wrapText, so it elides rather than reflows -- + // invisible to both checks above. Measured the same way the buttons + // are (laid-out width against asked-for width), which keeps it + // independent of the CI machine's font. + interact(() -> lookup(".review-verdict-refusal").queryAll().stream() + .map(Label.class::cast) + .filter(Label::isVisible) + .forEach(label -> { + double wanted = label.prefWidth(-1); + if (label.getWidth() + 0.5 < wanted) { + squeezed.add("'" + label.getText() + "' got " + + Math.round(label.getWidth()) + " of " + Math.round(wanted)); + } + })); + // Nothing may run off the END of a row either. A control with + // minWidth(USE_PREF_SIZE) cannot be squeezed, so an over-full row + // does not elide anything -- it simply lays a child out past its own + // right edge, where it is clipped and invisible. Every check above + // compares a child against what it ASKED for and sees nothing wrong. + for (String selector : List.of(".review-verdict-actions", ".review-verdict-footer")) { + interact(() -> lookup(selector).queryAll().stream() + .map(Parent.class::cast) + .forEach(row -> { + double edge = ((Region) row).getWidth(); + row.getChildrenUnmodifiable().stream() + .filter(Node::isManaged) + .filter(child -> child.getBoundsInParent().getMaxX() > edge + 0.5) + .forEach(child -> squeezed.add(describe(child) + " runs " + + Math.round(child.getBoundsInParent().getMaxX() - edge) + + "px past the end of " + selector)); + })); + } + assertTrue(squeezed.isEmpty(), "at " + (int) BAR_WIDTH_AT_FLOOR + + "px these controls were truncated or mis-wrapped: " + squeezed); + } + + /** A node named the way a reader would recognise it in a failure. */ + private static String describe(Node node) { + if (node instanceof Labeled labeled && labeled.getText() != null + && !labeled.getText().isBlank()) { + return "'" + labeled.getText() + "'"; + } + return node.getStyleClass().isEmpty() ? node.toString() : "." + node.getStyleClass().get(0); } private static ReviewIntent intent(int number, String title) { diff --git a/app/src/test/java/app/drydock/ui/review/ReviewVerdictBarNavigationTest.java b/app/src/test/java/app/drydock/ui/review/ReviewVerdictBarNavigationTest.java index f86b3fc0..808bf6a2 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewVerdictBarNavigationTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewVerdictBarNavigationTest.java @@ -1,5 +1,6 @@ package app.drydock.ui.review; +import app.drydock.ui.TestStages; import app.drydock.review.ReviewIntent; import app.drydock.review.ReviewVerdict; import javafx.scene.Scene; @@ -29,10 +30,15 @@ class ReviewVerdictBarNavigationTest extends ApplicationTest { @Override public void start(Stage stage) { bar = new ReviewVerdictBar(new ReviewVerdictBar.Host() { - @Override public void approve(ReviewIntent intent) { calls.add("approve"); } - @Override public void requestChanges(ReviewIntent intent) { calls.add("changes"); } - @Override public void askAgentToFix(ReviewIntent intent) { calls.add("ask"); } + @Override public void approve(ReviewIntent intent, SessionReviewView.SettleUnit unit) { + calls.add("approve"); + } + @Override public void requestChanges(ReviewIntent intent, SessionReviewView.SettleUnit unit) { + calls.add("changes"); + } + @Override public boolean askAgentToFix(ReviewIntent intent) { calls.add("ask"); return true; } @Override public void undo(ReviewIntent intent) { calls.add("undo"); } + @Override public void confirmStillGood(ReviewIntent intent) { calls.add("confirm"); } @Override public void nextUnsettled() { calls.add("nextUnsettled"); } @Override public void submit() { calls.add("submit"); } @Override public void previousIntent() { calls.add("previous"); } @@ -42,13 +48,12 @@ public void start(Stage stage) { scene.getStylesheets().addAll( getClass().getResource("/app/drydock/ui/app.css").toExternalForm(), getClass().getResource("/app/drydock/ui/theme-dark.css").toExternalForm()); - stage.setScene(scene); - stage.show(); + TestStages.show(stage, scene); } @Test void theBarNamesTheIntentItIsSettling() { - interact(() -> bar.update(intent(2, "Rename the parser"), Optional.empty(), false, 1, 4)); + interact(() -> bar.update(intent(2, "Rename the parser"), Optional.empty(), false)); assertEquals("2 · Rename the parser", ((Label) lookup(".review-verdict-intent").query()).getText()); @@ -56,7 +61,7 @@ void theBarNamesTheIntentItIsSettling() { @Test void theNavigationControlsReachTheSameActionsAsTheKeys() { - interact(() -> bar.update(intent(2, "Rename the parser"), Optional.empty(), false, 1, 4)); + interact(() -> bar.update(intent(2, "Rename the parser"), Optional.empty(), false)); interact(() -> ((Button) lookup(".review-verdict-previous").query()).fire()); interact(() -> ((Button) lookup(".review-verdict-next").query()).fire()); @@ -66,7 +71,7 @@ void theNavigationControlsReachTheSameActionsAsTheKeys() { @Test void withNoIntentTheBarSaysSoAndDisablesNavigation() { - interact(() -> bar.update(null, Optional.empty(), false, 0, 0)); + interact(() -> bar.update(null, Optional.empty(), false)); assertEquals("no intent", ((Label) lookup(".review-verdict-intent").query()).getText()); assertTrue(((Button) lookup(".review-verdict-next").query()).isDisabled()); diff --git a/app/src/test/java/app/drydock/ui/review/ReviewViewFixture.java b/app/src/test/java/app/drydock/ui/review/ReviewViewFixture.java new file mode 100644 index 00000000..4dc3520e --- /dev/null +++ b/app/src/test/java/app/drydock/ui/review/ReviewViewFixture.java @@ -0,0 +1,248 @@ +package app.drydock.ui.review; + +import app.drydock.ui.TestStages; +import app.drydock.git.DiffService; +import app.drydock.git.UnifiedDiff; +import app.drydock.review.HunkDigest; +import app.drydock.review.ReviewIntent; +import app.drydock.review.ReviewScope; +import app.drydock.review.ReviewScopeRegistry; +import app.drydock.review.SessionReviewScopes; + +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.control.Label; +import javafx.scene.control.ListCell; +import javafx.scene.input.MouseButton; +import javafx.stage.Stage; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.testfx.framework.junit5.ApplicationTest; +import org.testfx.util.WaitForAsyncUtils; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * Shared board for the settle-unit tests (spec §9.6): two overlapping + * sections over three files. Section {@code ①} covers TWO hunks of the same + * file ({@link #FILE_A}) plus one of {@link #FILE_B}, so a hunk-scoped action + * is distinguishable from a section-scoped one; it shares {@code FILE_A}'s + * first hunk with section {@code ②}, so the "settled elsewhere" effect + * (spec §5.6) is exercised too. + * + *

Modelled on {@link FakeReviewHost}'s use in {@link ReviewHunkProgressTest}: + * a real store and a real grouping, so the {@code (scopeId, digest)} keying + * under test is the real thing rather than a stub that keys however a test + * pleases.

+ */ +abstract class ReviewViewFixture extends ApplicationTest { + + static final String FILE_A = "src/guards.h"; + static final String FILE_B = "src/guards.cpp"; + static final String FILE_C = "src/profiler.cpp"; + + final ReviewScopeRegistry registry = new ReviewScopeRegistry(); + private final DiffService diffService = new DiffService(); + FakeReviewHost host; + SessionReviewView view; + ReviewScope scope; + + @Override + public void start(Stage stage) { + try { + host = new FakeReviewHost(Files.createTempDirectory("drydock-settle") + .resolve("annotations.json")); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + host.diff = new UnifiedDiff(List.of( + file(FILE_A, "void foo();", "void bar();"), + file(FILE_B, "void baz();"), + file(FILE_C, "void qux();"))); + view = new SessionReviewView(host, diffService, null); + Scene scene = new Scene(view, 1400, 900); + scene.getStylesheets().addAll( + getClass().getResource("/app/drydock/ui/app.css").toExternalForm(), + getClass().getResource("/app/drydock/ui/theme-dark.css").toExternalForm()); + TestStages.show(stage, scene); + } + + /** + * A fresh scope every test, rather than one shared for the class: scope + * ids namespace the annotation store, so this is what keeps one test's + * verdicts from leaking into the next even though {@link #host} and + * {@link #view} themselves are only built once for the whole class (the + * standard TestFX lifecycle -- {@link #start} runs once, not per test). + */ + @BeforeEach + void showBoard() throws TimeoutException { + scope = registry.mint(ReviewScopeRegistry.spec(ReviewScope.Kind.WORKING_TREE, + Path.of("/tmp/nowhere"), Optional.of(Path.of("/tmp/nowhere")), "main", "main", + Optional.empty(), Optional.empty())); + host.intents.set(scope.id(), List.of( + new ReviewIntent("section-1", 0, "Guards", ReviewIntent.Kind.CHANGE, + ReviewIntent.Risk.MED, "", List.of( + ReviewIntent.hunkId(FILE_A, 0), + ReviewIntent.hunkId(FILE_A, 1), + ReviewIntent.hunkId(FILE_B, 0)), + Optional.empty(), false), + new ReviewIntent("section-2", 0, "Profiler", ReviewIntent.Kind.CHANGE, + ReviewIntent.Risk.MED, "", List.of( + ReviewIntent.hunkId(FILE_A, 0), + ReviewIntent.hunkId(FILE_C, 0)), + Optional.empty(), false))); + interact(() -> view.showScopes(new SessionReviewScopes.Scopes(scope, Optional.empty()), + SessionReviewScopes.Choice.LOCAL)); + interact(() -> view.diagShowDiff(scope, host.diff)); + WaitForAsyncUtils.waitForFxEvents(); + // diagShowDiff kicks off a ChangeGraph build on a background + // executor (SessionReviewView#requestGraph); its completion + // refreshes the rail and diff column from the FX thread whenever it + // happens to land. A test that starts clicking before it settles + // races that refresh -- which can rebuild the very node the click + // just focused and hand focus somewhere else (see + // SessionReviewView#diagFocusSnapshot's javadoc, and the CI-only + // failure it was added to diagnose). Waiting here, once, closes the + // race for every test built on this fixture instead of leaving each + // one to hit it by chance. + WaitForAsyncUtils.waitFor(10, TimeUnit.SECONDS, () -> !view.diagGraphBuildPending(scope.id())); + WaitForAsyncUtils.waitForFxEvents(); + } + + @AfterEach + void tearDown() { + diffService.close(); + host.store.close(); + } + + /** + * A plain click on a rail card -- what {@link SessionReviewView}'s own + * {@code MOUSE_PRESSED} filter on {@code intentRail} reads to decide + * {@link SessionReviewView#settleUnit()}. Deliberately not {@code + * Node.requestFocus()}/{@code isFocusWithin()}: the rail replaces every + * card {@code Button} on each render, and a card discarded while + * focused hands focus to whatever JavaFX's {@code Direction.NEXT} + * traversal finds next -- which can land inside the diff column and + * never leave. A mouse click is real user input either way. + */ + final void focusRail() { + clickOn(".review-intent-card"); + WaitForAsyncUtils.waitForFxEvents(); + } + + /** + * A plain click into the diff column -- see {@link #focusRail}. + * + *

Uses {@code moveTo} + a separate {@code press}/{@code release} + * rather than the compound {@code clickOn(String)} that {@link + * #focusRail} uses for the rail: {@code + * withAGutterSelectionOpenApproveSettlesTheSelectedHunkNotTheAnchor} (the + * one test in this class using that same move-then-press shape, on the + * gutter) has never once failed on CI, while every test going through + * {@code clickOn(".review-diff-cell")} has -- see the CI-only failure + * {@code diagFocusSnapshot} was added to diagnose, still not fully + * understood, and TEMPORARY diagnostics below re-added to observe it.

+ * + *

Also polls {@link SessionReviewView#diagFocusInDiffColumn()} after + * the click rather than trusting one {@code waitForFxEvents()}: a real + * robot press is delivered to the FX thread asynchronously, off this + * thread, and a single drain only waits for whatever was ALREADY queued + * when it is called.

+ */ + final void focusDiffColumn() throws TimeoutException { + // TEMPORARY: logs every ".review-diff-cell" match's empty/visible/ + // bounds state before the click, and (via ReviewDiffColumn's own + // filter) whether the press physically reaches production code at + // all. Remove once the CI-only failure this investigates is + // understood -- see the class javadoc above. + interact(() -> lookup(".review-diff-cell").queryAll().forEach(node -> { + String empty = node instanceof ListCell cell ? String.valueOf(cell.isEmpty()) : "n/a"; + System.out.println("[diag] .review-diff-cell candidate empty=" + empty + + " visible=" + node.isVisible() + + " boundsInLocal=" + node.getBoundsInLocal() + + " boundsInScene=" + node.localToScene(node.getBoundsInLocal())); + })); + moveTo(".review-diff-cell"); + press(MouseButton.PRIMARY); + release(MouseButton.PRIMARY); + WaitForAsyncUtils.waitForFxEvents(); + try { + WaitForAsyncUtils.waitFor(5, TimeUnit.SECONDS, view::diagFocusInDiffColumn); + } catch (TimeoutException e) { + // TEMPORARY: a bare TimeoutException says only "it never + // happened", not what focus actually settled on instead. + throw new TimeoutException( + "focus never landed in the diff column within 5s; " + view.diagFocusSnapshot()); + } + } + + /** How many hunks {@link #FILE_A} has -- what {@code ⇧A}/{@code ⇧R} settle. */ + final int hunkCountOfCurrentFile() { + return 2; + } + + /** + * The gutter of {@link #FILE_A}'s SECOND hunk (new line 11), selected + * by its rendered line number rather than position -- a virtualized + * {@code ListView} recycles and reorders cells, so "the second gutter" + * is not a stable way to name a line (see {@code ReviewDiffGutterSelectionTest}). + */ + final Node gutterForFileASecondHunk() { + return gutterForLine("11"); + } + + private Node gutterForLine(String number) { + List found = new ArrayList<>(); + interact(() -> found.addAll(lookup(".review-code-gutter").queryAll())); + return found.stream() + .filter(node -> node.getOnMouseClicked() != null) + .filter(node -> number.equals(((Label) node).getText())) + .findFirst() + .orElseThrow(() -> new AssertionError("no clickable gutter for line " + number)); + } + + /** The digest of {@link #FILE_A}'s first hunk -- its anchor. */ + final String digestOfFirstHunkOfFileA() { + return digestOfHunk(FILE_A, 0); + } + + /** The digest of {@link #FILE_A}'s second hunk -- what the gutter click above selects. */ + final String digestOfSecondHunkOfFileA() { + return digestOfHunk(FILE_A, 1); + } + + private String digestOfHunk(String file, int index) { + return host.diff.files().stream() + .filter(candidate -> candidate.path().equals(file)) + .findFirst() + .map(candidate -> HunkDigest.of(file, candidate.hunks().get(index))) + .orElseThrow(); + } + + /** + * Each hunk's line gets a DIFFERENT new-line number (index*10 + 1), not + * a shared {@code 1}: a line key is {@code (file, newLine)}, and two + * hunks of the same file both keyed {@code n1} would make a gutter + * selection ambiguous between them -- {@code digestOfLine} would always + * resolve to whichever hunk it walks to first, silently, regardless of + * which one was actually clicked. + */ + private static UnifiedDiff.FileDiff file(String path, String... hunkTexts) { + List hunks = new ArrayList<>(); + for (int i = 0; i < hunkTexts.length; i++) { + hunks.add(new UnifiedDiff.Hunk("@@ -1 +1 @@", List.of( + new UnifiedDiff.Line(UnifiedDiff.Line.Kind.ADD, OptionalInt.empty(), + OptionalInt.of(i * 10 + 1), hunkTexts[i])))); + } + return new UnifiedDiff.FileDiff(path, "M", hunkTexts.length, 0, false, false, hunks); + } +} diff --git a/app/src/test/java/app/drydock/ui/review/SectionRailSwapTest.java b/app/src/test/java/app/drydock/ui/review/SectionRailSwapTest.java new file mode 100644 index 00000000..40edc436 --- /dev/null +++ b/app/src/test/java/app/drydock/ui/review/SectionRailSwapTest.java @@ -0,0 +1,215 @@ +package app.drydock.ui.review; + +import app.drydock.ui.TestStages; +import app.drydock.git.DiffService; +import app.drydock.git.UnifiedDiff; +import app.drydock.review.ReviewIntent; +import app.drydock.review.ReviewScope; +import app.drydock.review.ReviewScopeRegistry; +import app.drydock.review.SessionReviewScopes; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.stage.Stage; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.testfx.framework.junit5.ApplicationTest; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.concurrent.Callable; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The rail's fallback-to-computed swap end to end (Task 13's own headline + * behaviour): with no reviewer grouping, the (kind, directory) clustering + * renders the instant a diff lands, and the computed sections replace it + * once the background {@code ChangeGraph} finishes -- {@code grep -rn + * "computed:" app/src} found the id scheme nowhere in a test before this. + */ +class SectionRailSwapTest extends ApplicationTest { + + private final DiffService diffService = new DiffService(); + private final ReviewScopeRegistry registry = new ReviewScopeRegistry(); + private FakeReviewHost host; + private SessionReviewView view; + + @Override + public void start(Stage stage) { + try { + host = new FakeReviewHost(Files.createTempDirectory("drydock-rail-swap") + .resolve("annotations.json")); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + view = new SessionReviewView(host, diffService, null); + Scene scene = new Scene(view, 1400, 900); + scene.getStylesheets().addAll( + getClass().getResource("/app/drydock/ui/app.css").toExternalForm(), + getClass().getResource("/app/drydock/ui/theme-dark.css").toExternalForm()); + TestStages.show(stage, scene); + } + + @AfterEach + void tearDown() { + diffService.close(); + host.store.close(); + } + + /** + * Four files whose structure {@code Sections} is known to split: + * {@code m.h}/{@code m.cpp} merge on the same-basename convention, + * {@code z.cpp} and {@code a.cpp} stay their own units -- one fallback + * group of all four, three computed sections. + */ + private static UnifiedDiff fourFileDiff() { + List files = new ArrayList<>(); + for (String name : List.of("z.cpp", "a.cpp", "m.h", "m.cpp")) { + files.add(new UnifiedDiff.FileDiff("src/" + name, "M", 1, 0, false, false, + List.of(new UnifiedDiff.Hunk("@@", List.of(new UnifiedDiff.Line( + UnifiedDiff.Line.Kind.ADD, OptionalInt.empty(), OptionalInt.of(1), + "void go() { helperOne(); }")))))); + } + return new UnifiedDiff(files); + } + + /** + * Whether the rail settles at the fallback's plain single group or + * jumps straight to the computed one before the first read depends on + * how fast this tiny four-file diff's {@code ChangeGraph.of} happens to + * run in THIS JVM (an already-warm tree-sitter grammar can make it + * effectively instant) -- so this pins the one thing that is NOT a + * race: the rail settles at the computed grouping, and stays there. + */ + @Test + void theRailSettlesOnTheComputedGroupingWithDistinctContentDerivedIds() { + UnifiedDiff diff = fourFileDiff(); + host.diff = diff; + ReviewScope scope = registry.mint(ReviewScopeRegistry.spec(ReviewScope.Kind.WORKING_TREE, + Path.of("/tmp/nowhere"), Optional.of(Path.of("/tmp/nowhere")), "main", "main", + Optional.empty(), Optional.empty())); + + interact(() -> view.showScopes(new SessionReviewScopes.Scopes(scope, Optional.empty()), + SessionReviewScopes.Choice.LOCAL)); + interact(() -> view.diagShowDiff(scope, diff)); + + awaitCardCount(3); + + // The computed grouping: m.h/m.cpp merge, z.cpp and a.cpp stand + // alone -- three sections replacing the fallback's single one. + List ids = cardIds(); + assertEquals(3, ids.size()); + for (String id : ids) { + assertTrue(id.startsWith("computed:"), + "once the graph lands, a genuinely different grouping must not keep the " + + "fallback's auto: identity: " + id); + } + assertEquals(ids.size(), ids.stream().distinct().count(), "every computed card must have its own id"); + } + + /** + * The point of the version-keyed cache: an unrelated refresh -- nothing + * about scope, diff, graph or the reviewer's grouping changed -- must + * reuse the SAME {@link List} instance {@link SessionReviewView#intents} + * last computed, not merely an equal one, or {@code Sections.of} is + * still running on every keypress underneath an equals() check that + * happens to pass. Then an actual reviewer regroup (the one thing the + * cache key does not already cover via scope/diff/graph identity) must + * still invalidate it. + */ + @Test + void theIntentsCacheSurvivesAnUnrelatedRefreshAndInvalidatesOnARegroup() { + UnifiedDiff diff = fourFileDiff(); + host.diff = diff; + ReviewScope scope = registry.mint(ReviewScopeRegistry.spec(ReviewScope.Kind.WORKING_TREE, + Path.of("/tmp/nowhere"), Optional.of(Path.of("/tmp/nowhere")), "main", "main", + Optional.empty(), Optional.empty())); + + interact(() -> view.showScopes(new SessionReviewScopes.Scopes(scope, Optional.empty()), + SessionReviewScopes.Choice.LOCAL)); + interact(() -> view.diagShowDiff(scope, diff)); + awaitCardCount(3); + + List first = view.diagIntents(); + interact(view::refreshReviewState); + List second = view.diagIntents(); + assertSame(first, second, + "an unrelated refresh (nothing in the cache key changed) must reuse the cached " + + "list, not recompute an equal one"); + + host.intents.set(scope.id(), List.of(new ReviewIntent("agent-1", 1, "Regrouped", + ReviewIntent.Kind.CHANGE, ReviewIntent.Risk.HIGH, "", + List.of(ReviewIntent.hunkId("src/z.cpp", 0)), Optional.empty(), false))); + interact(view::refreshReviewState); + List third = view.diagIntents(); + assertNotSame(second, third, "a reviewer's own regroup must invalidate the cache"); + assertEquals(List.of("Regrouped"), third.stream().map(ReviewIntent::title).toList()); + } + + /** + * A reviewer's grouping always wins over the computed sections, so + * building the {@link app.drydock.review.ChangeGraph} it would take to + * compute them is pure waste when one is already supplied -- real + * parsing work, and a background completion that would fire a needless + * extra refresh. The rail must never even claim to be "refining" for a + * scope that already has a reviewer's answer. + */ + @Test + void noGraphIsBuiltWhenAReviewerHasAlreadySuppliedAGrouping() { + UnifiedDiff diff = fourFileDiff(); + host.diff = diff; + ReviewScope scope = registry.mint(ReviewScopeRegistry.spec(ReviewScope.Kind.WORKING_TREE, + Path.of("/tmp/nowhere"), Optional.of(Path.of("/tmp/nowhere")), "main", "main", + Optional.empty(), Optional.empty())); + host.intents.set(scope.id(), List.of(new ReviewIntent("agent-1", 1, "Reviewed", + ReviewIntent.Kind.CHANGE, ReviewIntent.Risk.HIGH, "", + List.of(ReviewIntent.hunkId("src/z.cpp", 0)), Optional.empty(), false))); + + interact(() -> view.showScopes(new SessionReviewScopes.Scopes(scope, Optional.empty()), + SessionReviewScopes.Choice.LOCAL)); + interact(() -> view.diagShowDiff(scope, diff)); + + // Generous, fixed wait rather than a poll-until: there is no + // "settled" event to wait for when nothing is ever going to build, + // which is exactly the property under test. + sleep(500); + + assertEquals(List.of("agent-1"), view.diagIntentIds(), + "the reviewer's own id must be showing, never a computed: one"); + assertTrue(call(() -> lookup(".review-intent-pending").queryAll()).stream().noneMatch(Node::isVisible), + "no graph was requested, so the rail must never claim to be refining one"); + } + + private int cardCount() { + return call(() -> lookup(".review-intent-card").queryAll().size()); + } + + private List cardIds() { + return view.diagIntentIds(); + } + + /** Polls the rendered card count on wall time until it reaches {@code expected}. */ + private void awaitCardCount(int expected) { + long start = System.nanoTime(); + while (cardCount() != expected) { + if (System.nanoTime() - start > 30_000_000_000L) { + throw new AssertionError("card count never reached " + expected + + "; stuck at " + cardCount()); + } + sleep(50); + } + } + + private T call(Callable work) { + return ReviewDiagFxThread.call(work); + } +} diff --git a/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java b/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java new file mode 100644 index 00000000..bdf81793 --- /dev/null +++ b/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java @@ -0,0 +1,1080 @@ +package app.drydock.ui.review; + +import app.drydock.git.UnifiedDiff; +import app.drydock.review.BaseMove; +import app.drydock.review.ChangeGraph; +import app.drydock.review.HunkDigest; +import app.drydock.review.Provenance; +import app.drydock.review.RecheckDispatch; +import app.drydock.review.ReviewIntent; +import app.drydock.review.ReviewScope; +import app.drydock.review.ReviewScopeRegistry; +import app.drydock.review.ReviewVerdict; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.TreeSet; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * What a section says about itself, derived from its hunks (spec §9.1) -- + * exercised directly, with no {@code Stage}. + * + *

Every question here is answered from a {@link SessionReviewView.Host}, + * a diff and a grouping; none of it is scene graph. {@link + * ReviewHunkProgressTest} keeps the assertions that are genuinely about what + * the rail and the verdict bar RENDER.

+ */ +class SectionStatesTest { + + private static final String GUARDS_H = "src/guards.h"; + private static final String GUARDS_CPP = "src/guards.cpp"; + private static final String PROFILER = "src/profiler.cpp"; + + private final ReviewScopeRegistry registry = new ReviewScopeRegistry(); + private FakeReviewHost host; + private SectionStates sections; + private ReviewScope scope; + private UnifiedDiff diff; + + @BeforeEach + void setUp(@TempDir Path store) { + host = new FakeReviewHost(store.resolve("annotations.json")); + sections = new SectionStates(host); + diff = new UnifiedDiff(List.of( + file(GUARDS_H, "class JmpCtxScope;"), + file(GUARDS_CPP, "void install();"), + file(PROFILER, "resolve();"))); + scope = registry.mint(ReviewScopeRegistry.spec(ReviewScope.Kind.WORKING_TREE, + Path.of("/tmp/nowhere"), Optional.of(Path.of("/tmp/nowhere")), "main", "main", + Optional.empty(), Optional.empty())); + } + + @AfterEach + void tearDown() { + host.store.close(); + } + + // ---- distinct hunks, not section slots ---------------------------------- + + /** Four section slots over three hunks: anything summing sizes reads 4. */ + @Test + void progressCountsDistinctHunksNotSectionSlots() { + SectionStates.Board board = overlapping(); + + assertEquals(3, sections.distinctDigests(board).size()); + assertEquals(0, sections.settledHunkCount(board)); + } + + @Test + void aHunkInTwoSectionsIsOneFlagNotTwo() { + SectionStates.Board board = overlapping(); + approve(GUARDS_H); + + assertEquals(1, sections.settledHunkCount(board)); + } + + // ---- a section's decision comes from its hunks --------------------------- + + @Test + void anUnsettledHunkLeavesItsSectionUnsettled() { + SectionStates.Board board = overlapping(); + approve(GUARDS_H); + + SectionStates.SectionState state = sections.stateOf(board, board.sections().get(0)); + assertEquals(Optional.empty(), state.decision()); + assertEquals(1, state.settledHunks()); + assertEquals(2, state.totalHunks()); + } + + @Test + void aSectionWithEveryHunkSettledIsApproved() { + SectionStates.Board board = overlapping(); + approve(GUARDS_H); + approve(GUARDS_CPP); + + assertEquals(Optional.of(ReviewVerdict.Decision.APPROVED), + sections.stateOf(board, board.sections().get(0)).decision()); + } + + /** Any changes request wins over the rest of the section (VerdictMerge). */ + @Test + void oneChangeRequestMakesTheWholeSectionChanges() { + SectionStates.Board board = overlapping(); + record(GUARDS_CPP, ReviewVerdict.Decision.CHANGES, host.baseCommit); + + assertEquals(Optional.of(ReviewVerdict.Decision.CHANGES), + sections.stateOf(board, board.sections().get(0)).decision()); + } + + // ---- a hunk settled in a neighbouring section ---------------------------- + + @Test + void aFullySettledSiblingIsNamed() { + SectionStates.Board board = overlapping(); + approve(GUARDS_H); + approve(GUARDS_CPP); + + assertEquals(List.of("①"), + sections.stateOf(board, board.sections().get(1)).settledElsewhere()); + } + + /** + * A sibling that settled ONE shared hunk moves this card's count by + * exactly as much as a fully settled one does. Marking only the + * fully-settled case solves the easy half of "state changing on its own" + * and leaves the other half exactly as mysterious. + */ + @Test + void aPartlySettledSiblingIsNamedToo() { + SectionStates.Board board = overlapping(); + approve(GUARDS_H); + + assertEquals(List.of("②"), + sections.stateOf(board, board.sections().get(0)).settledElsewhere()); + assertEquals(List.of("①"), + sections.stateOf(board, board.sections().get(1)).settledElsewhere()); + } + + /** A section that shares nothing has nothing to point at. */ + @Test + void aSectionSharingNoHunkNamesNobody() { + SectionStates.Board board = board(List.of( + section("section-1", GUARDS_H), + section("section-2", PROFILER))); + approve(GUARDS_H); + + assertTrue(sections.stateOf(board, board.sections().get(0)).settledElsewhere().isEmpty()); + } + + /** + * Task 18's correction 6b, unreachable until sections overlapped: with + * THREE sections sharing one hunk, a verdict is keyed {@code (scopeId, + * hunkDigest)} alone -- nothing records which of them the reader actually + * settled it through -- so naming every sharer would credit sections + * that, as far as this model can tell, reviewed nothing. At most one is + * named per card; the two-section tests above (still passing, unchanged) + * are the case where "at most one" and "the only one" coincide. + */ + @Test + void threeSectionsSharingAHunkNameAtMostOneEach() { + SectionStates.Board board = board(List.of( + section("section-1", GUARDS_H, GUARDS_CPP), + section("section-2", GUARDS_H, PROFILER), + section("section-3", GUARDS_H))); + approve(GUARDS_H); + + assertEquals(List.of("②"), + sections.stateOf(board, board.sections().get(0)).settledElsewhere(), + "section 1 must name at most one sharer, not both 2 and 3"); + assertEquals(List.of("①"), + sections.stateOf(board, board.sections().get(1)).settledElsewhere(), + "section 2 must name at most one sharer, not both 1 and 3"); + assertEquals(List.of("①"), + sections.stateOf(board, board.sections().get(2)).settledElsewhere(), + "section 3 must name at most one sharer, not both 1 and 2"); + } + + // ---- staleness has three states, not two -------------------------------- + + @Test + void aVerdictAgainstTheCurrentBaseIsFresh() { + SectionStates.Board board = overlapping(); + approve(GUARDS_H); + + assertEquals(SectionStates.Staleness.FRESH, + sections.stateOf(board, board.sections().get(0)).staleness()); + } + + @Test + void aBaseMoveTouchingTheSectionIsMoved() { + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of(GUARDS_H))); + SectionStates.Board board = overlapping(); + record(GUARDS_H, ReviewVerdict.Decision.APPROVED, "0".repeat(40)); + + assertEquals(SectionStates.Staleness.MOVED, + sections.stateOf(board, board.sections().get(0)).staleness()); + } + + /** + * Same exclusion {@link #settledHunkCount} applies globally, one layer + * down: a card's own "n/total" must not count a stale hunk either, or + * the card could read fully settled while the verdict bar's progress + * line, for the SAME hunks, read one short of it (coordinator's review). + * The DECISION still merges the stale verdict -- only the numeric count + * excludes it. + */ + @Test + void settledHunksExcludesAStaleOneButTheDecisionStillMergesIt() { + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of(GUARDS_H))); + SectionStates.Board board = overlapping(); + record(GUARDS_H, ReviewVerdict.Decision.APPROVED, "0".repeat(40)); + approve(GUARDS_CPP); + + SectionStates.SectionState state = sections.stateOf(board, board.sections().get(0)); + assertEquals(1, state.settledHunks(), "the stale GUARDS_H verdict must not be counted"); + assertEquals(2, state.totalHunks()); + assertEquals(Optional.of(ReviewVerdict.Decision.APPROVED), state.decision(), + "the decision persists across staleness -- only its freshness is in question"); + } + + /** + * Task 18's correction 6a: a section with one stale-approved hunk and one + * genuinely UNREAD hunk has {@code settledHunks()==0} -- correct, nothing + * here is safely settled -- but the card must not read as though NOTHING + * was ever recorded either. {@code recordedHunks()} is what the rail's + * progress LABEL reads instead, so "1/2 hunks" survives exactly this gap. + */ + @Test + void recordedHunksCountsAStaleVerdictEvenWhenNothingElseIsSettled() { + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of(GUARDS_H))); + SectionStates.Board board = overlapping(); + record(GUARDS_H, ReviewVerdict.Decision.APPROVED, "0".repeat(40)); + // GUARDS_CPP is left entirely unread -- no verdict of any kind. + + SectionStates.SectionState state = sections.stateOf(board, board.sections().get(0)); + assertEquals(0, state.settledHunks(), "the stale hunk must not count as SETTLED"); + assertEquals(1, state.recordedHunks(), + "but it WAS recorded -- the card must not understate to zero hunks touched"); + assertEquals(2, state.totalHunks()); + } + + /** A move that provably could not matter must not spend the reader's attention. */ + @Test + void aBaseMoveElsewhereIsFresh() { + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of("docs/README.md"))); + SectionStates.Board board = overlapping(); + record(GUARDS_H, ReviewVerdict.Decision.APPROVED, "0".repeat(40)); + + assertEquals(SectionStates.Staleness.FRESH, + sections.stateOf(board, board.sections().get(0)).staleness()); + } + + /** + * The delta is unresolvable while it is still being computed off the FX + * thread, and when the old base can no longer be diffed. Neither is + * evidence that the base moved, and rendering them as one would put a + * confirm-me banner on every card of a review nobody has touched. + */ + @Test + void anUnresolvableDeltaIsUnknownNotMoved() { + host.baseDelta = new BaseMove.Delta(true, new TreeSet<>()); + SectionStates.Board board = overlapping(); + record(GUARDS_H, ReviewVerdict.Decision.APPROVED, "0".repeat(40)); + + assertEquals(SectionStates.Staleness.UNKNOWN, + sections.stateOf(board, board.sections().get(0)).staleness()); + } + + // ---- an agent may add staleness, never take it away (spec 9.7) ---------- + + /** + * The blind spot {@link BaseMove} names in its own class comment: the + * intersection is file-level and lexical, so a base commit that changes + * behaviour without touching a file this section names reads as FRESH. + * An agent's {@code affected} recheck is the only thing that can close + * it, and this is the case where it has to. + */ + @Test + void anAgentsAffectedRecheckMarksAMoveTheFileFilterDismissed() { + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of("docs/README.md"))); + SectionStates.Board board = overlapping(); + record(GUARDS_H, ReviewVerdict.Decision.APPROVED, "0".repeat(40)); + assess(GUARDS_H, true, "0".repeat(40)); + + assertEquals(SectionStates.Staleness.MOVED, + sections.stateOf(board, board.sections().get(0)).staleness()); + assertEquals(0, sections.settledHunkCount(board), + "a hunk the agent marked must not count as settled either"); + } + + /** + * The asymmetry. The filter already found this move, and + * an agent saying "unaffected" must not take that back: an agent wrong + * THAT way leaves a human's approval standing over code nobody re-read, + * which is the outcome the whole reviewed-state model refuses. False and + * "never asked" are one answer here, deliberately. + */ + @Test + void anAgentsUnaffectedRecheckDoesNotClearAMoveTheFilterFound() { + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of(GUARDS_H))); + SectionStates.Board board = overlapping(); + record(GUARDS_H, ReviewVerdict.Decision.APPROVED, "0".repeat(40)); + assess(GUARDS_H, false, "0".repeat(40)); + + assertEquals(SectionStates.Staleness.MOVED, + sections.stateOf(board, board.sections().get(0)).staleness()); + assertEquals(0, sections.settledHunkCount(board), + "an agent's advice must not re-settle a hunk the base moved under"); + } + + /** Nor may it clear the weaker "cannot tell" the same way. */ + @Test + void anAgentsUnaffectedRecheckDoesNotClearAnUnresolvableDelta() { + host.baseDelta = new BaseMove.Delta(true, new TreeSet<>()); + SectionStates.Board board = overlapping(); + record(GUARDS_H, ReviewVerdict.Decision.APPROVED, "0".repeat(40)); + assess(GUARDS_H, false, "0".repeat(40)); + + assertEquals(SectionStates.Staleness.UNKNOWN, + sections.stateOf(board, board.sections().get(0)).staleness()); + } + + /** An affected recheck DOES outrank "cannot tell": it only ever adds reading. */ + @Test + void anAgentsAffectedRecheckOutranksAnUnresolvableDelta() { + host.baseDelta = new BaseMove.Delta(true, new TreeSet<>()); + SectionStates.Board board = overlapping(); + record(GUARDS_H, ReviewVerdict.Decision.APPROVED, "0".repeat(40)); + assess(GUARDS_H, true, "0".repeat(40)); + + assertEquals(SectionStates.Staleness.MOVED, + sections.stateOf(board, board.sections().get(0)).staleness()); + } + + /** + * An assessment is about one base PAIR. A recheck of an older move is not + * an answer about this one, and carrying it forward would be the agent + * answering something it was never asked. + */ + @Test + void anAgentsRecheckOfADifferentBasePairIsNotConsulted() { + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of("docs/README.md"))); + SectionStates.Board board = overlapping(); + record(GUARDS_H, ReviewVerdict.Decision.APPROVED, "0".repeat(40)); + // Marked affected -- but about a move FROM a base this verdict was + // never judged against. + host.store.putAssessment(new app.drydock.review.RecheckAssessment(scope.id(), + digestOf(GUARDS_H), "9".repeat(40), host.baseCommit, true, "why", Instant.EPOCH)); + + assertEquals(SectionStates.Staleness.FRESH, + sections.stateOf(board, board.sections().get(0)).staleness()); + } + + /** + * A recheck cannot invent staleness where the base never moved. The + * agent's answer is consulted only once the verdict is already stale + * against the current base -- it widens what counts as a move that + * matters, it does not decide that one happened. + */ + @Test + void anAgentsAffectedRecheckCannotStaleAVerdictAgainstTheCurrentBase() { + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of(GUARDS_H))); + SectionStates.Board board = overlapping(); + approve(GUARDS_H); + assess(GUARDS_H, true, host.baseCommit); + + assertEquals(SectionStates.Staleness.FRESH, + sections.stateOf(board, board.sections().get(0)).staleness()); + } + + /** One hunk known to have moved is the strongest thing true of the section. */ + @Test + void aKnownMoveOutranksAnUnknownOne() { + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of(GUARDS_CPP))); + SectionStates.Board board = overlapping(); + record(GUARDS_H, ReviewVerdict.Decision.APPROVED, "0".repeat(40)); + record(GUARDS_CPP, ReviewVerdict.Decision.APPROVED, "0".repeat(40)); + + assertEquals(SectionStates.Staleness.MOVED, + sections.stateOf(board, board.sections().get(0)).staleness()); + } + + /** + * The half Task 5 deferred: a base commit touching a file this section + * does not change but DOES reference can have moved the ground under an + * approval, and only the change graph -- when already in hand -- makes + * that visible (spec §9.2). Section-1 here names only Profiler.java; + * the base move touches only Guards.java, which Profiler.java + * references. Without the graph's widening this reads FRESH -- the + * scope's own files never touch Guards.java at all. + */ + @Test + void aBaseMoveTouchingAReferencedButUnchangedFileIsMoved() { + UnifiedDiff graphDiff = new UnifiedDiff(List.of( + file("src/Guards.java", "class JmpCtxScope { }"), + file("src/Profiler.java", "void go() { new JmpCtxScope(); }"))); + ChangeGraph graph = ChangeGraph.of(graphDiff); + SectionStates.Board board = new SectionStates.Board(scope, graphDiff, + List.of(section("section-1", "src/Profiler.java")), Optional.of(graph)); + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of("src/Guards.java"))); + record(graphDiff, "src/Profiler.java", ReviewVerdict.Decision.APPROVED, "0".repeat(40)); + + assertEquals(SectionStates.Staleness.MOVED, + sections.stateOf(board, board.sections().get(0)).staleness()); + } + + // ---- a grouping that drifted off the diff -------------------------------- + + /** + * Hunk ids are positional ({@code h__}), so an agent's + * grouping can name hunks a later diff does not have. Such a section can + * never be settled: counting it toward progress refuses Submit forever. + */ + @Test + void aSectionWhoseHunksLeftTheDiffIsAdriftNotUnread() { + SectionStates.Board board = board(List.of( + section("section-1", GUARDS_H), + new ReviewIntent("section-2", 2, "Profiler", ReviewIntent.Kind.CHANGE, + ReviewIntent.Risk.MED, "", List.of(ReviewIntent.hunkId(PROFILER, 7)), + Optional.empty(), false))); + + SectionStates.SectionState adrift = sections.stateOf(board, board.sections().get(1)); + assertTrue(adrift.hunksMissing()); + assertEquals(0, adrift.totalHunks()); + assertEquals(List.of("section-1"), + sections.counted(board).stream().map(ReviewIntent::id).toList()); + assertFalse(sections.hasResolvableHunks(board, board.sections().get(1))); + } + + /** An intent naming no hunks at all covers the whole diff -- it is not adrift. */ + @Test + void anIntentNamingNoHunksCoversEverything() { + SectionStates.Board board = board(List.of(new ReviewIntent("everything", 1, "All", + ReviewIntent.Kind.CHANGE, ReviewIntent.Risk.MED, "", List.of(), + Optional.empty(), false))); + + assertEquals(3, sections.digestsOf(board, board.sections().get(0)).size()); + assertFalse(sections.stateOf(board, board.sections().get(0)).hunksMissing()); + } + + /** A collapsed section is not counted: the point of the collapse is nothing to read. */ + @Test + void aCollapsedSectionIsNotCounted() { + ReviewIntent collapsed = new ReviewIntent("collapsed", 2, "Rename", + ReviewIntent.Kind.MOVE, ReviewIntent.Risk.NONE, "", + List.of(ReviewIntent.hunkId(PROFILER, 0)), + Optional.of(new ReviewIntent.Collapse("pure rename", "git -M", 1, 1)), false); + SectionStates.Board board = board(List.of(section("section-1", GUARDS_H), collapsed)); + + assertEquals(List.of("section-1"), + sections.counted(board).stream().map(ReviewIntent::id).toList()); + assertEquals(1, sections.distinctDigests(board).size()); + } + + // ---- the digest memo ----------------------------------------------------- + + /** + * A reviewer may re-issue the same id over DIFFERENT hunks. A memo keyed + * by the id would answer with the hunks of a grouping that no longer + * exists. + */ + @Test + void reIssuingAnIdOverDifferentHunksIsNotServedFromTheMemo() { + SectionStates.Board first = board(List.of(section("s", GUARDS_H))); + assertEquals(List.of(digestOf(GUARDS_H)), sections.digestsOf(first, first.sections().get(0))); + + SectionStates.Board second = board(List.of(section("s", PROFILER))); + assertEquals(List.of(digestOf(PROFILER)), + sections.digestsOf(second, second.sections().get(0))); + } + + @Test + void sectionMarksAreCircledUpToTwentyThenPlain() { + assertEquals("①", SectionStates.sectionMark(1)); + assertEquals("⑳", SectionStates.sectionMark(20)); + assertEquals("#21", SectionStates.sectionMark(21)); + } + + // ---- what a/r/u act on (spec §9.6) ---------------------------------------- + + /** The anchor hunk is the FIRST one named, matching where the diff column scrolls to. */ + @Test + void digestOfAnchorHunkIsTheFirstHunkNamed() { + SectionStates.Board board = overlapping(); + ReviewIntent section1 = board.sections().get(0); + + assertEquals(Optional.of(digestOf(GUARDS_H)), sections.digestOfAnchorHunk(board, section1)); + } + + @Test + void digestOfAnchorHunkIsEmptyForAnUnresolvableSection() { + SectionStates.Board board = board(List.of(section("adrift", "src/gone.cpp"))); + + assertTrue(sections.digestOfAnchorHunk(board, board.sections().get(0)).isEmpty()); + } + + @Test + void currentFileOfIsTheAnchorHunksFileWhenNothingIsSelected() { + SectionStates.Board board = overlapping(); + ReviewIntent section2 = board.sections().get(1); + + assertEquals(Optional.of(GUARDS_H), sections.currentFileOf(board, section2, Optional.empty())); + } + + /** + * An intent naming no hunks at all covers the whole diff (see {@link + * ReviewIntent#containsHunk}); the anchor-file fallback inside {@link + * SectionStates#currentFileOf} falls back further, to the first file of + * the diff, rather than answering nothing. + */ + @Test + void currentFileOfFallsBackToTheDiffsFirstFileWhenTheSectionNamesNone() { + SectionStates.Board board = board(List.of( + new ReviewIntent("whole-diff", 1, "Everything", ReviewIntent.Kind.CHANGE, + ReviewIntent.Risk.MED, "", List.of(), Optional.empty(), false))); + + assertEquals(Optional.of(GUARDS_H), + sections.currentFileOf(board, board.sections().get(0), Optional.empty())); + } + + /** A gutter selection wins over the section's own anchor file. */ + @Test + void currentFileOfPrefersTheGutterSelectionOverTheAnchor() { + SectionStates.Board board = overlapping(); + ReviewIntent section1 = board.sections().get(0); + String selectionKey = GUARDS_CPP + " n1"; + + assertEquals(Optional.of(GUARDS_CPP), + sections.currentFileOf(board, section1, Optional.of(selectionKey))); + } + + /** A gutter selection resolves to the hunk containing that exact line. */ + @Test + void digestOfCurrentHunkPrefersTheGutterSelection() { + SectionStates.Board board = overlapping(); + ReviewIntent section1 = board.sections().get(0); + String selectionKey = GUARDS_CPP + " n1"; + + assertEquals(Optional.of(digestOf(GUARDS_CPP)), + sections.digestOfCurrentHunk(board, section1, Optional.of(selectionKey))); + } + + /** + * With nothing selected, HUNK mode must not always answer hunk one: + * with the anchor hunk already settled, the next press has to reach + * the section's first UNSETTLED hunk, or a reader who never opens the + * gutter composer could never approve anything past the first hunk. + */ + @Test + void digestOfCurrentHunkFallsBackToTheFirstUnsettledHunk() { + SectionStates.Board board = overlapping(); + ReviewIntent section1 = board.sections().get(0); + approve(GUARDS_H); + + assertEquals(Optional.of(digestOf(GUARDS_CPP)), + sections.digestOfCurrentHunk(board, section1, Optional.empty())); + } + + /** Once every hunk is settled, the anchor is the last fallback left. */ + @Test + void digestOfCurrentHunkFallsBackToTheAnchorWhenEverythingIsSettled() { + SectionStates.Board board = overlapping(); + ReviewIntent section1 = board.sections().get(0); + approve(GUARDS_H); + approve(GUARDS_CPP); + + assertEquals(Optional.of(digestOf(GUARDS_H)), + sections.digestOfCurrentHunk(board, section1, Optional.empty())); + } + + /** A stale key -- selected line no longer in the diff -- is not trusted; the walk continues. */ + @Test + void digestOfCurrentHunkIgnoresASelectionTheDiffNoLongerHas() { + SectionStates.Board board = overlapping(); + ReviewIntent section1 = board.sections().get(0); + + assertEquals(Optional.of(digestOf(GUARDS_H)), + sections.digestOfCurrentHunk(board, section1, Optional.of(GUARDS_H + " n999"))); + } + + // ---- what a/r/u act on does not count as settled while stale (spec §9.2) -- + + @Test + void settledHunkCountExcludesAStaleVerdict() { + SectionStates.Board board = overlapping(); + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of(GUARDS_H))); + record(GUARDS_H, ReviewVerdict.Decision.APPROVED, "0".repeat(40)); + approve(GUARDS_CPP); + approve(PROFILER); + + assertEquals(2, sections.settledHunkCount(board), + "the stale GUARDS_H verdict must not count toward progress"); + } + + /** + * {@code ⇧A}/{@code ⇧R} settle every hunk of the file across the WHOLE + * diff -- not just the hunks the current section happens to name. + */ + @Test + void digestsOfFileCoversEveryHunkOfTheFileRegardlessOfSection() { + SectionStates.Board board = overlapping(); + + assertEquals(List.of(digestOf(GUARDS_H)), sections.digestsOfFile(board, GUARDS_H)); + } + + @Test + void digestsOfFileIsEmptyForAFileNotInTheDiff() { + SectionStates.Board board = overlapping(); + + assertTrue(sections.digestsOfFile(board, "src/nowhere.cpp").isEmpty()); + } + + @Test + void digestsForActionInHunkModeIsJustTheOneHunk() { + SectionStates.Board board = overlapping(); + ReviewIntent section1 = board.sections().get(0); + + assertEquals(List.of(digestOf(GUARDS_H)), sections.digestsForAction( + board, section1, SessionReviewView.SettleUnit.HUNK, false, Optional.empty())); + } + + @Test + void digestsForActionInSectionModeIsEveryHunkTheSectionNames() { + SectionStates.Board board = overlapping(); + ReviewIntent section1 = board.sections().get(0); + + assertEquals(List.of(digestOf(GUARDS_H), digestOf(GUARDS_CPP)), sections.digestsForAction( + board, section1, SessionReviewView.SettleUnit.SECTION, false, Optional.empty())); + } + + /** {@code wholeFile} wins over the unit even in HUNK mode -- ⇧A/⇧R always mean the file. */ + @Test + void digestsForActionWithWholeFileIgnoresTheUnit() { + SectionStates.Board board = overlapping(); + ReviewIntent section1 = board.sections().get(0); + + assertEquals(List.of(digestOf(GUARDS_H)), sections.digestsForAction( + board, section1, SessionReviewView.SettleUnit.HUNK, true, Optional.empty())); + } + + // ---- whose judgement the staleness is (spec §9.7 / §6.5) ---------------- + + /** + * Spec §9.7: "Assessments render as CLAIMED, not measured." A move the + * file-level filter found is drydock's own measurement. + */ + @Test + void aMoveTheFilterFoundIsMeasured() { + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of(GUARDS_H))); + SectionStates.Board board = overlapping(); + record(GUARDS_H, ReviewVerdict.Decision.APPROVED, "0".repeat(40)); + + SectionStates.SectionState state = sections.stateOf(board, board.sections().get(0)); + + assertEquals(SectionStates.Staleness.MOVED, state.staleness()); + assertEquals(Provenance.MEASURED, state.stalenessProvenance()); + } + + /** + * The case §6.5 exists for: the filter dismissed this move, and only the + * AGENT's assertion makes it stale. Rendered identically to a measured + * move, a reviewer could not tell whose judgement they were trusting. + */ + @Test + void aMoveOnlyTheAgentCallsAffectedIsClaimed() { + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of("docs/README.md"))); + SectionStates.Board board = overlapping(); + record(GUARDS_H, ReviewVerdict.Decision.APPROVED, "0".repeat(40)); + assess(GUARDS_H, true, "0".repeat(40)); + + SectionStates.SectionState state = sections.stateOf(board, board.sections().get(0)); + + assertEquals(SectionStates.Staleness.MOVED, state.staleness()); + assertEquals(Provenance.CLAIMED, state.stalenessProvenance()); + } + + /** An "unaffected" assessment is advice and changes no warrant. */ + @Test + void anUnaffectedAssessmentLeavesTheWarrantMeasured() { + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of(GUARDS_H))); + SectionStates.Board board = overlapping(); + record(GUARDS_H, ReviewVerdict.Decision.APPROVED, "0".repeat(40)); + assess(GUARDS_H, false, "0".repeat(40)); + + SectionStates.SectionState state = sections.stateOf(board, board.sections().get(0)); + + assertEquals(SectionStates.Staleness.MOVED, state.staleness()); + assertEquals(Provenance.MEASURED, state.stalenessProvenance(), + "the filter found this move; the agent's advice did not"); + } + + // ---- the automatic recheck a base move earns (spec §9.7) ---------------- + + /** + * A move that stales an approval asks the agent about it, naming the base + * PAIR the approval was recorded against and the base it now faces. + */ + @Test + void aBaseMoveThatStalesAnApprovalAsksTheAgentOnce() { + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of(GUARDS_H))); + SectionStates.Board board = overlapping(); + record(GUARDS_H, ReviewVerdict.Decision.APPROVED, "0".repeat(40)); + + sections.requestRechecks(board, new RecheckDispatch()); + + assertEquals(List.of("0".repeat(40) + "->" + host.baseCommit), host.recheckDispatches); + } + + /** + * The window the store cannot see. Between the dispatch + * and the agent's first {@code review_recheck} there is no assessment, and + * {@code assessedAffected} reads exactly the same as never having asked. + * A board re-renders whenever a background git answer lands, so a guard + * built on the store alone would send a subagent per render. + */ + @Test + void aSecondRenderInsideTheSameMoveDoesNotAskAgain() { + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of(GUARDS_H))); + SectionStates.Board board = overlapping(); + record(GUARDS_H, ReviewVerdict.Decision.APPROVED, "0".repeat(40)); + RecheckDispatch dispatch = new RecheckDispatch(); + + sections.requestRechecks(board, dispatch); + sections.requestRechecks(board, dispatch); + + assertEquals(1, host.recheckDispatches.size(), + "no assessment has arrived yet, and that must not read as 'never asked'"); + } + + /** + * A hand-off that did not happen must not be remembered as done: the send + * reached no terminal, and no human is present to notice the silence. + */ + @Test + void aRecheckWhoseHandOffFailedIsAskedAgainOnTheNextRender() { + host.recheckHandOffSucceeds = false; + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of(GUARDS_H))); + SectionStates.Board board = overlapping(); + record(GUARDS_H, ReviewVerdict.Decision.APPROVED, "0".repeat(40)); + RecheckDispatch dispatch = new RecheckDispatch(); + + sections.requestRechecks(board, dispatch); + sections.requestRechecks(board, dispatch); + + assertEquals(2, host.recheckDispatches.size()); + } + + /** + * Relevance-gated: a move touching nothing this scope reads leaves every + * section FRESH, and a fresh section has no disturbed approval to ask + * about. Without this every base move spends a subagent. + */ + @Test + void aMoveThatCouldNotMatterAsksNothing() { + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of("docs/README.md"))); + SectionStates.Board board = overlapping(); + record(GUARDS_H, ReviewVerdict.Decision.APPROVED, "0".repeat(40)); + + sections.requestRechecks(board, new RecheckDispatch()); + + assertTrue(host.recheckDispatches.isEmpty()); + } + + /** + * The relevance gate, for real. The production host + * returns an UNRESOLVABLE delta on the FIRST call for any base pair -- + * it spawns the git off-thread and answers later -- and that renders as + * UNKNOWN, not FRESH. Gating on "not FRESH" therefore dispatched on the + * very render that discovers the move, before couldMatter had answered + * anything, and the claim is permanent. Only MOVED means "the move could + * matter"; UNKNOWN means "ask again once git has spoken". + */ + @Test + void aMoveNobodyCanResolveYetAsksNothing() { + host.baseDelta = new BaseMove.Delta(true, new TreeSet<>()); + SectionStates.Board board = overlapping(); + record(GUARDS_H, ReviewVerdict.Decision.APPROVED, "0".repeat(40)); + + sections.requestRechecks(board, new RecheckDispatch()); + + assertTrue(host.recheckDispatches.isEmpty(), + "an unanswered question is not a reason to spend an agent"); + } + + /** + * "unresolved" is not a revision. The guard exists for the CURRENT base + * forty lines from where the recorded one is read, and a verdict can + * carry it too -- baselineOf returns the sentinel while git is still + * answering and permanently when resolveRef fails. + */ + @Test + void aVerdictRecordedAgainstAnUnresolvedBaseAsksNothing() { + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of(GUARDS_H))); + SectionStates.Board board = overlapping(); + record(GUARDS_H, ReviewVerdict.Decision.APPROVED, SessionReviewView.UNRESOLVED_BASE); + + sections.requestRechecks(board, new RecheckDispatch()); + + assertTrue(host.recheckDispatches.isEmpty(), + "no agent can read what changed between 'unresolved' and a commit"); + } + + /** The mirror: an unresolved CURRENT base names no pair either. */ + @Test + void anUnresolvedCurrentBaseAsksNothing() { + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of(GUARDS_H))); + SectionStates.Board board = overlapping(); + record(GUARDS_H, ReviewVerdict.Decision.APPROVED, "0".repeat(40)); + host.baseCommit = SessionReviewView.UNRESOLVED_BASE; + + sections.requestRechecks(board, new RecheckDispatch()); + + assertTrue(host.recheckDispatches.isEmpty()); + } + + /** + * Relevance is per approval, not per section. Two + * approvals in ONE section, recorded at different bases: one move is + * resolved and could matter, the other is still in flight. Gating on the + * section alone let the resolved one drag the unresolved one into the + * dispatch -- asking the agent about a move before git had said whether + * it mattered, with the claim permanent. + */ + @Test + void aNeighbourWhoseMoveIsStillInFlightIsNotDraggedIn() { + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of(GUARDS_H, GUARDS_CPP))); + host.baseDeltaByRecordedBase.put("9".repeat(40), new BaseMove.Delta(true, new TreeSet<>())); + SectionStates.Board board = overlapping(); + record(GUARDS_H, ReviewVerdict.Decision.APPROVED, "0".repeat(40)); + record(GUARDS_CPP, ReviewVerdict.Decision.APPROVED, "9".repeat(40)); + + sections.requestRechecks(board, new RecheckDispatch()); + + assertEquals(List.of("0".repeat(40) + "->" + host.baseCommit), host.recheckDispatches, + "only the move git has actually answered for earns a recheck"); + } + + /** The same, for a neighbour whose move is RESOLVED and provably irrelevant. */ + @Test + void aNeighbourWhoseMoveCouldNotMatterIsNotDraggedIn() { + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of(GUARDS_H, GUARDS_CPP))); + host.baseDeltaByRecordedBase.put("9".repeat(40), + new BaseMove.Delta(false, new TreeSet<>(List.of("docs/README.md")))); + SectionStates.Board board = overlapping(); + record(GUARDS_H, ReviewVerdict.Decision.APPROVED, "0".repeat(40)); + record(GUARDS_CPP, ReviewVerdict.Decision.APPROVED, "9".repeat(40)); + + sections.requestRechecks(board, new RecheckDispatch()); + + assertEquals(List.of("0".repeat(40) + "->" + host.baseCommit), host.recheckDispatches, + "a move touching only docs is exactly what the filter exists to drop"); + } + + /** + * Two approvals recorded at two DIFFERENT older bases are two distinct + * questions, and the loop has to emit both. Every other test here has at + * most one stale base, so the loop was only ever exercised emitting one. + */ + @Test + void twoApprovalsAtDifferentOlderBasesEachEarnTheirOwnRecheck() { + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of(GUARDS_H, GUARDS_CPP))); + SectionStates.Board board = overlapping(); + record(GUARDS_H, ReviewVerdict.Decision.APPROVED, "0".repeat(40)); + record(GUARDS_CPP, ReviewVerdict.Decision.APPROVED, "9".repeat(40)); + + sections.requestRechecks(board, new RecheckDispatch()); + + assertEquals(List.of("0".repeat(40) + "->" + host.baseCommit, + "9".repeat(40) + "->" + host.baseCommit), + host.recheckDispatches); + } + + /** + * Spec §9.7: "inline harnesses simply do not get one". Only a harness + * that can run the recheck in a subagent is asked automatically -- an + * inline agent would have an unrequested prompt typed into whatever it + * was doing, with no human present to have asked for it. + */ + @Test + void aHarnessWithoutSubagentsIsNeverAskedAutomatically() { + host.supportsAutomaticRecheck = false; + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of(GUARDS_H))); + SectionStates.Board board = overlapping(); + record(GUARDS_H, ReviewVerdict.Decision.APPROVED, "0".repeat(40)); + + sections.requestRechecks(board, new RecheckDispatch()); + + assertTrue(host.recheckDispatches.isEmpty()); + } + + /** + * The in-memory claim dies with the view; the stale mark outlives it. An + * answer already in the store is what stops a restart re-asking the same + * question forever. + */ + @Test + void aMoveTheAgentHasAlreadyAnsweredIsNotAskedAgain() { + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of(GUARDS_H))); + SectionStates.Board board = overlapping(); + record(GUARDS_H, ReviewVerdict.Decision.APPROVED, "0".repeat(40)); + assess(GUARDS_H, false, "0".repeat(40)); + + sections.requestRechecks(board, new RecheckDispatch()); + + assertTrue(host.recheckDispatches.isEmpty(), + "the answer is already on disk; a fresh RecheckDispatch must not re-ask"); + } + + /** The instruction says "for each approved hunk"; a CHANGES verdict is not one. */ + @Test + void aRequestedChangesVerdictEarnsNoRecheck() { + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of(GUARDS_H))); + SectionStates.Board board = overlapping(); + record(GUARDS_H, ReviewVerdict.Decision.CHANGES, "0".repeat(40)); + + sections.requestRechecks(board, new RecheckDispatch()); + + assertTrue(host.recheckDispatches.isEmpty()); + } + + /** + * One {@link RecheckDispatch} serves every scope the view shows -- it is a + * single field for the life of the view. A claim keyed by anything less + * than the scope would let one scope's move permanently silence another's + * identical one. {@code RecheckDispatchTest} proves the SET discriminates + * on scope; only this proves the CALLER supplies it. + */ + @Test + void oneDispatchMemoryServesTwoScopesIndependently() { + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of(GUARDS_H))); + RecheckDispatch shared = new RecheckDispatch(); + SectionStates.Board first = overlapping(); + record(GUARDS_H, ReviewVerdict.Decision.APPROVED, "0".repeat(40)); + sections.requestRechecks(first, shared); + assertEquals(1, host.recheckDispatches.size(), "precondition"); + + // A DIFFERENT identity, or ReviewScopeRegistry.mint hands back the + // same scope: it does computeIfAbsent on (kind, roots, refs), so a + // spec equal to an existing one is the same handle, not a new one. + ReviewScope other = registry.mint(ReviewScopeRegistry.spec( + ReviewScope.Kind.WORKING_TREE, Path.of("/tmp/elsewhere"), + Optional.of(Path.of("/tmp/elsewhere")), "main", "main", + Optional.empty(), Optional.empty())); + assertNotEquals(scope.id(), other.id(), "precondition: two distinct scopes"); + host.store.putVerdict(new ReviewVerdict(other.id(), digestOf(GUARDS_H), + ReviewVerdict.Decision.APPROVED, Optional.empty(), Instant.EPOCH, + "0".repeat(40), host.headCommit)); + SectionStates.Board second = new SectionStates.Board(other, diff, first.sections()); + + sections.requestRechecks(second, shared); + + assertEquals(2, host.recheckDispatches.size(), + "a different scope's identical base move is its own question"); + } + + /** + * Only the approvals the move actually staled are asked about. A section + * can hold one stale hunk and one approved against the CURRENT base; + * taking every verdict in a non-FRESH section would ask the agent to read + * what changed between a base and itself -- a subagent spent on an empty + * diff. + */ + @Test + void aFreshApprovalSharingAStaleSectionIsNotAskedAbout() { + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of(GUARDS_H, GUARDS_CPP))); + SectionStates.Board board = overlapping(); + record(GUARDS_H, ReviewVerdict.Decision.APPROVED, "0".repeat(40)); + record(GUARDS_CPP, ReviewVerdict.Decision.APPROVED, host.baseCommit); + + sections.requestRechecks(board, new RecheckDispatch()); + + assertEquals(List.of("0".repeat(40) + "->" + host.baseCommit), host.recheckDispatches, + "a verdict already recorded against the current base has not moved"); + } + + /** + * No approval, nothing staled, nothing to ask. Paired with a positive + * control: on its own this passes against an EMPTY method body, so it + * pins nothing until the same fixture is shown to dispatch once a verdict + * exists. + */ + @Test + void aScopeWithNoRecordedApprovalAsksNothing() { + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of(GUARDS_H))); + SectionStates.Board board = overlapping(); + + sections.requestRechecks(board, new RecheckDispatch()); + assertTrue(host.recheckDispatches.isEmpty()); + + record(GUARDS_H, ReviewVerdict.Decision.APPROVED, "0".repeat(40)); + sections.requestRechecks(board, new RecheckDispatch()); + assertEquals(1, host.recheckDispatches.size(), + "positive control: the same fixture DOES ask once an approval exists"); + } + + // ---- helpers ------------------------------------------------------------- + + /** Section ① covers both guards files; section ② covers guards.h again and profiler. */ + private SectionStates.Board overlapping() { + return board(List.of( + section("section-1", GUARDS_H, GUARDS_CPP), + section("section-2", GUARDS_H, PROFILER))); + } + + private SectionStates.Board board(List grouping) { + List numbered = new ArrayList<>(); + int number = 1; + for (ReviewIntent intent : grouping) { + numbered.add(new ReviewIntent(intent.id(), number++, intent.title(), intent.kind(), + intent.risk(), intent.rationale(), intent.hunkIds(), intent.collapse(), + intent.autoApprove())); + } + return new SectionStates.Board(scope, diff, numbered); + } + + private static ReviewIntent section(String id, String... files) { + List hunkIds = new ArrayList<>(); + for (String file : files) { + hunkIds.add(ReviewIntent.hunkId(file, 0)); + } + return new ReviewIntent(id, 0, id, ReviewIntent.Kind.CHANGE, ReviewIntent.Risk.MED, + "", hunkIds, Optional.empty(), false); + } + + private void approve(String file) { + record(file, ReviewVerdict.Decision.APPROVED, host.baseCommit); + } + + /** + * An agent's recheck of the move from {@code fromBase} to the scope's + * current base, as {@code review_recheck} records one -- against the + * hunk's content DIGEST, which is the only thing the board ever looks a + * recheck up by. + */ + private void assess(String file, boolean affected, String fromBase) { + host.store.putAssessment(new app.drydock.review.RecheckAssessment(scope.id(), + digestOf(file), fromBase, host.baseCommit, affected, "why", Instant.EPOCH)); + } + + private void record(String file, ReviewVerdict.Decision decision, String base) { + record(diff, file, decision, base); + } + + /** As {@link #record(String, ReviewVerdict.Decision, String)}, over a diff other than the fixture's. */ + private void record(UnifiedDiff source, String file, ReviewVerdict.Decision decision, String base) { + host.store.putVerdict(new ReviewVerdict(scope.id(), digestOf(source, file), decision, + Optional.empty(), Instant.EPOCH, base, host.headCommit)); + } + + private String digestOf(String file) { + return digestOf(diff, file); + } + + private static String digestOf(UnifiedDiff source, String file) { + return source.files().stream() + .filter(candidate -> candidate.path().equals(file)) + .findFirst() + .map(candidate -> HunkDigest.of(file, candidate.hunks().get(0))) + .orElseThrow(); + } + + private static UnifiedDiff.FileDiff file(String path, String text) { + return new UnifiedDiff.FileDiff(path, "M", 1, 0, false, false, List.of( + new UnifiedDiff.Hunk("@@ -1 +1 @@", List.of( + new UnifiedDiff.Line(UnifiedDiff.Line.Kind.ADD, OptionalInt.empty(), + OptionalInt.of(1), text))))); + } +} diff --git a/app/src/test/java/app/drydock/ui/review/SessionReviewViewCloseTest.java b/app/src/test/java/app/drydock/ui/review/SessionReviewViewCloseTest.java new file mode 100644 index 00000000..e5ade0a9 --- /dev/null +++ b/app/src/test/java/app/drydock/ui/review/SessionReviewViewCloseTest.java @@ -0,0 +1,121 @@ +package app.drydock.ui.review; + +import app.drydock.ui.TestStages; +import app.drydock.git.DiffService; +import app.drydock.git.UnifiedDiff; +import app.drydock.review.ReviewIntent; +import app.drydock.review.ReviewScope; +import app.drydock.review.ReviewScopeRegistry; +import app.drydock.review.SessionReviewScopes; + +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.stage.Stage; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.testfx.framework.junit5.ApplicationTest; +import org.testfx.util.WaitForAsyncUtils; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; +import java.util.OptionalInt; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * {@code close()}'s one new job (coordinator's review): detaching {@link + * SessionReviewView}'s focus-owner listener from the Scene. The Scene is + * app-lifetime ({@code AppShell} builds one for the whole application), so a + * listener left on it after this view is done keeps the WHOLE view -- + * diff column included -- strongly reachable for the process's life, and + * re-renders its verdict bar on every focus change anywhere in the app, for + * every session ever closed. + * + *

Kept in its own class with its own Stage, rather than folded into + * {@code ReviewSettleActionsTest}'s shared fixture: calling {@code close()} + * permanently detaches the listener from that Scene for the rest of the + * class's lifetime, and JUnit does not guarantee test order within a class + * -- doing it against a SHARED view would intermittently break every other + * test in that class depending on which happened to run first.

+ */ +class SessionReviewViewCloseTest extends ApplicationTest { + + private DiffService diffService; + private FakeReviewHost host; + private SessionReviewView view; + + @Override + public void start(Stage stage) throws IOException { + diffService = new DiffService(); + host = new FakeReviewHost(Files.createTempDirectory("drydock-close") + .resolve("annotations.json")); + host.diff = new UnifiedDiff(List.of(file("src/a.java", "void foo();"))); + ReviewScopeRegistry registry = new ReviewScopeRegistry(); + ReviewScope scope = registry.mint(ReviewScopeRegistry.spec(ReviewScope.Kind.WORKING_TREE, + Path.of("/tmp/nowhere"), Optional.of(Path.of("/tmp/nowhere")), "main", "main", + Optional.empty(), Optional.empty())); + host.intents.set(scope.id(), List.of( + new ReviewIntent("section-1", 0, "A", ReviewIntent.Kind.CHANGE, + ReviewIntent.Risk.MED, "", List.of(ReviewIntent.hunkId("src/a.java", 0)), + Optional.empty(), false))); + view = new SessionReviewView(host, diffService, null); + Scene scene = new Scene(view, 1400, 900); + scene.getStylesheets().addAll( + getClass().getResource("/app/drydock/ui/app.css").toExternalForm(), + getClass().getResource("/app/drydock/ui/theme-dark.css").toExternalForm()); + TestStages.show(stage, scene); + interact(() -> view.showScopes(new SessionReviewScopes.Scopes(scope, Optional.empty()), + SessionReviewScopes.Choice.LOCAL)); + interact(() -> view.diagShowDiff(scope, host.diff)); + WaitForAsyncUtils.waitForFxEvents(); + } + + @AfterEach + void tearDown() { + diffService.close(); + host.store.close(); + } + + /** + * Verified indirectly, since {@code ObservableValue} exposes no way to + * count or inspect its listeners: after {@code close()}, a later real + * focus change into the diff column must no longer move the Approve + * button's label off "(section)". + */ + @Test + void closeStopsTheBarFromReactingToLaterFocusChanges() { + clickOn(".review-intent-card"); + WaitForAsyncUtils.waitForFxEvents(); + assertEquals("Approve (section)", approveButtonText()); + + interact(view::close); + clickOn(".review-diff-cell"); + WaitForAsyncUtils.waitForFxEvents(); + + assertEquals("Approve (section)", approveButtonText(), + "close() must detach the focus listener so a later focus change no longer " + + "re-renders this view's verdict bar"); + } + + private String approveButtonText() { + String[] text = new String[1]; + interact(() -> text[0] = lookup(".review-verdict-action").queryAll().stream() + .map(Button.class::cast) + .map(Button::getText) + .filter(t -> t.startsWith("Approve (")) + .findFirst() + .orElse("")); + return text[0]; + } + + private static UnifiedDiff.FileDiff file(String path, String text) { + return new UnifiedDiff.FileDiff(path, "M", 1, 0, false, false, List.of( + new UnifiedDiff.Hunk("@@ -1 +1 @@", List.of( + new UnifiedDiff.Line(UnifiedDiff.Line.Kind.ADD, OptionalInt.empty(), + OptionalInt.of(1), text))))); + } +} diff --git a/app/src/test/java/app/drydock/ui/review/SessionReviewViewTest.java b/app/src/test/java/app/drydock/ui/review/SessionReviewViewTest.java index d44274d7..0eebef7e 100644 --- a/app/src/test/java/app/drydock/ui/review/SessionReviewViewTest.java +++ b/app/src/test/java/app/drydock/ui/review/SessionReviewViewTest.java @@ -1,5 +1,6 @@ package app.drydock.ui.review; +import app.drydock.ui.TestStages; import app.drydock.git.DiffService; import app.drydock.git.UnifiedDiff; import app.drydock.review.ReviewScope; @@ -97,8 +98,7 @@ public void start(Stage stage) { scene.getStylesheets().addAll( getClass().getResource("/app/drydock/ui/app.css").toExternalForm(), getClass().getResource("/app/drydock/ui/theme-dark.css").toExternalForm()); - stage.setScene(scene); - stage.show(); + TestStages.show(stage, scene); } @Test @@ -278,6 +278,14 @@ void theExplorerJumpStillWorksOnARestoredScope() { SessionReviewScopes.Choice.LOCAL); view.diagSelectChoice(SessionReviewScopes.Choice.PULL_REQUEST); view.diagSelectChoice(SessionReviewScopes.Choice.LOCAL); + // The virtualized ListView lays out its cells on the pulse AFTER the + // scope switch, not synchronously within it -- under a full suite run + // (other tests' own background section-graph builds competing for + // the same CPU, see SessionReviewView#requestGraph) that pulse can + // land late enough that the lookup below races an empty cell list. + // Every other diff-driven lookup in this suite already pumps events + // first; this one predates that convention. + WaitForAsyncUtils.waitForFxEvents(); // fire() rather than clickOn(): the button lives inside a virtualized // ListView cell, so the robot's hit test depends on where the list diff --git a/docs/superpowers/plans/2026-08-22-review-navigation.md b/docs/superpowers/plans/2026-08-22-review-navigation.md new file mode 100644 index 00000000..10ad2b12 --- /dev/null +++ b/docs/superpowers/plans/2026-08-22-review-navigation.md @@ -0,0 +1,4593 @@ +# Review Navigation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make Drydock's Review board group a change by its own structure, order it foundation-first, link related hunks, and key the human's approvals to content rather than to a grouping. + +**Architecture:** One in-memory `ChangeGraph` over the scope's diff (changed symbols as nodes, references as edges) feeds four consumers — the section grouping, the reading path, the out-of-diff caller popover, and the base-move relevance filter. Reviewed state moves off the section and onto the hunk, keyed by a digest of its content, so sections may overlap freely and an agent may regroup without destroying the human's work. Parsing is tree-sitter where a grammar is loaded and lexical everywhere else, behind one interface with one edge-matching rule. + +**Tech Stack:** Java 26, JavaFX 26, JUnit 5 + TestFX/Monocle (headless), Gradle. New runtime dependencies: `io.github.bonede:tree-sitter` and per-language grammar artifacts. No graph library — Kahn and Tarjan are hand-rolled. + +**Spec:** `docs/superpowers/specs/2026-08-22-review-navigation-design.md` + +## Global Constraints + +- **Never block the FX thread.** Graph construction, parsing, native library loading and every `git` spawn run on a background executor; only `Platform.runLater` touches UI. Every user-triggered async op shows progress immediately and clears it on success, failure **and** early return. +- **All process spawns go through `app.drydock.process.ProcessRunner`** — argument list never a shell string, explicit timeout, `--end-of-options` before positional revision/path arguments, and a failed command is never silently equal to an empty result. +- **Determinism is a requirement, not a property.** No `HashMap`/`HashSet` iteration order anywhere in scanning, graph construction, grouping or sorting. `LinkedHashMap`, `LinkedHashSet`, `TreeMap` only. The same diff must produce a byte-identical grouping and reading path across two processes. +- **An agent may never clear a human's approval.** Agent input adds staleness or proposes; it never settles, resolves, or un-stales. +- **Anything advertised in `ShortcutsOverlay` must be bound, and vice versa.** +- **Never inline fully-qualified class names**; use imports. +- **Java toolchain 26**; source encoding UTF-8 (already pinned in `app/build.gradle.kts`). +- Test command shape: `./gradlew :app:test --tests "app.drydock.review.SomeTest"`. The full suite takes 14–20 minutes; always run the targeted subset during a task and the full suite only at a phase boundary. + +## Phases + +The plan is three independently shippable phases with a hard order. + +| Phase | Tasks | Ships | +|---|---|---| +| **1 — Reviewed state moves to the hunk** | 1–7 | Approvals survive a re-diff correctly and sections become free to overlap. Works with today's grouping. | +| **2 — Graph-backed sections** | 8–15 | The rail stops reading `main/cpp · 12 files`. The highest-value phase; needs Phase 1 because §5.2's header conventions produce overlapping membership. | +| **3 — Reading path, links, recheck** | 16–23 | Order, entry points, hunk-to-hunk links, and the agent staleness recheck. | + +**One dependency is deliberately deferred across a phase boundary:** §9.2's relevance filter intersects a base delta against the scope's own files *and* the files declaring symbols its hunks reference. The second half needs the `ChangeGraph`, which is Phase 2. Task 5 implements the first half; Task 15 widens it. This is called out again in both tasks. + +## File Structure + +**New — `app/src/main/java/app/drydock/review/`** + +| File | Responsibility | +|---|---| +| `HunkDigest.java` | The content identity of one hunk: `sha256(path + context + changed lines)`. Pure. | +| `VerdictMerge.java` | Derives a group's decision from its members' — any `CHANGES` wins, `APPROVED` needs all. Extracted from `AnnotationStore` so it is testable without a store. | +| `RecheckAssessment.java` | An agent's statement about whether a base move affects one approved hunk. | +| `BaseMove.java` | Resolves whether a base move can matter: `git diff --name-only`, intersected with the scope's files (Task 5) and its referenced declarations (Task 15). | +| `SymbolScan.java` | One file's declarations and uses. Two implementations behind it: tree-sitter and lexical. | +| `GrammarRegistry.java` | Extension → tree-sitter grammar. A missing grammar is the lexical path, not an error. | +| `ChangeGraph.java` | Changed symbols and their references, built from a `UnifiedDiff`. | +| `Graphs.java` | Kahn topological sort and Tarjan SCC, both with a caller-supplied total tie-break. | +| `Sections.java` | Components + header conventions + hub titles + dependency order. Sections may overlap. | +| `ReadingPath.java` | Order, entry points and links over a `ChangeGraph`. | +| `OutOfDiffFanIn.java` | One bounded `git grep -n -F -f`, kept with its locations. | + +**Modified** + +| File | Change | +|---|---| +| `review/ReviewVerdict.java` | Keyed by `(scopeId, hunkDigest)`; carries the `(base, head)` it was given against. | +| `review/AnnotationStore.java` | Verdicts stored per hunk digest; `migrateLegacyVerdicts` deleted; assessments persisted. | +| `review/FallbackIntents.java` | Graph-backed grouping, with today's (kind, directory) clustering as its own fallback. | +| `review/ReviewIntent.java` | `reads` field. | +| `ui/review/ReviewVerdictBar.java` | Progress in hunks; acting unit named; stale banner and submit refusal. | +| `ui/review/ReviewIntentRail.java` | Derived section state, `✓ reviewed in ①` markers, PATH mode. | +| `ui/review/ReviewDiffColumn.java` | Link footer rows; caller popover. | +| `ui/review/SessionReviewView.java` | Wiring, focus-scoped settle actions. | +| `mcp/ReviewToolCodec.java`, `mcp/McpToolRouter.java` | `reads`, `sections` include, `review_recheck`. | +| `ui/ShortcutsOverlay.java` | `p`, `⇧A`, `⇧R`; `a`/`r`/`u` reworded to name their unit. | +| `app/build.gradle.kts` | tree-sitter core + grammar artifacts. | + +--- + +# Phase 1 — Reviewed state moves to the hunk + +### Task 1: `HunkDigest` — the content identity of a hunk + +**Files:** +- Create: `app/src/main/java/app/drydock/review/HunkDigest.java` +- Test: `app/src/test/java/app/drydock/review/HunkDigestTest.java` + +**Interfaces:** +- Consumes: `app.drydock.git.UnifiedDiff.FileDiff`, `UnifiedDiff.Hunk`, `UnifiedDiff.Line` +- Produces: `static String HunkDigest.of(String path, UnifiedDiff.Hunk hunk)` → 64-char lowercase hex + +- [ ] **Step 1: Write the failing test** + +```java +package app.drydock.review; + +import app.drydock.git.UnifiedDiff; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.OptionalInt; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +/** + * What an approval is pinned to (spec §9.2). A digest that ignores context + * lets an approval stand over code whose surroundings moved; a digest that + * covers the whole file re-reviews hunks nobody touched. These tests pin + * both edges of that window. + */ +class HunkDigestTest { + + private static UnifiedDiff.Line ctx(int line, String text) { + return new UnifiedDiff.Line(UnifiedDiff.Line.Kind.CONTEXT, + OptionalInt.of(line), OptionalInt.of(line), text); + } + + private static UnifiedDiff.Line add(int line, String text) { + return new UnifiedDiff.Line(UnifiedDiff.Line.Kind.ADD, + OptionalInt.empty(), OptionalInt.of(line), text); + } + + private static UnifiedDiff.Hunk hunk(List lines) { + return new UnifiedDiff.Hunk("@@ -1,3 +1,4 @@", lines); + } + + @Test + void theSameContentInTheSamePathDigestsIdentically() { + UnifiedDiff.Hunk left = hunk(List.of(ctx(1, "int a;"), add(2, "int b;"))); + UnifiedDiff.Hunk right = hunk(List.of(ctx(1, "int a;"), add(2, "int b;"))); + + assertEquals(HunkDigest.of("src/a.c", left), HunkDigest.of("src/a.c", right)); + } + + /** A hunk that only moved is the same code, and stays approved. */ + @Test + void movingAHunkWithoutChangingItKeepsTheDigest() { + UnifiedDiff.Hunk before = hunk(List.of(ctx(1, "int a;"), add(2, "int b;"))); + UnifiedDiff.Hunk after = hunk(List.of(ctx(41, "int a;"), add(42, "int b;"))); + + assertEquals(HunkDigest.of("src/a.c", before), HunkDigest.of("src/a.c", after)); + } + + /** + * The reason context is in the digest: a hunk means what it means in + * place, so an edit to the line above it must unsettle the approval even + * though the changed lines are byte-identical. + */ + @Test + void changingOnlyAContextLineChangesTheDigest() { + UnifiedDiff.Hunk before = hunk(List.of(ctx(1, "int a;"), add(2, "int b;"))); + UnifiedDiff.Hunk after = hunk(List.of(ctx(1, "long a;"), add(2, "int b;"))); + + assertNotEquals(HunkDigest.of("src/a.c", before), HunkDigest.of("src/a.c", after)); + } + + @Test + void changingAChangedLineChangesTheDigest() { + UnifiedDiff.Hunk before = hunk(List.of(ctx(1, "int a;"), add(2, "int b;"))); + UnifiedDiff.Hunk after = hunk(List.of(ctx(1, "int a;"), add(2, "int c;"))); + + assertNotEquals(HunkDigest.of("src/a.c", before), HunkDigest.of("src/a.c", after)); + } + + /** Identical hunks in two files are two different things to approve. */ + @Test + void thePathIsPartOfTheIdentity() { + UnifiedDiff.Hunk both = hunk(List.of(ctx(1, "int a;"), add(2, "int b;"))); + + assertNotEquals(HunkDigest.of("src/a.c", both), HunkDigest.of("src/b.c", both)); + } + + /** The line's KIND matters: an added line and a deleted one are not the same review. */ + @Test + void addAndDeleteOfTheSameTextDigestDifferently() { + UnifiedDiff.Hunk added = hunk(List.of(add(1, "int b;"))); + UnifiedDiff.Hunk deleted = hunk(List.of(new UnifiedDiff.Line( + UnifiedDiff.Line.Kind.DEL, OptionalInt.of(1), OptionalInt.empty(), "int b;"))); + + assertNotEquals(HunkDigest.of("src/a.c", added), HunkDigest.of("src/a.c", deleted)); + } + + @Test + void theDigestIsLowercaseHexOfFixedWidth() { + String digest = HunkDigest.of("src/a.c", hunk(List.of(add(1, "x")))); + + assertEquals(64, digest.length()); + assertEquals(digest.toLowerCase(java.util.Locale.ROOT), digest); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :app:test --tests "app.drydock.review.HunkDigestTest"` +Expected: FAIL — `cannot find symbol: class HunkDigest` + +- [ ] **Step 3: Write minimal implementation** + +```java +package app.drydock.review; + +import app.drydock.git.UnifiedDiff; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Objects; + +/** + * The content identity of one hunk: what an approval is valid for + * (spec §9.2). + * + *

Covers the file path, the hunk's changed lines and its context + * lines. Context is included because a hunk means what it means in place -- + * change the line above it and its changed lines are byte-identical, so a + * changed-lines-only digest would leave an approval standing over code whose + * surroundings moved. It stops at the context window rather than the whole + * file: a file-wide digest would unsettle every hunk whenever a file is + * touched again, re-reviewing code nobody changed.

+ * + *

Line NUMBERS are deliberately excluded. A hunk that only moved is the + * same code and stays approved; that is the whole reason this is not the + * positional line key findings use.

+ */ +public final class HunkDigest { + + private HunkDigest() { + } + + /** The digest {@code hunk} in {@code path} is approved under. */ + public static String of(String path, UnifiedDiff.Hunk hunk) { + Objects.requireNonNull(path, "path"); + Objects.requireNonNull(hunk, "hunk"); + StringBuilder material = new StringBuilder(path).append('\n'); + for (UnifiedDiff.Line line : hunk.lines()) { + // The kind is part of the material: an added line and a deleted + // line carrying the same text are not the same thing to approve. + material.append(line.kind().name()).append(' ').append(line.text()).append('\n'); + } + return hex(material.toString()); + } + + private static String hex(String material) { + try { + MessageDigest sha = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(sha.digest(material.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + // SHA-256 is mandated by the platform; its absence is not a + // condition this application can meaningfully continue past. + throw new IllegalStateException("SHA-256 unavailable", e); + } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :app:test --tests "app.drydock.review.HunkDigestTest"` +Expected: PASS (7 tests) + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/app/drydock/review/HunkDigest.java \ + app/src/test/java/app/drydock/review/HunkDigestTest.java +git commit -m "An approval is pinned to a hunk's content, not its position + +The digest covers the path, the changed lines and the surrounding context. +Context is in because a hunk means what it means in place: change the line +above it and its changed lines are byte-identical, so a changed-lines-only +digest leaves the approval standing over code whose surroundings moved. It +stops at the context window because a file-wide digest re-reviews hunks +nobody touched. Line numbers are out, so a hunk that only moved stays +approved -- which is the reason this is not the positional line key findings +are anchored to." +``` + +--- + +### Task 2: `ReviewVerdict` is keyed by content and remembers its base + +**Files:** +- Modify: `app/src/main/java/app/drydock/review/ReviewVerdict.java` +- Test: `app/src/test/java/app/drydock/review/ReviewVerdictTest.java` + +**Interfaces:** +- Consumes: nothing from earlier tasks (the digest is a plain `String` here) +- Produces: `ReviewVerdict(String scopeId, String hunkDigest, Decision decision, Optional note, Instant at, String baseCommit, String headCommit)`; `ReviewVerdict.Key(String scopeId, String hunkDigest)`; `ReviewVerdict.key()`; `boolean staleAgainst(String currentBase)`; `ReviewVerdict confirmedAgainst(String currentBase, String currentHead, Instant at)` + +- [ ] **Step 1: Write the failing test** + +```java +package app.drydock.review; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * A verdict names what it was given against (spec §9.2). A digest over the + * hunk's own text cannot see the base move underneath it, so the base is + * recorded and staleness is derived from it -- and "confirm still good" + * rewrites the recorded base rather than storing a fourth state. + */ +class ReviewVerdictTest { + + private static ReviewVerdict approvedAt(String base) { + return new ReviewVerdict("scope-1", "digest-1", ReviewVerdict.Decision.APPROVED, + Optional.empty(), Instant.EPOCH, base, "head-1"); + } + + @Test + void aVerdictIsKeyedByScopeAndHunkDigest() { + assertEquals(new ReviewVerdict.Key("scope-1", "digest-1"), approvedAt("base-1").key()); + } + + @Test + void aVerdictGivenAgainstTheCurrentBaseIsNotStale() { + assertFalse(approvedAt("base-1").staleAgainst("base-1")); + } + + @Test + void aVerdictGivenAgainstAnOlderBaseIsStale() { + assertTrue(approvedAt("base-1").staleAgainst("base-2")); + } + + /** + * Confirming rewrites the recorded base. Keeping a separate "confirmed" + * flag would mean two sources of truth for the same question, and the + * next base move would have to remember to clear it. + */ + @Test + void confirmingRewritesTheRecordedBaseAndClearsStaleness() { + ReviewVerdict confirmed = approvedAt("base-1") + .confirmedAgainst("base-2", "head-2", Instant.ofEpochSecond(10)); + + assertFalse(confirmed.staleAgainst("base-2")); + assertEquals("base-2", confirmed.baseCommit()); + assertEquals("head-2", confirmed.headCommit()); + assertEquals(ReviewVerdict.Decision.APPROVED, confirmed.decision()); + assertEquals("digest-1", confirmed.hunkDigest()); + } + + @Test + void aBlankHunkDigestIsRefused() { + assertThrows(IllegalArgumentException.class, () -> new ReviewVerdict( + "scope-1", " ", ReviewVerdict.Decision.APPROVED, + Optional.empty(), Instant.EPOCH, "base-1", "head-1")); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :app:test --tests "app.drydock.review.ReviewVerdictTest"` +Expected: FAIL — constructor arity mismatch, `hunkDigest()` and `staleAgainst` not found + +- [ ] **Step 3: Write minimal implementation** + +Replace the record header, `Key`, and compact constructor in `ReviewVerdict.java`, keeping `Decision` exactly as it is: + +```java +public record ReviewVerdict(String scopeId, String hunkDigest, Decision decision, + Optional note, Instant at, + String baseCommit, String headCommit) { + + public ReviewVerdict { + Objects.requireNonNull(scopeId, "scopeId"); + Objects.requireNonNull(hunkDigest, "hunkDigest"); + Objects.requireNonNull(decision, "decision"); + Objects.requireNonNull(note, "note"); + Objects.requireNonNull(at, "at"); + Objects.requireNonNull(baseCommit, "baseCommit"); + Objects.requireNonNull(headCommit, "headCommit"); + if (scopeId.isBlank() || hunkDigest.isBlank()) { + throw new IllegalArgumentException( + "a verdict is keyed by (scopeId, hunkDigest); neither may be blank"); + } + } + + public Key key() { + return new Key(scopeId, hunkDigest); + } + + /** {@code (scopeId, hunkDigest)} -- a hunk's content is its identity (spec §9.2). */ + public record Key(String scopeId, String hunkDigest) { + public Key { + Objects.requireNonNull(scopeId, "scopeId"); + Objects.requireNonNull(hunkDigest, "hunkDigest"); + } + } + + /** + * Whether the base has moved since this was given. Only a candidate for + * staleness: whether the move could actually matter is + * {@link BaseMove}'s question, not this record's. + */ + public boolean staleAgainst(String currentBase) { + return !baseCommit.equals(currentBase); + } + + /** + * "Confirm still good": the same decision, re-dated, recorded against the + * base it has now been judged against. Rewriting the base rather than + * storing a confirmed flag keeps one source of truth for staleness -- + * a flag would have to be cleared by the next base move, and forgetting + * to is a silently-approved-stale-code bug. + */ + public ReviewVerdict confirmedAgainst(String currentBase, String currentHead, Instant when) { + return new ReviewVerdict(scopeId, hunkDigest, decision, note, when, currentBase, currentHead); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :app:test --tests "app.drydock.review.ReviewVerdictTest"` +Expected: PASS (5 tests). `AnnotationStore` and the UI will not compile yet — Task 3 fixes the store, Task 6 the UI. If the module fails to compile, stop and complete Task 3 before re-running. + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/app/drydock/review/ReviewVerdict.java \ + app/src/test/java/app/drydock/review/ReviewVerdictTest.java +git commit -m "A verdict is keyed by hunk content and names the base it was given against + +Two changes, one record. The key moves from intentId to a hunk content +digest, so a verdict no longer belongs to a grouping and an agent regrouping +cannot orphan it. And the (base, head) it was judged against is recorded, +because a digest over a hunk's own text cannot see the base move underneath +it -- a rebase leaves every hunk byte-identical while the code they sit on +changed. + +Confirm-still-good rewrites the recorded base rather than setting a +confirmed flag. A flag would be a second source of truth that the next base +move has to remember to clear, and forgetting is a silently-approved-stale- +code bug." +``` + +--- + +### Task 3: `AnnotationStore` stores verdicts per hunk, and the migration goes + +**Files:** +- Modify: `app/src/main/java/app/drydock/review/AnnotationStore.java` (verdict accessors ~178–184, `putVerdict` ~302, `migrateLegacyVerdicts` ~312–410, JSON encode ~601–612, JSON decode ~855–877, `SCHEMA_VERSION` line 75) +- Test: `app/src/test/java/app/drydock/review/AnnotationStoreVerdictKeyTest.java` + +**Interfaces:** +- Consumes: `ReviewVerdict` (Task 2) +- Produces: `Optional verdict(String scopeId, String hunkDigest)`; `List verdictsFor(String scopeId)` (unchanged signature); `void putVerdict(ReviewVerdict)`; `void clearVerdict(String scopeId, String hunkDigest)`; `void flushPendingSaves()` (already exists) + +- [ ] **Step 1: Write the failing test** + +```java +package app.drydock.review; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verdicts are stored under a hunk's content, not under a grouping + * (spec §9.1). The round trip is what makes an approval outlive the process + * that recorded it, and the base/head it was given against has to survive + * with it or staleness cannot be derived on the next launch. + */ +class AnnotationStoreVerdictKeyTest { + + private static ReviewVerdict approved(String digest, String base) { + return new ReviewVerdict("scope-1", digest, ReviewVerdict.Decision.APPROVED, + Optional.of("looks right"), Instant.parse("2026-08-22T00:00:00Z"), base, "head-1"); + } + + @Test + void aVerdictRoundTripsThroughDiskWithItsBaseAndHead() throws IOException { + Path file = Files.createTempDirectory("drydock-verdicts").resolve("annotations.json"); + AnnotationStore store = new AnnotationStore(file); + store.putVerdict(approved("digest-a", "base-1")); + store.flushPendingSaves(); + + AnnotationStore reloaded = new AnnotationStore(file); + Optional read = reloaded.verdict("scope-1", "digest-a"); + + assertTrue(read.isPresent()); + assertEquals("base-1", read.get().baseCommit()); + assertEquals("head-1", read.get().headCommit()); + assertEquals(Optional.of("looks right"), read.get().note()); + assertEquals(ReviewVerdict.Decision.APPROVED, read.get().decision()); + } + + /** + * The property that makes overlapping sections possible (spec §5.6): one + * hunk shown in three sections is one digest, so it is one flag. + */ + @Test + void oneDigestIsOneFlagHoweverManySectionsShowIt() throws IOException { + Path file = Files.createTempDirectory("drydock-verdicts").resolve("annotations.json"); + AnnotationStore store = new AnnotationStore(file); + + store.putVerdict(approved("shared-digest", "base-1")); + + assertEquals(1, store.verdictsFor("scope-1").size()); + assertTrue(store.verdict("scope-1", "shared-digest").isPresent()); + } + + @Test + void clearingRemovesTheVerdictForThatDigestOnly() throws IOException { + Path file = Files.createTempDirectory("drydock-verdicts").resolve("annotations.json"); + AnnotationStore store = new AnnotationStore(file); + store.putVerdict(approved("digest-a", "base-1")); + store.putVerdict(approved("digest-b", "base-1")); + + store.clearVerdict("scope-1", "digest-a"); + + assertEquals(List.of("digest-b"), + store.verdictsFor("scope-1").stream().map(ReviewVerdict::hunkDigest).toList()); + } + + /** + * A v3 entry names an intentId and no digest. There are none in the wild + * (which is why no migration is written), but a file carrying one must + * be skipped rather than crash the load -- lenient decoding is the + * store's existing contract. + */ + @Test + void aPreDigestVerdictEntryIsSkippedNotFatal() throws IOException { + Path file = Files.createTempDirectory("drydock-verdicts").resolve("annotations.json"); + Files.writeString(file, """ + {"schemaVersion":3,"annotations":[],"submitted":[], + "verdicts":[{"scopeId":"scope-1","intentId":"auto:change:src", + "verdict":"approved","at":"2026-08-01T00:00:00Z"}]} + """); + + AnnotationStore store = new AnnotationStore(file); + + assertEquals(List.of(), store.verdictsFor("scope-1")); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :app:test --tests "app.drydock.review.AnnotationStoreVerdictKeyTest"` +Expected: FAIL — `verdict(String, String)` still resolves against `intentId`, and the encoder writes no `hunkDigest` + +- [ ] **Step 3: Write minimal implementation** + +Bump the version and rename the parameter (line 75 and the accessors): + +```java + private static final int SCHEMA_VERSION = 4; +``` + +```java + public synchronized Optional verdict(String scopeId, String hunkDigest) { + return Optional.ofNullable(verdicts.get(new ReviewVerdict.Key(scopeId, hunkDigest))); + } + + /** {@code u}: undoes the verdict on one hunk. */ + public void clearVerdict(String scopeId, String hunkDigest) { + if (clearVerdictInternal(scopeId, hunkDigest)) { + fireChanged(null); + } + } + + private synchronized boolean clearVerdictInternal(String scopeId, String hunkDigest) { + if (verdicts.remove(new ReviewVerdict.Key(scopeId, hunkDigest)) != null) { + persistAsync(); + return true; + } + return false; + } +``` + +Encoder — replace the `intentId` line and add the two commits: + +```java + obj.put("hunkDigest", new JsonString(verdict.hunkDigest())); + obj.put("verdict", new JsonString(verdict.decision().wireName())); + verdict.note().ifPresent(note -> obj.put("note", new JsonString(note))); + obj.put("at", new JsonString(verdict.at().toString())); + obj.put("base", new JsonString(verdict.baseCommit())); + obj.put("head", new JsonString(verdict.headCommit())); +``` + +Decoder — `requireString(obj, "hunkDigest")` replaces `intentId`; an entry without one is skipped by the existing `catch`: + +```java + result.add(new ReviewVerdict( + requireString(obj, "scopeId"), + requireString(obj, "hunkDigest"), + ReviewVerdict.Decision.fromWire(requireString(obj, "verdict")) + .orElseThrow(() -> new IllegalArgumentException("unknown verdict")), + optionalString(obj, "note"), + Instant.parse(requireString(obj, "at")), + requireString(obj, "base"), + requireString(obj, "head"))); +``` + +Delete `migrateLegacyVerdicts`, `migrateLegacyVerdictsInternal`, `LEGACY_FILE_INTENT_PREFIX` and their callers. Keep the private `merge(List)` helper — Task 4 extracts it. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :app:test --tests "app.drydock.review.AnnotationStoreVerdictKeyTest"` +Expected: PASS (4 tests) + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/app/drydock/review/AnnotationStore.java \ + app/src/test/java/app/drydock/review/AnnotationStoreVerdictKeyTest.java +git commit -m "Verdicts are stored per hunk digest, and the legacy migration is deleted + +Schema 4. A verdict entry carries hunkDigest, base and head instead of +intentId; a v3 entry naming an intentId is skipped by the existing lenient +decode rather than failing the load. + +migrateLegacyVerdicts goes with it. It carried verdicts from the old +file: intent ids onto directory-clustered intents, and with the key no +longer naming a grouping there is nothing for it to carry and no caller left +to call it. Its merge helper survives -- it answers how a group's decision +follows from its members, which is now a live question rather than a +migration one." +``` + +--- + +### Task 4: `VerdictMerge` — a section's state is derived from its hunks + +**Files:** +- Create: `app/src/main/java/app/drydock/review/VerdictMerge.java` +- Modify: `app/src/main/java/app/drydock/review/AnnotationStore.java` (delete the private `merge`) +- Test: `app/src/test/java/app/drydock/review/VerdictMergeTest.java` + +**Interfaces:** +- Consumes: `ReviewVerdict` (Task 2) +- Produces: `static Optional VerdictMerge.derive(List> hunkVerdicts)` + +- [ ] **Step 1: Write the failing test** + +```java +package app.drydock.review; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * How a section's state follows from its hunks (spec §9.1). The asymmetry is + * the point and it is not new -- it is the rule migrateLegacyVerdicts was + * written around, promoted from a one-off migration to the live derivation: + * "something in here needs work" survives any redrawing of the group, while + * approving a group claims the human read all of it. + */ +class VerdictMergeTest { + + private static Optional of(ReviewVerdict.Decision decision) { + return Optional.of(new ReviewVerdict("s", "d" + decision.ordinal(), decision, + Optional.empty(), Instant.EPOCH, "base", "head")); + } + + private static final Optional UNSETTLED = Optional.empty(); + + @Test + void everyHunkApprovedApprovesTheSection() { + assertEquals(Optional.of(ReviewVerdict.Decision.APPROVED), + VerdictMerge.derive(List.of(of(ReviewVerdict.Decision.APPROVED), + of(ReviewVerdict.Decision.APPROVED)))); + } + + /** Any changes request survives however the group is drawn. */ + @Test + void oneChangesRequestMakesTheWholeSectionChanges() { + assertEquals(Optional.of(ReviewVerdict.Decision.CHANGES), + VerdictMerge.derive(List.of(of(ReviewVerdict.Decision.APPROVED), + of(ReviewVerdict.Decision.CHANGES)))); + } + + /** + * The outcome this must never produce: approving code nobody looked at. + * A section with one unread hunk is not approved, it is unsettled. + */ + @Test + void oneUnsettledHunkLeavesTheSectionUnsettled() { + assertEquals(Optional.empty(), + VerdictMerge.derive(List.of(of(ReviewVerdict.Decision.APPROVED), UNSETTLED))); + } + + /** But a changes request outranks an unread hunk: it is already true. */ + @Test + void changesWinsEvenWithAnUnsettledHunkPresent() { + assertEquals(Optional.of(ReviewVerdict.Decision.CHANGES), + VerdictMerge.derive(List.of(of(ReviewVerdict.Decision.CHANGES), UNSETTLED))); + } + + @Test + void autoApprovalCountsAsSettledAndIsReportedAsItself() { + assertEquals(Optional.of(ReviewVerdict.Decision.AUTO_APPROVED), + VerdictMerge.derive(List.of(of(ReviewVerdict.Decision.AUTO_APPROVED), + of(ReviewVerdict.Decision.AUTO_APPROVED)))); + } + + /** A human approval outranks the agent's assertion in the label. */ + @Test + void aMixOfHumanAndAutoApprovalReadsAsApproved() { + assertEquals(Optional.of(ReviewVerdict.Decision.APPROVED), + VerdictMerge.derive(List.of(of(ReviewVerdict.Decision.AUTO_APPROVED), + of(ReviewVerdict.Decision.APPROVED)))); + } + + @Test + void anEmptySectionHasNoDecision() { + assertEquals(Optional.empty(), VerdictMerge.derive(List.of())); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :app:test --tests "app.drydock.review.VerdictMergeTest"` +Expected: FAIL — `cannot find symbol: class VerdictMerge` + +- [ ] **Step 3: Write minimal implementation** + +```java +package app.drydock.review; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * A section's decision, derived from its hunks' (spec §9.1). + * + *

The merge is deliberately asymmetric, and the asymmetry is inherited + * rather than invented: it is the rule {@code AnnotationStore}'s legacy + * verdict migration was written around, promoted from a one-off carry to the + * live derivation now that sections overlap and cannot own a verdict of + * their own.

+ * + *
    + *
  • Any {@code CHANGES} makes the section {@code CHANGES}. "Something in + * here needs work" stays true of a section however it is drawn.
  • + *
  • An approval needs EVERY hunk settled. Approving a section is a claim + * that the human read all of it, so one unread hunk leaves it + * unsettled. Silently approving code nobody looked at is the one + * outcome this must never produce.
  • + *
+ */ +public final class VerdictMerge { + + private VerdictMerge() { + } + + /** + * The section's decision, or empty when its hunks do not support one. + * {@code hunkVerdicts} carries one entry per hunk in the section, empty + * where that hunk is unsettled. + */ + public static Optional derive( + List> hunkVerdicts) { + Objects.requireNonNull(hunkVerdicts, "hunkVerdicts"); + if (hunkVerdicts.isEmpty()) { + return Optional.empty(); + } + boolean anyUnsettled = false; + boolean anyHumanApproval = false; + for (Optional verdict : hunkVerdicts) { + if (verdict.isEmpty()) { + anyUnsettled = true; + continue; + } + switch (verdict.get().decision()) { + // Checked before the unsettled test: a changes request is + // already true of the section, and waiting for the rest to be + // read before saying so would hide it exactly when it matters. + case CHANGES -> { + return Optional.of(ReviewVerdict.Decision.CHANGES); + } + case APPROVED -> anyHumanApproval = true; + case AUTO_APPROVED -> { } + } + } + if (anyUnsettled) { + return Optional.empty(); + } + return Optional.of(anyHumanApproval + ? ReviewVerdict.Decision.APPROVED + : ReviewVerdict.Decision.AUTO_APPROVED); + } +} +``` + +Then delete the private `merge(...)` from `AnnotationStore` (it went unused with Task 3's deletion). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :app:test --tests "app.drydock.review.VerdictMergeTest"` +Expected: PASS (7 tests) + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/app/drydock/review/VerdictMerge.java \ + app/src/main/java/app/drydock/review/AnnotationStore.java \ + app/src/test/java/app/drydock/review/VerdictMergeTest.java +git commit -m "A section's decision is derived from its hunks, not stored + +Sections overlap, so a section cannot own a verdict -- a hunk shown in three +of them would need three. The decision is derived instead, by the asymmetric +merge the legacy migration was already written around: any CHANGES makes the +section CHANGES because that is true however the group is drawn, and an +approval needs every hunk settled because approving a section claims the +human read all of it. + +Extracted from AnnotationStore so it can be tested without a store, and +because it is no longer a migration detail but the rule the rail renders." +``` + +--- + +### Task 5: `BaseMove` — staleness only when the base move could matter + +**Files:** +- Create: `app/src/main/java/app/drydock/review/BaseMove.java` +- Test: `app/src/test/java/app/drydock/review/BaseMoveTest.java` + +**Interfaces:** +- Consumes: `app.drydock.process.ProcessRunner`, `ProcessResult`, `ProcessTimeoutException` +- Produces: `record BaseMove.Delta(boolean unresolvable, java.util.SortedSet changedFiles)`; `static Delta between(Path worktree, String oldBase, String newBase)`; `static boolean couldMatter(Delta delta, java.util.Collection scopeFiles)` + +**Deferred by one phase, deliberately:** spec §9.2 intersects the delta against the scope's files **and** the files declaring symbols its hunks reference. The second half needs the `ChangeGraph`, which is Phase 2. This task implements the first half; **Task 15 widens it**. The `couldMatter` signature takes a `Collection` precisely so Task 15 can pass a wider set without changing callers. + +- [ ] **Step 1: Write the failing test** + +```java +package app.drydock.review; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.TreeSet; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Which base moves are worth telling the reviewer about (spec §9.2). + * Marking every verdict stale on any base move treats "main advanced in an + * unrelated subsystem" the same as "main rewrote a function this hunk + * calls", and on an active repository the first is nearly all of them -- + * which is how a confirm button becomes reflex. + * + *

{@code between} spawns git and is covered by the running-app pass; + * what is unit-tested here is the decision the spawn feeds.

+ */ +class BaseMoveTest { + + private static BaseMove.Delta delta(String... files) { + return new BaseMove.Delta(false, new TreeSet<>(List.of(files))); + } + + @Test + void aBaseMoveTouchingOnlyUnrelatedFilesCannotMatter() { + assertFalse(BaseMove.couldMatter(delta("docs/README.md", "web/app.ts"), + List.of("src/guards.cpp", "src/guards.h"))); + } + + @Test + void aBaseMoveTouchingAFileThisScopeChangesMatters() { + assertTrue(BaseMove.couldMatter(delta("docs/README.md", "src/guards.h"), + List.of("src/guards.cpp", "src/guards.h"))); + } + + /** + * Failing safe is the only defensible default for a signal about what was + * read: if the old base cannot be resolved -- a force-push, a collected + * commit -- everything is a candidate. + */ + @Test + void anUnresolvableOldBaseMattersRegardlessOfFiles() { + assertTrue(BaseMove.couldMatter(new BaseMove.Delta(true, new TreeSet<>()), + List.of("src/guards.cpp"))); + } + + @Test + void anEmptyDeltaCannotMatter() { + assertFalse(BaseMove.couldMatter(delta(), List.of("src/guards.cpp"))); + } + + /** A scope with no files is not a reason to mark anything. */ + @Test + void aScopeWithNoFilesCannotBeAffected() { + assertFalse(BaseMove.couldMatter(delta("src/guards.h"), List.of())); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :app:test --tests "app.drydock.review.BaseMoveTest"` +Expected: FAIL — `cannot find symbol: class BaseMove` + +- [ ] **Step 3: Write minimal implementation** + +```java +package app.drydock.review; + +import app.drydock.process.ProcessResult; +import app.drydock.process.ProcessRunner; +import app.drydock.process.ProcessTimeoutException; + +import java.io.IOException; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.SortedSet; +import java.util.TreeSet; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Whether a base move can have changed what an approval was given for + * (spec §9.2). + * + *

Marking every verdict stale on any base move spends the reviewer's + * attention on commits that provably could not matter, and a + * "confirm still good" button clicked reflexively is worth less than no + * button. So the base delta is intersected first.

+ * + *

The intersection is file-level and lexical. A base change that alters + * behaviour without touching a file the scope names or references will not + * mark anything -- drydock does not index the repository, so it cannot see + * that far. Closing that gap is the agent recheck's job, not this class's.

+ */ +public final class BaseMove { + + private static final Logger LOG = Logger.getLogger(BaseMove.class.getName()); + private static final Duration TIMEOUT = Duration.ofSeconds(20); + + private BaseMove() { + } + + /** + * What a base move touched. {@code unresolvable} means the old base could + * not be diffed -- a force-push, or a collected commit -- and is NOT the + * same as an empty delta. + */ + public record Delta(boolean unresolvable, SortedSet changedFiles) { + public Delta { + Objects.requireNonNull(changedFiles, "changedFiles"); + changedFiles = new TreeSet<>(changedFiles); + } + } + + /** The files {@code oldBase..newBase} touched. Blocking; never call on the FX thread. */ + public static Delta between(Path worktree, String oldBase, String newBase) { + List command = List.of("git", "diff", "--name-only", "--end-of-options", + oldBase + ".." + newBase); + try { + ProcessResult result = ProcessRunner.run(command, worktree, TIMEOUT); + if (result.exitCode() != 0) { + LOG.log(Level.WARNING, "git diff for base move failed: " + + ProcessRunner.excerpt(result.stderr())); + return new Delta(true, new TreeSet<>()); + } + SortedSet files = new TreeSet<>(); + for (String line : result.stdout().split("\n")) { + String path = line.strip(); + if (!path.isEmpty()) { + files.add(path); + } + } + return new Delta(false, files); + } catch (ProcessTimeoutException e) { + LOG.log(Level.WARNING, "git diff for base move timed out", e); + return new Delta(true, new TreeSet<>()); + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + LOG.log(Level.WARNING, "git diff for base move could not run", e); + return new Delta(true, new TreeSet<>()); + } + } + + /** + * Whether {@code delta} could have changed the meaning of code in + * {@code scopeFiles}. + * + *

{@code scopeFiles} is a {@link Collection} rather than the scope's + * own file list so that the set can widen -- Phase 2 adds the files + * declaring symbols the scope's hunks reference -- without moving any + * caller.

+ */ + public static boolean couldMatter(Delta delta, Collection scopeFiles) { + Objects.requireNonNull(delta, "delta"); + Objects.requireNonNull(scopeFiles, "scopeFiles"); + if (delta.unresolvable()) { + return true; + } + for (String file : scopeFiles) { + if (delta.changedFiles().contains(file)) { + return true; + } + } + return false; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :app:test --tests "app.drydock.review.BaseMoveTest"` +Expected: PASS (5 tests) + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/app/drydock/review/BaseMove.java \ + app/src/test/java/app/drydock/review/BaseMoveTest.java +git commit -m "A base move marks approvals stale only when it could matter + +Marking every verdict stale on any base move treats main advancing in an +unrelated subsystem the same as main rewriting a function this hunk calls. +On an active repository the first is nearly all of them, and that is how a +confirm button becomes reflex. One git diff --name-only, intersected with +the scope's files, decides. + +Failing safe where it cannot decide: an unresolvable old base -- a +force-push, a collected commit -- marks everything, because for a signal +about what was read there is no defensible alternative. Two more honest +limits: the intersection is file-level and lexical, so a base change that +alters behaviour without touching a named file marks nothing, and the +scope-file set is a Collection so Phase 2 can widen it to the files +declaring symbols these hunks reference without moving a caller." +``` + +--- + +### Task 6: The rail and the verdict bar read hunks, not sections + +**Files:** +- Modify: `app/src/main/java/app/drydock/ui/review/ReviewVerdictBar.java` (progress label ~259, `render` ~221, `showSubmitRefused` ~208) +- Modify: `app/src/main/java/app/drydock/ui/review/ReviewIntentRail.java` (card rendering) +- Modify: `app/src/main/java/app/drydock/ui/review/SessionReviewView.java` (`renderVerdictBar` ~765, `renderSelectedScope` ~495) +- Test: `app/src/test/java/app/drydock/ui/review/ReviewHunkProgressTest.java` + +**Interfaces:** +- Consumes: `HunkDigest.of` (Task 1), `VerdictMerge.derive` (Task 4), `AnnotationStore.verdict(scopeId, digest)` (Task 3), `ReviewVerdict.staleAgainst` (Task 2), `BaseMove.couldMatter` (Task 5) +- Produces: `ReviewVerdictBar.showProgress(int settledHunks, int totalHunks)`; `ReviewIntentRail` card state `SectionState(Optional decision, int settledHunks, int totalHunks, boolean stale, List settledElsewhere)` + +- [ ] **Step 1: Write the failing test** + +```java +package app.drydock.ui.review; + +import app.drydock.git.UnifiedDiff; +import app.drydock.review.HunkDigest; +import app.drydock.review.ReviewVerdict; +import javafx.scene.Scene; +import javafx.scene.control.Label; +import javafx.stage.Stage; +import org.junit.jupiter.api.Test; +import org.testfx.framework.junit5.ApplicationTest; +import org.testfx.util.WaitForAsyncUtils; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.OptionalInt; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Overlapping sections break the old arithmetic (spec §5.6): the sum of + * section sizes exceeds the number of hunks, so "3 of 5 intents settled" + * measures nothing. Progress counts distinct hunks, and a hunk settled in + * one section shows as settled in the other. + */ +class ReviewHunkProgressTest extends ApplicationTest { + + private FakeReviewHost host; + private SessionReviewView view; + + private static UnifiedDiff.Hunk hunk(String text) { + return new UnifiedDiff.Hunk("@@ -1,1 +1,1 @@", List.of(new UnifiedDiff.Line( + UnifiedDiff.Line.Kind.ADD, OptionalInt.empty(), OptionalInt.of(1), text))); + } + + @Override + public void start(Stage stage) throws Exception { + host = new FakeReviewHost(java.nio.file.Files + .createTempDirectory("drydock-progress").resolve("annotations.json")); + // One shared file placed in two sections, plus one file of its own: + // three hunks total, four section slots. + host.diff = new UnifiedDiff(List.of( + new UnifiedDiff.FileDiff("src/guards.h", "M", 1, 0, false, false, + List.of(hunk("class JmpCtxScope;"))), + new UnifiedDiff.FileDiff("src/guards.cpp", "M", 1, 0, false, false, + List.of(hunk("void install();"))), + new UnifiedDiff.FileDiff("src/profiler.cpp", "M", 1, 0, false, false, + List.of(hunk("resolve();"))))); + view = new SessionReviewView(host, new app.drydock.git.DiffService(), null); + stage.setScene(new Scene(view, 1400, 900)); + stage.show(); + WaitForAsyncUtils.waitForFxEvents(); + } + + private String progressText() { + return lookup(".review-verdict-progress-label").queryAll().stream() + .filter(Label.class::isInstance).map(Label.class::cast) + .map(Label::getText).findFirst().orElse(""); + } + + @Test + void progressCountsDistinctHunksNotSectionSlots() { + assertTrue(progressText().contains("0/3"), + "expected three distinct hunks, got: " + progressText()); + } + + @Test + void settlingASharedHunkAdvancesProgressExactlyOnce() { + String shared = HunkDigest.of("src/guards.h", hunk("class JmpCtxScope;")); + host.annotations().putVerdict(new ReviewVerdict(host.scopeId(), shared, + ReviewVerdict.Decision.APPROVED, Optional.empty(), Instant.EPOCH, + host.baseCommit(), host.headCommit())); + WaitForAsyncUtils.waitForFxEvents(); + + assertTrue(progressText().contains("1/3"), + "a hunk in two sections is one flag, got: " + progressText()); + } + + @Test + void anUnsettledHunkLeavesItsSectionUnsettled() { + assertEquals(Optional.empty(), view.sectionStateForTest(0).decision()); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :app:test --tests "app.drydock.ui.review.ReviewHunkProgressTest"` +Expected: FAIL — `sectionStateForTest` not found; progress label still reads `0/2 intents settled` + +- [ ] **Step 3: Write minimal implementation** + +In `ReviewVerdictBar`, replace the progress label text and rename the setter: + +```java + /** Progress is counted in distinct hunks: sections overlap, so their sizes do not sum. */ + void showProgress(int settledHunks, int totalHunks) { + this.settledCount = settledHunks; + this.totalCount = totalHunks; + render(); + } +``` + +```java + progressLabel.setText(settledCount + "/" + totalCount + " hunks reviewed"); +``` + +In `SessionReviewView`, add the per-section derivation and expose it for tests: + +```java + /** One section's rendered state, derived from its hunks (spec §9.1). */ + record SectionState(Optional decision, int settledHunks, + int totalHunks, boolean stale, List settledElsewhere) { + } + + SectionState sectionStateForTest(int sectionIndex) { + return sectionState(intents().get(sectionIndex)); + } + + private SectionState sectionState(ReviewIntent intent) { + List> perHunk = new ArrayList<>(); + List elsewhere = new ArrayList<>(); + boolean stale = false; + for (String digest : digestsOf(intent)) { + Optional verdict = host.annotations().verdict(scopeId(), digest); + perHunk.add(verdict); + if (verdict.isPresent() && verdict.get().staleAgainst(currentBase()) + && BaseMove.couldMatter(baseDelta(), filesOf(intent))) { + stale = true; + } + settlingSectionOf(digest).ifPresent(elsewhere::add); + } + long settled = perHunk.stream().filter(Optional::isPresent).count(); + return new SectionState(VerdictMerge.derive(perHunk), (int) settled, + perHunk.size(), stale, List.copyOf(elsewhere)); + } +``` + +`digestsOf(intent)` maps the intent's hunk ids through `HunkDigest.of`; `settlingSectionOf(digest)` returns the number of the first *other* section whose hunks include that digest and which is settled, so the rail can render `✓ reviewed in ①`. Distinct-hunk progress is the union of every section's digests, counted once. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :app:test --tests "app.drydock.ui.review.ReviewHunkProgressTest"` +Expected: PASS (3 tests) + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/app/drydock/ui/review/ app/src/test/java/app/drydock/ui/review/ReviewHunkProgressTest.java +git commit -m "Progress counts hunks, and a section's state is derived from them + +Sections overlap, so the sum of their sizes exceeds the number of hunks and +n/m intents settled measures nothing. The bar counts distinct hunks; a +section's decision comes from VerdictMerge over its own; and a hunk settled +in one section renders as settled in the other, marked with where, so the +effect of settling is visible where it lands rather than looking like state +changing on its own." +``` + +--- + +### Task 7: Settle actions, the stale banner, and the shortcut strip + +**Files:** +- Modify: `app/src/main/java/app/drydock/ui/review/SessionReviewView.java` (key handling) +- Modify: `app/src/main/java/app/drydock/ui/review/ReviewVerdictBar.java` (stale banner, acting-unit label) +- Modify: `app/src/main/java/app/drydock/ui/ShortcutsOverlay.java` (lines 46–59, the `IN REVIEW` section) +- Test: `app/src/test/java/app/drydock/ui/review/ReviewSettleActionsTest.java` + +**Interfaces:** +- Consumes: `SectionState` (Task 6), `ReviewVerdict.confirmedAgainst` (Task 2), `AnnotationStore.putVerdict` / `clearVerdict` (Task 3) +- Produces: `SessionReviewView.settleUnit()` → `enum SettleUnit { SECTION, HUNK, FILE }` + +- [ ] **Step 1: Write the failing test** + +```java +package app.drydock.ui.review; + +import javafx.scene.input.KeyCode; +import org.junit.jupiter.api.Test; +import org.testfx.util.WaitForAsyncUtils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Reading is per hunk; settling usually is not (spec §9.6). The unit follows + * focus rather than adding a parallel key set -- the same rule [ and ] + * already follow -- and the bar names the unit, because a key whose target + * depends on focus must say what it is about to do. + */ +class ReviewSettleActionsTest extends ReviewViewFixture { + + @Test + void withTheRailFocusedApproveSettlesTheWholeSection() { + focusRail(); + press(KeyCode.A); + WaitForAsyncUtils.waitForFxEvents(); + + assertEquals(view.sectionStateForTest(0).totalHunks(), + view.sectionStateForTest(0).settledHunks()); + } + + @Test + void withTheDiffColumnFocusedApproveSettlesOneHunk() { + focusDiffColumn(); + press(KeyCode.A); + WaitForAsyncUtils.waitForFxEvents(); + + assertEquals(1, view.sectionStateForTest(0).settledHunks()); + } + + @Test + void shiftApproveSettlesEveryHunkOfTheCurrentFile() { + focusDiffColumn(); + press(KeyCode.SHIFT, KeyCode.A); + WaitForAsyncUtils.waitForFxEvents(); + + assertEquals(hunkCountOfCurrentFile(), view.sectionStateForTest(0).settledHunks()); + } + + /** Settling a shared hunk has to be visible where it lands. */ + @Test + void settlingASectionShowsItsSharedHunksSettledInTheOtherSection() { + focusRail(); + press(KeyCode.A); + WaitForAsyncUtils.waitForFxEvents(); + + assertTrue(view.sectionStateForTest(1).settledElsewhere().contains("1")); + } + + @Test + void theBarNamesTheUnitAnActionWillHit() { + focusRail(); + assertEquals(SessionReviewView.SettleUnit.SECTION, view.settleUnit()); + focusDiffColumn(); + assertEquals(SessionReviewView.SettleUnit.HUNK, view.settleUnit()); + } +} +``` + +Add the shared fixture `ReviewViewFixture` (base class holding `start`, `focusRail`, `focusDiffColumn`, `press`, `hunkCountOfCurrentFile`) alongside it, modelled on `FakeReviewHost`'s existing use in `ReviewCarriedOverVerdictTest`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :app:test --tests "app.drydock.ui.review.ReviewSettleActionsTest"` +Expected: FAIL — `SettleUnit` not found + +- [ ] **Step 3: Write minimal implementation** + +```java + /** What {@code a} / {@code r} / {@code u} act on, decided by focus (spec §9.6). */ + enum SettleUnit { SECTION, HUNK, FILE } + + SettleUnit settleUnit() { + return diffColumn.isFocusWithin() ? SettleUnit.HUNK : SettleUnit.SECTION; + } + + private void onApprove(boolean wholeFile) { + List digests = wholeFile + ? digestsOfCurrentFile() + : switch (settleUnit()) { + case SECTION -> digestsOf(selectedIntent()); + case HUNK -> List.of(digestOfCurrentHunk()); + case FILE -> digestsOfCurrentFile(); + }; + Instant now = Instant.now(); + for (String digest : digests) { + host.annotations().putVerdict(new ReviewVerdict(scopeId(), digest, + ReviewVerdict.Decision.APPROVED, Optional.empty(), now, + currentBase(), currentHead())); + } + } +``` + +`r` mints `CHANGES` the same way; `u` calls `clearVerdict` over the same digest list. The stale banner's *confirm still good* rewrites each stale verdict through `confirmedAgainst(currentBase(), currentHead(), Instant.now())`; *re-review* clears them. A section holding a stale verdict does not count as settled, so `ReviewVerdictBar.showSubmitRefused("approvals were given against an older base")` fires on `⏎`. + +`ShortcutsOverlay`'s `IN REVIEW` section becomes: + +```java + {"Approve (section, or hunk in the diff)", "a"}, + {"Request changes (section, or hunk in the diff)", "r"}, + {"Undo (section, or hunk in the diff)", "u"}, + {"Approve every hunk in this file", "⇧A"}, + {"Request changes on this file", "⇧R"}, +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :app:test --tests "app.drydock.ui.review.ReviewSettleActionsTest"` +Expected: PASS (5 tests) + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/app/drydock/ui/ app/src/test/java/app/drydock/ui/review/ +git commit -m "Settle a hunk, a file or a section, and say which one a key will hit + +Reading is per hunk; settling usually is not. The unit follows focus rather +than adding a parallel key set -- the rule [ and ] already follow -- so a +and r and u keep their keys and gain a defined effect on overlapping +sections, with SHIFT variants for the file. The bar names the unit, because +a key whose target depends on focus has to say what it is about to do. + +Stale verdicts get their two answers: confirm still good rewrites the +recorded base, re-review clears them, and until one of those happens the +section does not count as settled and the submit refuses with a reason +rather than silently doing nothing." +``` + +--- + +### Phase 1 gate + +- [ ] **Run the full suite:** `./gradlew :app:test` (14–20 minutes; run it from the controlling session, not a subagent — the 10-minute Bash ceiling will kill it) +- [ ] **Run the app** and confirm by screenshot, per `docs/` visual-verification practice: the verdict bar reading `n/m hunks reviewed`, a section showing `✓ reviewed in ①` on a shared hunk, and the stale banner with its two buttons at a realistic window width. The rail's cards have truncated before. +- [ ] **Confirm the deletion is safe:** `rg -n "migrateLegacyVerdicts" app/src` must return nothing. + +--- + +# Phase 2 — Graph-backed sections + +### Task 8: tree-sitter on the classpath, with a lexical fallback that is not an error + +**Files:** +- Modify: `app/build.gradle.kts` (dependencies block, after the `pty4j` line) +- Create: `app/src/main/java/app/drydock/review/GrammarRegistry.java` +- Test: `app/src/test/java/app/drydock/review/GrammarRegistryTest.java` + +**Interfaces:** +- Produces: `Optional GrammarRegistry.forPath(String path)`; `boolean GrammarRegistry.nativeAvailable()` + +- [ ] **Step 1: Write the failing test** + +```java +package app.drydock.review; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * A grammar that is not on the classpath is the lexical path, not an error + * (spec §10.2). That single rule is what keeps the shipped language set a + * packaging decision rather than an architectural one -- the .app and the + * jbang jar may ship different sets, and an unsupported language produces a + * coarser surface rather than a broken one. + */ +class GrammarRegistryTest { + + @Test + void aShippedLanguageResolvesToAGrammar() { + assertTrue(GrammarRegistry.forPath("src/Main.java").isPresent()); + } + + @Test + void anUnshippedLanguageResolvesToNothingWithoutThrowing() { + assertTrue(GrammarRegistry.forPath("build/config.zig").isEmpty()); + } + + @Test + void aFileWithNoExtensionResolvesToNothing() { + assertTrue(GrammarRegistry.forPath("Makefile").isEmpty()); + } + + /** Case is not a language: .JAVA is Java. */ + @Test + void extensionMatchingIsCaseInsensitive() { + assertTrue(GrammarRegistry.forPath("src/Main.JAVA").isPresent()); + } + + @Test + void aDirectoryEndingInAKnownExtensionIsNotAFile() { + assertFalse(GrammarRegistry.forPath("vendor/foo.java/").isPresent()); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :app:test --tests "app.drydock.review.GrammarRegistryTest"` +Expected: FAIL — `cannot find symbol: class GrammarRegistry` + +- [ ] **Step 3: Write minimal implementation** + +`app/build.gradle.kts`, in `dependencies`: + +```kotlin + // Structural parsing for the Review board's change graph (docs/superpowers/ + // specs/2026-08-22-review-navigation-design.md §10). The core artifact + // bundles aarch64/x86_64 macOS, x86_64 Windows and both Linux natives -- + // exactly the platforms this app supports -- and extracts the matching one + // to ~/.tree-sitter/tree-sitter-lib/ on first use. A grammar missing from + // the classpath is the lexical path (GrammarRegistry), not an error, so + // this list is a packaging decision and may differ per artifact. + implementation("io.github.bonede:tree-sitter:0.25.3") + implementation("io.github.bonede:tree-sitter-java:0.23.4") + implementation("io.github.bonede:tree-sitter-kotlin:0.3.8.1") + implementation("io.github.bonede:tree-sitter-python:0.23.4") + implementation("io.github.bonede:tree-sitter-javascript:0.23.1") + implementation("io.github.bonede:tree-sitter-typescript:0.23.2") + implementation("io.github.bonede:tree-sitter-go:0.23.3") + implementation("io.github.bonede:tree-sitter-rust:0.23.1") + implementation("io.github.bonede:tree-sitter-c:0.23.2") + implementation("io.github.bonede:tree-sitter-cpp:0.23.4") +``` + +```java +package app.drydock.review; + +import org.treesitter.TSLanguage; + +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Extension to tree-sitter grammar (spec §10.2). + * + *

A grammar that is absent is the lexical path, not an error. + * That rule is what keeps the shipped language set a packaging decision + * rather than an architectural one: the {@code .app} and the jbang jar may + * ship different sets, and a language nobody packaged produces a coarser + * change graph rather than a broken surface.

+ * + *

Grammars are resolved reflectively and cached. Loading pulls a native + * library out of the jar and {@code System.load}s it, so the first call for + * a language is disk I/O -- never make it on the FX thread.

+ */ +public final class GrammarRegistry { + + private static final Logger LOG = Logger.getLogger(GrammarRegistry.class.getName()); + + /** Extension to the grammar class the artifact publishes, insertion-ordered for determinism. */ + private static final Map GRAMMARS = new LinkedHashMap<>(); + + static { + GRAMMARS.put("java", "org.treesitter.TreeSitterJava"); + GRAMMARS.put("kt", "org.treesitter.TreeSitterKotlin"); + GRAMMARS.put("kts", "org.treesitter.TreeSitterKotlin"); + GRAMMARS.put("py", "org.treesitter.TreeSitterPython"); + GRAMMARS.put("js", "org.treesitter.TreeSitterJavascript"); + GRAMMARS.put("mjs", "org.treesitter.TreeSitterJavascript"); + GRAMMARS.put("ts", "org.treesitter.TreeSitterTypescript"); + GRAMMARS.put("tsx", "org.treesitter.TreeSitterTypescript"); + GRAMMARS.put("go", "org.treesitter.TreeSitterGo"); + GRAMMARS.put("rs", "org.treesitter.TreeSitterRust"); + GRAMMARS.put("c", "org.treesitter.TreeSitterC"); + GRAMMARS.put("h", "org.treesitter.TreeSitterCpp"); + GRAMMARS.put("cc", "org.treesitter.TreeSitterCpp"); + GRAMMARS.put("cpp", "org.treesitter.TreeSitterCpp"); + GRAMMARS.put("hpp", "org.treesitter.TreeSitterCpp"); + } + + private static final Map> CACHE = new LinkedHashMap<>(); + private static volatile boolean nativeFailed; + + private GrammarRegistry() { + } + + /** Whether the native library loaded. False means every file takes the lexical path. */ + public static boolean nativeAvailable() { + return !nativeFailed; + } + + /** The grammar for {@code path}'s language, or empty when there is none. */ + public static synchronized Optional forPath(String path) { + if (path == null || path.endsWith("/")) { + return Optional.empty(); + } + int dot = path.lastIndexOf('.'); + int slash = path.lastIndexOf('/'); + if (dot < 0 || dot < slash || dot == path.length() - 1) { + return Optional.empty(); + } + String extension = path.substring(dot + 1).toLowerCase(Locale.ROOT); + String className = GRAMMARS.get(extension); + if (className == null) { + return Optional.empty(); + } + return CACHE.computeIfAbsent(extension, key -> load(className)); + } + + private static Optional load(String className) { + if (nativeFailed) { + return Optional.empty(); + } + try { + Class type = Class.forName(className); + return Optional.of((TSLanguage) type.getDeclaredConstructor().newInstance()); + } catch (ClassNotFoundException e) { + // The grammar was not packaged for this artifact. Normal, and the + // lexical path handles it -- logging it per file would be noise. + return Optional.empty(); + } catch (ReflectiveOperationException | UnsatisfiedLinkError | RuntimeException e) { + // The native library could not load: unsupported arch, a failed + // extraction, a CRC mismatch. Say it ONCE and fall back for + // everything; per-file logging would bury it. + if (!nativeFailed) { + nativeFailed = true; + LOG.log(Level.WARNING, "tree-sitter unavailable; the change graph " + + "falls back to lexical scanning for every file", e); + } + return Optional.empty(); + } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :app:test --tests "app.drydock.review.GrammarRegistryTest"` +Expected: PASS (5 tests) + +- [ ] **Step 5: Commit** + +```bash +git add app/build.gradle.kts app/src/main/java/app/drydock/review/GrammarRegistry.java \ + app/src/test/java/app/drydock/review/GrammarRegistryTest.java +git commit -m "tree-sitter grammars are a packaging decision, not an architectural one + +A grammar missing from the classpath resolves to empty and the file takes +the lexical path. That single rule is what lets the .app and the jbang jar +ship different language sets, and what makes an unsupported language produce +a coarser change graph rather than a broken surface. + +Failures are told apart on purpose. A grammar class that is simply absent is +the normal case and logs nothing; a native library that cannot load -- wrong +arch, failed extraction, CRC mismatch -- logs once for the process and turns +every file lexical, because logging either one per file would bury the one +that matters. + +The core artifact bundles aarch64/x86_64 macOS, x86_64 Windows and both +Linux natives, which is exactly the platform set this app supports." +``` + +--- + +### Task 9: `SymbolScan` — declarations and uses, two front ends, one shape + +**Files:** +- Create: `app/src/main/java/app/drydock/review/SymbolScan.java` +- Test: `app/src/test/java/app/drydock/review/SymbolScanTest.java` + +**Interfaces:** +- Consumes: `GrammarRegistry.forPath` (Task 8), `app.drydock.review.SymbolWords`, `UnifiedDiff.FileDiff` +- Produces: `record SymbolScan.Symbol(String name, String path, boolean declaration, boolean onChangedLine)`; `static List SymbolScan.of(UnifiedDiff.FileDiff file)` + +- [ ] **Step 1: Write the failing test** + +```java +package app.drydock.review; + +import app.drydock.git.UnifiedDiff; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.OptionalInt; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * What a file contributes to the change graph (spec §4.2). Tree-sitter tells + * us a token is a declaration and another is a call; it does NOT tell us + * which declaration a call resolves to, so it raises the precision of + * classification and not the correctness of resolution. A file with no + * grammar therefore still contributes uses -- it simply cannot claim to + * declare anything, because a lexical scan cannot tell one from the other + * without guessing. + */ +class SymbolScanTest { + + private static UnifiedDiff.FileDiff file(String path, String... addedLines) { + List lines = new java.util.ArrayList<>(); + int n = 1; + for (String text : addedLines) { + lines.add(new UnifiedDiff.Line(UnifiedDiff.Line.Kind.ADD, + OptionalInt.empty(), OptionalInt.of(n++), text)); + } + return new UnifiedDiff.FileDiff(path, "M", addedLines.length, 0, false, false, + List.of(new UnifiedDiff.Hunk("@@ -1,0 +1," + addedLines.length + " @@", lines))); + } + + private static boolean has(List symbols, String name, boolean declaration) { + return symbols.stream().anyMatch(s -> s.name().equals(name) + && s.declaration() == declaration); + } + + @Test + void aGrammarBackedFileDeclaresItsTypesAndMethods() { + List symbols = SymbolScan.of(file("src/Guards.java", + "class JmpCtxScope {", " void install() { helper(); }", "}")); + + assertTrue(has(symbols, "JmpCtxScope", true)); + assertTrue(has(symbols, "install", true)); + assertTrue(has(symbols, "helper", false)); + } + + /** + * The honest floor: no grammar means uses only. Claiming a declaration + * from a regex is exactly the guess this design refuses to make. + */ + @Test + void aFileWithNoGrammarContributesUsesButNoDeclarations() { + List symbols = SymbolScan.of(file("build/setup.zig", + "const JmpCtxScope = struct {};")); + + assertTrue(has(symbols, "JmpCtxScope", false)); + assertFalse(has(symbols, "JmpCtxScope", true)); + } + + /** SymbolWords is the shared vocabulary; keywords are not symbols. */ + @Test + void keywordsAndShortIdentifiersAreNotSymbols() { + List symbols = SymbolScan.of(file("build/setup.zig", + "return id;")); + + assertFalse(symbols.stream().anyMatch(s -> s.name().equals("return"))); + assertFalse(symbols.stream().anyMatch(s -> s.name().equals("id"))); + } + + /** Context lines are scanned but marked, so an edge can require a changed line. */ + @Test + void aSymbolOnAContextLineIsNotOnAChangedLine() { + UnifiedDiff.FileDiff file = new UnifiedDiff.FileDiff("src/Guards.java", "M", 0, 0, + false, false, List.of(new UnifiedDiff.Hunk("@@ -1,1 +1,1 @@", + List.of(new UnifiedDiff.Line(UnifiedDiff.Line.Kind.CONTEXT, + OptionalInt.of(1), OptionalInt.of(1), "helper();"))))); + + assertTrue(SymbolScan.of(file).stream() + .filter(s -> s.name().equals("helper")).noneMatch(SymbolScan.Symbol::onChangedLine)); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :app:test --tests "app.drydock.review.SymbolScanTest"` +Expected: FAIL — `cannot find symbol: class SymbolScan` + +- [ ] **Step 3: Write minimal implementation** + +```java +package app.drydock.review; + +import app.drydock.git.UnifiedDiff; +import org.treesitter.TSLanguage; +import org.treesitter.TSNode; +import org.treesitter.TSParser; +import org.treesitter.TSTree; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.regex.Matcher; + +/** + * One file's symbols: what it declares, what it uses, and whether each sits + * on a changed line (spec §4.2). + * + *

Two front ends behind one shape. With a grammar, declarations come from + * the parse tree. Without one, every occurrence is a use and the + * file declares nothing -- a lexical scan cannot tell a declaration from a + * call without guessing, and a wrong declaration would mint wrong edges + * everywhere the name appears.

+ * + *

Blocking: parsing and (on first use per language) a native library + * load. Never call on the FX thread.

+ */ +public final class SymbolScan { + + /** One symbol occurrence. */ + public record Symbol(String name, String path, boolean declaration, boolean onChangedLine) { + } + + /** tree-sitter node types that introduce a name, across the shipped grammars. */ + private static final List DECLARATION_NODES = List.of( + "class_declaration", "interface_declaration", "record_declaration", + "enum_declaration", "method_declaration", "constructor_declaration", + "function_definition", "function_declarator", "function_declaration", + "struct_specifier", "class_specifier", "enum_specifier", "type_definition", + "field_declaration", "function_item", "struct_item", "enum_item", "impl_item", + "class_definition", "type_alias_declaration", "object_declaration"); + + private SymbolScan() { + } + + /** {@code file}'s symbols, in source order. */ + public static List of(UnifiedDiff.FileDiff file) { + Optional grammar = GrammarRegistry.forPath(file.path()); + List symbols = new ArrayList<>(); + for (UnifiedDiff.Hunk hunk : file.hunks()) { + for (UnifiedDiff.Line line : hunk.lines()) { + boolean changed = line.kind() != UnifiedDiff.Line.Kind.CONTEXT; + if (grammar.isPresent()) { + symbols.addAll(parsed(grammar.get(), file.path(), line.text(), changed)); + } else { + symbols.addAll(lexical(file.path(), line.text(), changed)); + } + } + } + return List.copyOf(symbols); + } + + /** + * Line-at-a-time parsing. A diff line is not a compilation unit, so the + * tree is usually an ERROR node with recognisable children -- which is + * enough for "is this token introducing a name", the only question asked + * here, and avoids reconstructing whole files from a diff. + */ + private static List parsed(TSLanguage language, String path, String text, + boolean changed) { + List symbols = new ArrayList<>(); + TSParser parser = new TSParser(); + try { + parser.setLanguage(language); + TSTree tree = parser.parseString(null, text); + walk(tree.getRootNode(), text, path, changed, false, symbols); + } catch (RuntimeException e) { + // A grammar that cannot parse a fragment is not a reason to lose + // the file: fall back to the same lexical scan an ungrammared + // file gets. + return lexical(path, text, changed); + } + return symbols; + } + + private static void walk(TSNode node, String text, String path, boolean changed, + boolean inDeclaration, List out) { + boolean declaring = inDeclaration || DECLARATION_NODES.contains(node.getType()); + if ("identifier".equals(node.getType()) || "type_identifier".equals(node.getType()) + || "field_identifier".equals(node.getType())) { + String name = text.substring(node.getStartByte(), node.getEndByte()); + if (SymbolWords.isSymbol(name)) { + out.add(new Symbol(name, path, declaring, changed)); + } + return; + } + for (int i = 0; i < node.getChildCount(); i++) { + walk(node.getChild(i), text, path, changed, declaring, out); + } + } + + private static List lexical(String path, String text, boolean changed) { + List symbols = new ArrayList<>(); + Matcher matcher = SymbolWords.IDENTIFIER.matcher(text); + while (matcher.find()) { + String name = matcher.group(); + if (SymbolWords.isSymbol(name)) { + symbols.add(new Symbol(name, path, false, changed)); + } + } + return symbols; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :app:test --tests "app.drydock.review.SymbolScanTest"` +Expected: PASS (4 tests) + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/app/drydock/review/SymbolScan.java \ + app/src/test/java/app/drydock/review/SymbolScanTest.java +git commit -m "A file contributes what it declares and what it uses, however it is parsed + +Two front ends, one shape. With a grammar, declarations come from the parse +tree. Without one, every occurrence is a use and the file declares nothing -- +a lexical scan cannot tell a declaration from a call without guessing, and a +wrong declaration mints wrong edges everywhere that name appears. + +That asymmetry is the honest reading of what tree-sitter buys: it tells us a +token is a declaration and another is a call, not which declaration a call +resolves to. It raises the precision of classification, not the correctness +of resolution, which is why an ungrammared file degrades to a usable graph +rather than to nothing." +``` + +--- + +### Task 10: `ChangeGraph` — one edge rule, whichever front end found the symbol + +**Files:** +- Create: `app/src/main/java/app/drydock/review/ChangeGraph.java` +- Test: `app/src/test/java/app/drydock/review/ChangeGraphTest.java` + +**Interfaces:** +- Consumes: `SymbolScan.of` (Task 9), `UnifiedDiff` +- Produces: `static ChangeGraph ChangeGraph.of(UnifiedDiff diff)`; `SortedSet files()`; `SortedSet declarationsIn(String file)`; `SortedSet filesReferencedBy(String file)`; `SortedSet filesReferencing(String file)`; `Optional fileDeclaring(String symbol)`; `SortedSet changedDeclarations()` + +- [ ] **Step 1: Write the failing test** + +```java +package app.drydock.review; + +import app.drydock.git.UnifiedDiff; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.OptionalInt; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The one matching rule (spec §4.2): a use resolves to a declaration only + * when EXACTLY ONE changed declaration in the scope carries that name, and + * only across files. Ambiguous names mint nothing -- a false edge sends a + * reviewer to unrelated code and is worse than a missing one -- and + * intra-file edges are noise from short-name matching. + */ +class ChangeGraphTest { + + private static UnifiedDiff.FileDiff file(String path, String... added) { + List lines = new java.util.ArrayList<>(); + int n = 1; + for (String text : added) { + lines.add(new UnifiedDiff.Line(UnifiedDiff.Line.Kind.ADD, + OptionalInt.empty(), OptionalInt.of(n++), text)); + } + return new UnifiedDiff.FileDiff(path, "M", added.length, 0, false, false, + List.of(new UnifiedDiff.Hunk("@@", lines))); + } + + @Test + void aUniqueDeclarationUsedInAnotherFileMintsAnEdge() { + ChangeGraph graph = ChangeGraph.of(new UnifiedDiff(List.of( + file("src/Guards.java", "class JmpCtxScope { }"), + file("src/Profiler.java", "void go() { new JmpCtxScope(); }")))); + + assertTrue(graph.filesReferencedBy("src/Profiler.java").contains("src/Guards.java")); + assertTrue(graph.filesReferencing("src/Guards.java").contains("src/Profiler.java")); + } + + /** Two declarations of one name cannot be told apart, so neither is linked. */ + @Test + void anAmbiguousNameMintsNoEdge() { + ChangeGraph graph = ChangeGraph.of(new UnifiedDiff(List.of( + file("src/A.java", "class Helper { }"), + file("src/B.java", "class Helper { }"), + file("src/C.java", "void go() { new Helper(); }")))); + + assertEquals(List.of(), List.copyOf(graph.filesReferencedBy("src/C.java"))); + } + + @Test + void aReferenceWithinOneFileMintsNoEdge() { + ChangeGraph graph = ChangeGraph.of(new UnifiedDiff(List.of( + file("src/Guards.java", "class JmpCtxScope { }", "void go() { new JmpCtxScope(); }")))); + + assertEquals(List.of(), List.copyOf(graph.filesReferencedBy("src/Guards.java"))); + } + + @Test + void aDeclarationIsFoundByName() { + ChangeGraph graph = ChangeGraph.of(new UnifiedDiff(List.of( + file("src/Guards.java", "class JmpCtxScope { }")))); + + assertEquals(java.util.Optional.of("src/Guards.java"), graph.fileDeclaring("JmpCtxScope")); + } + + /** Determinism: iteration order is a property this graph must keep (spec §9.5). */ + @Test + void everyExposedCollectionIsSorted() { + ChangeGraph graph = ChangeGraph.of(new UnifiedDiff(List.of( + file("src/Z.java", "class Zed { }"), + file("src/A.java", "void go() { new Zed(); }")))); + + assertEquals(List.of("src/A.java", "src/Z.java"), List.copyOf(graph.files())); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :app:test --tests "app.drydock.review.ChangeGraphTest"` +Expected: FAIL — `cannot find symbol: class ChangeGraph` + +- [ ] **Step 3: Write minimal implementation** + +```java +package app.drydock.review; + +import app.drydock.git.UnifiedDiff; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.SortedSet; +import java.util.TreeMap; +import java.util.TreeSet; + +/** + * The changed symbols of one scope and the references between them + * (spec §4). + * + *

In memory, scope lifetime, rebuilt when the diff is re-read. Nothing is + * persisted -- the reference implementation keeps a SQLite graph only because + * it is a multi-process pipeline, and one process needs no file, no + * invalidation story and no collection.

+ * + *

Every exposed collection is sorted. Determinism is a requirement here, + * not a property (spec §9.5), and hash iteration order is the cheapest way + * to lose it.

+ */ +public final class ChangeGraph { + + private final SortedSet files; + private final Map> declarationsByFile; + private final Map fileByUniqueDeclaration; + private final Map> referencesOut; + private final Map> referencesIn; + + private ChangeGraph(SortedSet files, + Map> declarationsByFile, + Map fileByUniqueDeclaration, + Map> referencesOut, + Map> referencesIn) { + this.files = files; + this.declarationsByFile = declarationsByFile; + this.fileByUniqueDeclaration = fileByUniqueDeclaration; + this.referencesOut = referencesOut; + this.referencesIn = referencesIn; + } + + /** Builds the graph for {@code diff}. Blocking; never call on the FX thread. */ + public static ChangeGraph of(UnifiedDiff diff) { + Map> scans = new LinkedHashMap<>(); + for (UnifiedDiff.FileDiff file : diff.files()) { + scans.put(file.path(), SymbolScan.of(file)); + } + + // A name declared in more than one changed file cannot be resolved, + // so it is dropped rather than guessed at. + Map> declaringFiles = new TreeMap<>(); + Map> declarationsByFile = new TreeMap<>(); + for (Map.Entry> entry : scans.entrySet()) { + for (SymbolScan.Symbol symbol : entry.getValue()) { + if (symbol.declaration() && symbol.onChangedLine()) { + declaringFiles.computeIfAbsent(symbol.name(), key -> new ArrayList<>()) + .add(entry.getKey()); + declarationsByFile.computeIfAbsent(entry.getKey(), key -> new TreeSet<>()) + .add(symbol.name()); + } + } + } + Map unique = new TreeMap<>(); + for (Map.Entry> entry : declaringFiles.entrySet()) { + List distinct = entry.getValue().stream().distinct().toList(); + if (distinct.size() == 1) { + unique.put(entry.getKey(), distinct.get(0)); + } + } + + Map> out = new TreeMap<>(); + Map> in = new TreeMap<>(); + for (Map.Entry> entry : scans.entrySet()) { + for (SymbolScan.Symbol symbol : entry.getValue()) { + String target = unique.get(symbol.name()); + // Cross-file only: an intra-file match is noise from + // short-name matching, not a relationship worth showing. + if (target == null || target.equals(entry.getKey())) { + continue; + } + out.computeIfAbsent(entry.getKey(), key -> new TreeSet<>()).add(target); + in.computeIfAbsent(target, key -> new TreeSet<>()).add(entry.getKey()); + } + } + + SortedSet files = new TreeSet<>(scans.keySet()); + return new ChangeGraph(files, declarationsByFile, unique, out, in); + } + + public SortedSet files() { + return java.util.Collections.unmodifiableSortedSet(files); + } + + public SortedSet declarationsIn(String file) { + return declarationsByFile.getOrDefault(file, new TreeSet<>()); + } + + /** Files {@code file} references. */ + public SortedSet filesReferencedBy(String file) { + return referencesOut.getOrDefault(file, new TreeSet<>()); + } + + /** Files that reference {@code file}. */ + public SortedSet filesReferencing(String file) { + return referencesIn.getOrDefault(file, new TreeSet<>()); + } + + /** The one changed file declaring {@code symbol}, when exactly one does. */ + public Optional fileDeclaring(String symbol) { + return Optional.ofNullable(fileByUniqueDeclaration.get(symbol)); + } + + /** Every uniquely-declared changed symbol name. */ + public SortedSet changedDeclarations() { + return new TreeSet<>(fileByUniqueDeclaration.keySet()); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :app:test --tests "app.drydock.review.ChangeGraphTest"` +Expected: PASS (5 tests) + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/app/drydock/review/ChangeGraph.java \ + app/src/test/java/app/drydock/review/ChangeGraphTest.java +git commit -m "The change graph resolves a name only when exactly one file declares it + +One matching rule whichever front end found the symbol: a use resolves to a +declaration only when exactly one changed declaration in the scope carries +that name, and only across files. An ambiguous name mints nothing, because a +false edge sends a reviewer to unrelated code and is worse than a missing +one; an intra-file match is noise from short-name matching. + +In memory and scope-lifetime, with no file behind it. The reference +implementation persists its graph only because it is a multi-process +pipeline; one process needs no invalidation story and nothing to collect. + +Every exposed collection is sorted, because determinism here is a +requirement rather than a property and hash iteration order is the cheapest +way to lose it." +``` + +--- + +### Task 11: `Graphs` — Kahn and Tarjan, with a caller-supplied total tie-break + +**Files:** +- Create: `app/src/main/java/app/drydock/review/Graphs.java` +- Test: `app/src/test/java/app/drydock/review/GraphsTest.java` + +**Interfaces:** +- Produces: `static List> Graphs.topologicalOrder(SortedSet nodes, Function> dependsOn, Comparator tieBreak)` — returns units in reading order, each unit a strongly-connected component (a single-element list for an ordinary node, several for a cycle) + +- [ ] **Step 1: Write the failing test** + +```java +package app.drydock.review; + +import org.junit.jupiter.api.Test; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.SortedSet; +import java.util.TreeSet; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Order and cycles (spec §6.1). Foundation first: if A is referenced by B, + * A is read before B. A cycle is collapsed into one named unit rather than + * broken arbitrarily -- a cycle among changed symbols is a fact about the + * change worth showing, and a silent arbitrary break is the unexplained + * ordering this whole feature exists to remove. + */ +class GraphsTest { + + private static SortedSet set(String... values) { + return new TreeSet<>(List.of(values)); + } + + private static List> order(Map> dependsOn) { + return Graphs.topologicalOrder(new TreeSet<>(dependsOn.keySet()), + node -> dependsOn.getOrDefault(node, new TreeSet<>()), + Comparator.naturalOrder()); + } + + @Test + void aDependencyIsReadBeforeItsDependent() { + assertEquals(List.of(List.of("guards"), List.of("profiler")), + order(Map.of("profiler", set("guards"), "guards", set()))); + } + + @Test + void independentNodesFallBackToTheTieBreak() { + assertEquals(List.of(List.of("a"), List.of("b"), List.of("c")), + order(Map.of("c", set(), "a", set(), "b", set()))); + } + + @Test + void aCycleBecomesOneUnitHoldingItsMembers() { + List> result = order(Map.of("a", set("b"), "b", set("a"), "c", set("a"))); + + assertEquals(List.of("a", "b"), result.get(0)); + assertEquals(List.of("c"), result.get(1)); + } + + /** + * Determinism, pinned: the same graph presented in a different insertion + * order must produce the identical result (spec §9.5). + */ + @Test + void theOrderDoesNotDependOnInsertionOrder() { + assertEquals(order(Map.of("a", set(), "b", set("a"), "c", set("b"))), + order(Map.of("c", set("b"), "a", set(), "b", set("a")))); + } + + @Test + void anEmptyGraphOrdersToNothing() { + assertEquals(List.of(), order(Map.of())); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :app:test --tests "app.drydock.review.GraphsTest"` +Expected: FAIL — `cannot find symbol: class Graphs` + +- [ ] **Step 3: Write minimal implementation** + +```java +package app.drydock.review; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Deque; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.SortedSet; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.function.Function; + +/** + * Kahn and Tarjan (spec §2.3, §6.1). + * + *

Hand-rolled rather than taken from a graph library: what this design + * asks of a graph is a topological sort, strongly-connected components and + * reachability over tens of nodes, and a library costs a megabyte of + * transitives, an entry in the jlink module list that a test pins against + * jdeps, and a POM dependency the jbang jar bundles nothing of.

+ * + *

The tie-break is supplied by the caller and must be TOTAL: two runs may + * not order equal units differently (spec §9.5).

+ */ +public final class Graphs { + + private Graphs() { + } + + /** + * {@code nodes} in reading order, foundation first. Each entry is one + * unit: a single node, or the members of a cycle collapsed together and + * ordered by {@code tieBreak}. + */ + public static List> topologicalOrder(SortedSet nodes, + Function> dependsOn, + Comparator tieBreak) { + List> components = stronglyConnected(nodes, dependsOn, tieBreak); + + Map componentOf = new LinkedHashMap<>(); + for (int index = 0; index < components.size(); index++) { + for (T member : components.get(index)) { + componentOf.put(member, index); + } + } + + // Condense to a DAG over components, then Kahn it. + Map> prerequisites = new TreeMap<>(); + Map> dependents = new TreeMap<>(); + for (int index = 0; index < components.size(); index++) { + prerequisites.put(index, new TreeSet<>()); + dependents.put(index, new TreeSet<>()); + } + for (T node : nodes) { + for (T prerequisite : dependsOn.apply(node)) { + Integer from = componentOf.get(prerequisite); + Integer to = componentOf.get(node); + if (from == null || to == null || from.equals(to)) { + continue; + } + prerequisites.get(to).add(from); + dependents.get(from).add(to); + } + } + + Comparator byFirstMember = + Comparator.comparing(index -> components.get(index).get(0), tieBreak); + TreeSet ready = new TreeSet<>(byFirstMember); + for (int index = 0; index < components.size(); index++) { + if (prerequisites.get(index).isEmpty()) { + ready.add(index); + } + } + + List> ordered = new ArrayList<>(); + while (!ready.isEmpty()) { + Integer next = ready.first(); + ready.remove(next); + ordered.add(components.get(next)); + for (Integer dependent : dependents.get(next)) { + SortedSet remaining = prerequisites.get(dependent); + remaining.remove(next); + if (remaining.isEmpty()) { + ready.add(dependent); + } + } + } + return List.copyOf(ordered); + } + + /** Tarjan, iterative so a deep graph cannot overflow the stack. */ + private static List> stronglyConnected(SortedSet nodes, + Function> edges, + Comparator tieBreak) { + Map index = new LinkedHashMap<>(); + Map lowLink = new LinkedHashMap<>(); + Deque stack = new ArrayDeque<>(); + java.util.Set onStack = new java.util.LinkedHashSet<>(); + List> components = new ArrayList<>(); + int[] counter = {0}; + + for (T root : nodes) { + if (index.containsKey(root)) { + continue; + } + Deque work = new ArrayDeque<>(); + Deque> pending = new ArrayDeque<>(); + work.push(root); + pending.push(edges.apply(root).iterator()); + index.put(root, counter[0]); + lowLink.put(root, counter[0]++); + stack.push(root); + onStack.add(root); + + while (!work.isEmpty()) { + T node = work.peek(); + java.util.Iterator children = pending.peek(); + if (children.hasNext()) { + T child = children.next(); + if (!nodes.contains(child)) { + continue; + } + if (!index.containsKey(child)) { + index.put(child, counter[0]); + lowLink.put(child, counter[0]++); + stack.push(child); + onStack.add(child); + work.push(child); + pending.push(edges.apply(child).iterator()); + } else if (onStack.contains(child)) { + lowLink.put(node, Math.min(lowLink.get(node), index.get(child))); + } + } else { + work.pop(); + pending.pop(); + if (!work.isEmpty()) { + T parent = work.peek(); + lowLink.put(parent, Math.min(lowLink.get(parent), lowLink.get(node))); + } + if (lowLink.get(node).equals(index.get(node))) { + List component = new ArrayList<>(); + T member; + do { + member = stack.pop(); + onStack.remove(member); + component.add(member); + } while (!member.equals(node)); + component.sort(tieBreak); + components.add(component); + } + } + } + } + components.sort(Comparator.comparing(c -> c.get(0), tieBreak)); + return components; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :app:test --tests "app.drydock.review.GraphsTest"` +Expected: PASS (5 tests) + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/app/drydock/review/Graphs.java \ + app/src/test/java/app/drydock/review/GraphsTest.java +git commit -m "Kahn and Tarjan, hand-rolled, with a total tie-break + +What this design asks of a graph is a topological sort, strongly-connected +components and reachability over tens of nodes. jgrapht-core costs 1.27MB +plus jheaps and an arbitrary-precision math transitive, an entry in the +jlink --add-modules list that RuntimeImageModuleListTest pins against jdeps, +and a POM dependency the jbang jar bundles nothing of. Three textbook +algorithms do not buy that. + +A cycle collapses into one unit rather than being broken arbitrarily: a +cycle among changed symbols is a fact about the change worth showing, and a +silent arbitrary break is the unexplained ordering this feature exists to +remove. Tarjan is iterative so a deep graph cannot overflow the stack, and +the tie-break is caller-supplied and must be total -- two runs ordering equal +units differently is how the determinism requirement gets lost." +``` + +--- + +### Task 12: `Sections` — components, header conventions, hub titles, overlap + +**Files:** +- Create: `app/src/main/java/app/drydock/review/Sections.java` +- Test: `app/src/test/java/app/drydock/review/SectionsTest.java` + +**Interfaces:** +- Consumes: `ChangeGraph` (Task 10), `Graphs.topologicalOrder` (Task 11), `UnifiedDiff` +- Produces: `record Sections.Section(String title, List files, List hunkIds, Optional hubSymbol, List cycleWith)`; `static List
Sections.of(UnifiedDiff diff, ChangeGraph graph)` + +- [ ] **Step 1: Write the failing test** + +```java +package app.drydock.review; + +import app.drydock.git.UnifiedDiff; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.OptionalInt; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Sections follow the code's structure, not its folders (spec §5). + * + *

The failure this replaces, measured on a real C++ change: cards reading + * "main/cpp · 12 files", "test/cpp · 4 files", "cpp/hotspot · 6 files" -- + * each individually correct and collectively saying nothing, because the + * grouping had no structural input at all.

+ */ +class SectionsTest { + + private static UnifiedDiff.FileDiff file(String path, String... added) { + List lines = new java.util.ArrayList<>(); + int n = 1; + for (String text : added) { + lines.add(new UnifiedDiff.Line(UnifiedDiff.Line.Kind.ADD, + OptionalInt.empty(), OptionalInt.of(n++), text)); + } + return new UnifiedDiff.FileDiff(path, "M", added.length, 0, false, false, + List.of(new UnifiedDiff.Hunk("@@", lines))); + } + + private static List sectionsOf(UnifiedDiff diff) { + return Sections.of(diff, ChangeGraph.of(diff)); + } + + private static Sections.Section sectionContaining(List sections, String file) { + return sections.stream().filter(s -> s.files().contains(file)).findFirst().orElseThrow(); + } + + /** The convention a C or C++ change is unreadable without. */ + @Test + void aHeaderGroupsWithItsSameBasenameImplementation() { + List sections = sectionsOf(new UnifiedDiff(List.of( + file("src/guards.h", "class JmpCtxScope { };"), + file("src/guards.cpp", "void install() { }")))); + + assertTrue(sectionContaining(sections, "src/guards.h").files().contains("src/guards.cpp")); + } + + /** + * The counters.h case from the reference output: a header with no changed + * symbol of its own still belongs with the file that pulls it in. + */ + @Test + void aHeaderGroupsWithAChangedImplementationThatReferencesIt() { + List sections = sectionsOf(new UnifiedDiff(List.of( + file("src/counters.h", "#define FAULTS 1"), + file("src/profiler.cpp", "#include \"counters.h\"", "void go() { }")))); + + assertTrue(sectionContaining(sections, "src/profiler.cpp").files().contains("src/counters.h")); + } + + /** Overlap is the point (spec §5.6): a shared header appears in both. */ + @Test + void aFileNeededByTwoSectionsAppearsInBoth() { + List sections = sectionsOf(new UnifiedDiff(List.of( + file("src/guards.h", "class JmpCtxScope { };"), + file("src/a.cpp", "#include \"guards.h\"", "void a() { new JmpCtxScope(); }"), + file("src/b.cpp", "#include \"guards.h\"", "void b() { new JmpCtxScope(); }")))); + + long appearances = sections.stream().filter(s -> s.files().contains("src/guards.h")).count(); + assertTrue(appearances >= 2, "a shared header must appear wherever it is needed"); + } + + /** Foundation first: the guard is read before what uses it. */ + @Test + void sectionsAreOrderedByDependencyDirection() { + List sections = sectionsOf(new UnifiedDiff(List.of( + file("src/profiler.cpp", "void go() { new JmpCtxScope(); }"), + file("src/guards.cpp", "class JmpCtxScope { };")))); + + assertEquals("src/guards.cpp", sections.get(0).files().get(0)); + } + + /** A test referencing a changed symbol lands with it -- no path-based split. */ + @Test + void aTestReferencingAChangedSymbolIsInThatSymbolsSection() { + List sections = sectionsOf(new UnifiedDiff(List.of( + file("src/guards.cpp", "class JmpCtxScope { };"), + file("test/guards_ut.cpp", "void t() { new JmpCtxScope(); }")))); + + assertTrue(sectionContaining(sections, "src/guards.cpp") + .files().contains("test/guards_ut.cpp")); + } + + /** A test referencing nothing changed is its own section, honestly. */ + @Test + void aTestReferencingNothingChangedFormsItsOwnSection() { + List sections = sectionsOf(new UnifiedDiff(List.of( + file("src/guards.cpp", "class JmpCtxScope { };"), + file("test/unrelated_ut.cpp", "void t() { checkSomethingElse(); }")))); + + assertEquals(List.of("test/unrelated_ut.cpp"), + sectionContaining(sections, "test/unrelated_ut.cpp").files()); + } + + /** The name is the thing, not the folder. */ + @Test + void aSectionIsTitledByItsHighestFanInChangedSymbol() { + List sections = sectionsOf(new UnifiedDiff(List.of( + file("src/guards.cpp", "class JmpCtxScope { };"), + file("src/a.cpp", "void a() { new JmpCtxScope(); }"), + file("src/b.cpp", "void b() { new JmpCtxScope(); }")))); + + assertTrue(sections.get(0).title().startsWith("JmpCtxScope"), + "expected a hub-symbol title, got: " + sections.get(0).title()); + } + + /** With nothing to consult, today's behaviour survives unchanged. */ + @Test + void anEdgelessDiffFallsBackToDirectoryClustering() { + UnifiedDiff diff = new UnifiedDiff(List.of( + file("web/a.zzz", "nothing"), file("web/b.zzz", "nothing"))); + + assertEquals(FallbackIntents.group(diff).size(), sectionsOf(diff).size()); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :app:test --tests "app.drydock.review.SectionsTest"` +Expected: FAIL — `cannot find symbol: class Sections` + +- [ ] **Step 3: Write minimal implementation** + +```java +package app.drydock.review; + +import app.drydock.git.UnifiedDiff; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.SortedSet; +import java.util.TreeMap; +import java.util.TreeSet; + +/** + * The change's sections: connected components of the file-level reference + * graph, plus the two conventions a C or C++ change is unreadable without + * (spec §5.2). + * + *

Sections overlap. A file appears in every section that + * needs it to be understood -- a header belongs with its implementation AND + * with everything that references it, and with disjoint membership one of + * those has to lose. The reviewed flag is keyed to hunk content, so a file + * shown three times is still read once (spec §5.6, §9).

+ * + *

Tests are NOT split out. A test references the symbol under test, so + * the graph already places it; splitting on {@code /test/} would be a path + * heuristic drawing a boundary through a structurally sound group, which is + * the very failure this class replaces.

+ */ +public final class Sections { + + /** One section. {@code cycleWith} is non-empty when it is part of a dependency cycle. */ + public record Section(String title, List files, List hunkIds, + Optional hubSymbol, List cycleWith) { + public Section { + files = List.copyOf(files); + hunkIds = List.copyOf(hunkIds); + cycleWith = List.copyOf(cycleWith); + } + } + + private Sections() { + } + + /** {@code diff}'s sections, in reading order. */ + public static List
of(UnifiedDiff diff, ChangeGraph graph) { + Map> neighbours = neighbours(diff, graph); + boolean anyEdge = neighbours.values().stream().anyMatch(set -> !set.isEmpty()); + if (!anyEdge) { + // Nothing structural to consult: today's (kind, directory) + // clustering is still the best available guess. + return fromFallback(diff); + } + + List> units = Graphs.topologicalOrder( + new TreeSet<>(neighbours.keySet()), + file -> graph.filesReferencedBy(file), + Comparator.naturalOrder()); + + List
sections = new ArrayList<>(); + for (List unit : units) { + Set files = new LinkedHashSet<>(unit); + for (String file : unit) { + files.addAll(neighbours.getOrDefault(file, new TreeSet<>())); + } + List ordered = new ArrayList<>(files); + ordered.sort(Comparator.naturalOrder()); + // The unit's own members lead: they are what the section is + // about, and the pulled-in neighbours are context. + ordered.sort(Comparator.comparing(file -> unit.contains(file) ? 0 : 1)); + Optional hub = hubOf(ordered, graph); + sections.add(new Section( + title(ordered, hub), + ordered, + hunkIdsOf(diff, ordered), + hub, + unit.size() > 1 ? unit : List.of())); + } + return List.copyOf(sections); + } + + /** + * What each file is grouped with: its references, its same-basename + * counterpart, and any changed file that references it at file level. + */ + private static Map> neighbours(UnifiedDiff diff, ChangeGraph graph) { + Map> result = new TreeMap<>(); + for (UnifiedDiff.FileDiff file : diff.files()) { + result.put(file.path(), new TreeSet<>()); + } + for (String file : result.keySet()) { + result.get(file).addAll(graph.filesReferencedBy(file)); + result.get(file).addAll(graph.filesReferencing(file)); + } + // Header convention: same basename, different extension. + for (String left : new TreeSet<>(result.keySet())) { + for (String right : new TreeSet<>(result.keySet())) { + if (!left.equals(right) && basename(left).equals(basename(right))) { + result.get(left).add(right); + } + } + } + // A header a changed file names in an include or import belongs with + // it even when the header declares no changed symbol of its own. + for (UnifiedDiff.FileDiff file : diff.files()) { + for (String other : new TreeSet<>(result.keySet())) { + if (!other.equals(file.path()) && mentionsFileName(file, other)) { + result.get(file.path()).add(other); + result.get(other).add(file.path()); + } + } + } + return result; + } + + private static boolean mentionsFileName(UnifiedDiff.FileDiff file, String other) { + String name = other.substring(other.lastIndexOf('/') + 1); + for (UnifiedDiff.Hunk hunk : file.hunks()) { + for (UnifiedDiff.Line line : hunk.lines()) { + if (line.text().contains(name)) { + return true; + } + } + } + return false; + } + + private static String basename(String path) { + String name = path.substring(path.lastIndexOf('/') + 1); + int dot = name.lastIndexOf('.'); + return dot < 0 ? name : name.substring(0, dot); + } + + /** The section's most-referenced changed symbol: what the section is about. */ + private static Optional hubOf(List files, ChangeGraph graph) { + String best = null; + int bestFanIn = 0; + for (String file : files) { + for (String symbol : graph.declarationsIn(file)) { + int fanIn = graph.filesReferencing(file).size(); + if (fanIn > bestFanIn || (fanIn == bestFanIn && best != null + && symbol.compareTo(best) < 0)) { + best = symbol; + bestFanIn = fanIn; + } + } + } + return Optional.ofNullable(bestFanIn > 0 ? best : null); + } + + private static String title(List files, Optional hub) { + String count = files.size() + (files.size() == 1 ? " file" : " files"); + return hub.map(symbol -> symbol + " · " + count) + // No symbol dominates: the directory tail is still the most + // specific true thing that can be said. + .orElseGet(() -> FallbackIntents.directoryOf(files.get(0)) + " · " + count); + } + + private static List hunkIdsOf(UnifiedDiff diff, List files) { + List ids = new ArrayList<>(); + for (UnifiedDiff.FileDiff file : diff.files()) { + if (!files.contains(file.path())) { + continue; + } + for (int hunk = 0; hunk < file.hunks().size(); hunk++) { + ids.add(ReviewIntent.hunkId(file.path(), hunk)); + } + } + return ids; + } + + private static List
fromFallback(UnifiedDiff diff) { + List
sections = new ArrayList<>(); + for (ReviewIntent intent : FallbackIntents.group(diff)) { + sections.add(new Section(intent.title(), intent.files(), intent.hunkIds(), + Optional.empty(), List.of())); + } + return List.copyOf(sections); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :app:test --tests "app.drydock.review.SectionsTest"` +Expected: PASS (8 tests) + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/app/drydock/review/Sections.java \ + app/src/test/java/app/drydock/review/SectionsTest.java +git commit -m "Sections follow the code's structure, not its folders + +Measured on a real C++ change, the old grouping produced main/cpp · 12 +files, test/cpp · 4 files, cpp/hotspot · 6 files: each card individually +correct, the rail collectively saying nothing, because (kind, directory) has +no structural input at all. Sections are now connected components of the +file-level reference graph, ordered foundation-first, titled by the +component's highest-fan-in changed symbol. + +Two conventions carried in because a C or C++ change is unreadable without +them: a .h groups with its same-basename .cpp, and a header groups with any +changed file that names it even when the header declares no changed symbol +of its own -- the counters.h case. + +Sections overlap. A header belongs with its implementation AND with +everything referencing it, and with disjoint membership one of those has to +lose. Tests are not split out: a test references the symbol under test, so +the graph already places it, and splitting on /test/ would draw a path-based +boundary through a structurally sound group -- the very failure this +replaces. With no edges to consult, today's directory clustering survives +untouched." +``` + +--- + +### Task 13: The rail renders computed sections, and determinism is pinned + +**Files:** +- Modify: `app/src/main/java/app/drydock/review/IntentGrouping.java` (`intentsFor`) +- Modify: `app/src/main/java/app/drydock/ui/review/SessionReviewView.java` (build the graph off the FX thread, hand it to `IntentGrouping`) +- Test: `app/src/test/java/app/drydock/review/SectionDeterminismTest.java` + +**Interfaces:** +- Consumes: `Sections.of` (Task 12), `ChangeGraph.of` (Task 10) +- Produces: `List IntentGrouping.intentsFor(String scopeId, UnifiedDiff diff, Optional graph)` + +- [ ] **Step 1: Write the failing test** + +```java +package app.drydock.review; + +import app.drydock.git.UnifiedDiff; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.OptionalInt; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Calling the computed layer stable is a claim the code has to keep + * (spec §9.5). The cheapest way to lose it is a hash-ordered collection, and + * the hardest place to notice is a single JVM, which usually agrees with + * itself. The cross-process half of that check is the running-app pass; this + * pins the in-process half and the shape the other half compares. + */ +class SectionDeterminismTest { + + private static UnifiedDiff diff() { + List files = new java.util.ArrayList<>(); + for (String path : List.of("src/z.cpp", "src/a.cpp", "src/m.h", "src/m.cpp")) { + files.add(new UnifiedDiff.FileDiff(path, "M", 1, 0, false, false, + List.of(new UnifiedDiff.Hunk("@@", List.of(new UnifiedDiff.Line( + UnifiedDiff.Line.Kind.ADD, OptionalInt.empty(), OptionalInt.of(1), + "void go() { helperOne(); }")))))); + } + return new UnifiedDiff(files); + } + + private static List titles() { + UnifiedDiff diff = diff(); + return Sections.of(diff, ChangeGraph.of(diff)).stream() + .map(Sections.Section::title).toList(); + } + + @Test + void theSameDiffProducesTheSameSectionsEveryTime() { + assertEquals(titles(), titles()); + } + + @Test + void theSameDiffProducesTheSameHunkOrderEveryTime() { + UnifiedDiff diff = diff(); + assertEquals(Sections.of(diff, ChangeGraph.of(diff)).stream() + .map(Sections.Section::hunkIds).toList(), + Sections.of(diff, ChangeGraph.of(diff)).stream() + .map(Sections.Section::hunkIds).toList()); + } + + /** A reviewer's grouping still wins; the computed one is the fallback. */ + @Test + void aReviewerGroupingIsNotRecomputed() { + IntentGrouping grouping = new IntentGrouping(); + ReviewIntent supplied = new ReviewIntent("agent-1", 1, "Crash-protected resolve()", + ReviewIntent.Kind.CHANGE, ReviewIntent.Risk.HIGH, "", + List.of(ReviewIntent.hunkId("src/a.cpp", 0)), java.util.Optional.empty(), false); + grouping.set("scope-1", List.of(supplied)); + + UnifiedDiff diff = diff(); + List intents = grouping.intentsFor("scope-1", diff, + java.util.Optional.of(ChangeGraph.of(diff))); + + assertEquals(List.of("Crash-protected resolve()"), + intents.stream().map(ReviewIntent::title).toList()); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :app:test --tests "app.drydock.review.SectionDeterminismTest"` +Expected: FAIL — `intentsFor` takes two arguments + +- [ ] **Step 3: Write minimal implementation** + +```java + /** + * {@code scopeId}'s intents: the reviewer's grouping when there is one, + * otherwise the computed sections -- and, with no graph to compute from, + * {@link FallbackIntents}' clustering of {@code diff}. + * + *

A reviewer's grouping is never re-sorted or re-drawn. It came from + * something that read the change; recomputing over it would be drydock + * overruling the reviewer.

+ */ + public List intentsFor(String scopeId, UnifiedDiff diff, + Optional graph) { + List supplied = byScope.get(scopeId); + if (supplied != null) { + return supplied; + } + if (graph.isEmpty()) { + return FallbackIntents.group(diff); + } + List computed = new ArrayList<>(); + int number = 1; + for (Sections.Section section : Sections.of(diff, graph.get())) { + computed.add(new ReviewIntent("computed:" + number, number, + section.title(), ReviewIntent.Kind.CHANGE, ReviewIntent.Risk.NONE, + rationale(section), section.hunkIds(), Optional.empty(), false)); + number++; + } + return List.copyOf(computed); + } + + /** + * What a computed section says for itself with no agent to name it: the + * structural facts, and the cycle when it is in one. + */ + private static String rationale(Sections.Section section) { + String base = section.files().size() + " files · " + + section.hunkIds().size() + " hunks · grouped by drydock, no reviewer has run"; + return section.cycleWith().isEmpty() + ? base + : base + " · in a dependency cycle with " + String.join(", ", section.cycleWith()); + } +``` + +In `SessionReviewView`, build the graph on the existing background executor when a diff arrives and pass `Optional.of(graph)` on the render path; while it is being built, pass `Optional.empty()` so the rail shows the directory clustering rather than nothing. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :app:test --tests "app.drydock.review.SectionDeterminismTest"` +Expected: PASS (3 tests) + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/app/drydock/review/IntentGrouping.java \ + app/src/main/java/app/drydock/ui/review/SessionReviewView.java \ + app/src/test/java/app/drydock/review/SectionDeterminismTest.java +git commit -m "The rail renders computed sections when no reviewer has run + +Three sources, one ladder: a reviewer's grouping wins and is never re-sorted, +because it came from something that read the change and recomputing over it +would be drydock overruling the reviewer. Otherwise the computed sections. +With no graph yet -- it is built off the FX thread and takes a moment -- the +directory clustering, so the rail is never empty while waiting. + +A computed section says the structural facts for itself, including the cycle +it is in when it is in one, which is the part a directory title could never +carry." +``` + +--- + +### Task 14: `review_scope` offers the computed sections + +**Files:** +- Modify: `app/src/main/java/app/drydock/mcp/ReviewToolCodec.java` +- Modify: `app/src/main/java/app/drydock/mcp/McpToolRouter.java` (the `review_scope` descriptor, ~90–99) +- Test: `app/src/test/java/app/drydock/mcp/McpToolRouterSectionsTest.java` + +**Interfaces:** +- Consumes: `Sections.Section` (Task 12) +- Produces: `review_scope` accepts `include: "sections"`; the response gains `sections: [{title, files, hunkIds, hubSymbol?}]` + +- [ ] **Step 1: Write the failing test** + +```java +package app.drydock.mcp; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The agent has to be able to see the grouping it is being asked to name + * (spec §5.5). An agent that cannot regroups from scratch and loses the + * header conventions and the dependency order -- arriving back at prose + * titles over structurally worse sections. + */ +class McpToolRouterSectionsTest extends McpRouterFixture { + + @Test + void reviewScopeOmitsSectionsUnlessAsked() { + String response = callReviewScope(scopeId(), null); + + assertFalse(response.contains("\"sections\"")); + } + + @Test + void reviewScopeIncludesSectionsWhenAsked() { + String response = callReviewScope(scopeId(), "sections"); + + assertTrue(response.contains("\"sections\"")); + assertTrue(response.contains("\"hunkIds\"")); + } + + /** An unknown include is ignored, not an error: it is an optional read. */ + @Test + void anUnknownIncludeIsIgnored() { + String response = callReviewScope(scopeId(), "nonsense"); + + assertFalse(response.contains("\"sections\"")); + } +} +``` + +Add `McpRouterFixture` beside it, modelled on the existing `McpToolRouterReviewTest` setup, exposing `scopeId()` and `callReviewScope(String scopeId, String include)`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :app:test --tests "app.drydock.mcp.McpToolRouterSectionsTest"` +Expected: FAIL — no `sections` key is ever emitted + +- [ ] **Step 3: Write minimal implementation** + +Descriptor gains the parameter: + +```java + .put("include", schemaString("Optional extras, comma-separated. " + + "\"sections\" returns drydock's computed grouping: " + + "accept and name it, or regroup deliberately.")) +``` + +Codec gains the encoder: + +```java + /** drydock's computed grouping, offered so an agent can accept-and-name it. */ + static JsonValue sectionsToJson(List sections) { + List entries = new ArrayList<>(); + for (Sections.Section section : sections) { + JsonObject obj = JsonObject.empty(); + obj.put("title", new JsonString(section.title())); + obj.put("files", new JsonArray(section.files().stream() + .map(file -> (JsonValue) new JsonString(file)).toList())); + obj.put("hunkIds", new JsonArray(section.hunkIds().stream() + .map(id -> (JsonValue) new JsonString(id)).toList())); + section.hubSymbol().ifPresent(hub -> obj.put("hubSymbol", new JsonString(hub))); + entries.add(obj); + } + return new JsonArray(entries); + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :app:test --tests "app.drydock.mcp.McpToolRouterSectionsTest"` +Expected: PASS (3 tests) + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/app/drydock/mcp/ app/src/test/java/app/drydock/mcp/ +git commit -m "review_scope can hand the agent the grouping it is being asked to name + +An earlier draft deferred this on the grounds that the agent can read the +diff itself. The reference change settles it the other way: drydock now has +a grouping worth proposing, and an agent that cannot see it regroups from +scratch and loses the header conventions and the dependency order, arriving +back at prose titles over structurally worse sections. + +Optional, and off by default -- the include exists so accept-and-name is the +cheap path and regrouping is the deliberate one. The agent's grouping still +wins when it sends one." +``` + +--- + +### Task 15: The relevance filter widens to referenced declarations + +**Files:** +- Modify: `app/src/main/java/app/drydock/ui/review/SessionReviewView.java` (the `couldMatter` call site added in Task 6) +- Test: `app/src/test/java/app/drydock/review/BaseMoveTest.java` (add one case) + +**Interfaces:** +- Consumes: `ChangeGraph.filesReferencedBy` (Task 10), `BaseMove.couldMatter` (Task 5) +- Produces: no new signature — this is the widening Task 5 was built to accept + +- [ ] **Step 1: Write the failing test** + +```java + /** + * The half Task 5 deferred: a base commit touching a file this scope does + * not change but DOES reference can have moved the ground under an + * approval, and the graph is what makes that visible. + */ + @Test + void aBaseMoveTouchingAReferencedButUnchangedFileMatters() { + assertTrue(BaseMove.couldMatter(delta("src/support.h"), + List.of("src/guards.cpp", "src/support.h"))); + } +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :app:test --tests "app.drydock.review.BaseMoveTest"` +Expected: PASS at the unit level (the signature already accepts a wider set) — the real gap is the *call site*, which still passes only the scope's own files. Confirm by inspection: `rg -n "couldMatter" app/src/main` must show the argument being widened in Step 3. + +- [ ] **Step 3: Write minimal implementation** + +At the call site in `SessionReviewView`, replace the `filesOf(intent)` +argument added in Task 6 with the wider set: + +```java + /** + * Which files a base move has to touch before it can matter to this + * scope: the files it changes, plus the files declaring symbols those + * changes reference. Spec §9.2 -- the second half needs the graph, which + * is why it arrives a phase after the first. + */ + private Collection filesAffectingScope() { + SortedSet relevant = new TreeSet<>(changedFiles()); + changeGraph().ifPresent(graph -> { + for (String file : changedFiles()) { + relevant.addAll(graph.filesReferencedBy(file)); + } + }); + return relevant; + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :app:test --tests "app.drydock.review.BaseMoveTest" --tests "app.drydock.ui.review.ReviewHunkProgressTest"` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/app/drydock/ui/review/SessionReviewView.java \ + app/src/test/java/app/drydock/review/BaseMoveTest.java +git commit -m "Staleness also notices a base move in a file this scope only references + +The half deferred at Phase 1: a base commit touching a file this scope does +not change but does reference can move the ground under an approval, and +only the change graph makes that visible. couldMatter already took a +Collection for exactly this, so the widening is at the call site and no +caller moved. + +The filter stays file-level and lexical. A base change that alters behaviour +without touching a file this scope names or references still marks nothing -- +that is §4.3's boundary, and closing it is the agent recheck's job." +``` + +--- + +### Phase 2 gate + +- [ ] **Run the full suite:** `./gradlew :app:test` (from the controlling session) +- [ ] **Determinism across processes:** run `./gradlew :app:test --tests "app.drydock.review.SectionDeterminismTest"` twice in separate JVMs and diff the printed section titles. A hash-ordered collection usually agrees with itself inside one JVM, which is why this check has to leave it. +- [ ] **Run the app on a real C++ change** and screenshot the rail. The pass condition is that it no longer reads `main/cpp · 12 files` — it should name symbols, pair headers with implementations, and put a new guard's section ahead of the section using it. +- [ ] **Check the packaging cost:** `./gradlew :app:runtimeImage` and confirm the image grows by roughly 7 MB and still launches. `RuntimeImageModuleListTest` will fail if the jlink `--add-modules` list stopped covering the jar. + +--- + +# Phase 3 — Reading path, links, recheck + +### Task 16: `OutOfDiffFanIn` — one bounded `git grep`, locations kept + +**Files:** +- Create: `app/src/main/java/app/drydock/review/OutOfDiffFanIn.java` +- Test: `app/src/test/java/app/drydock/review/OutOfDiffFanInTest.java` + +**Interfaces:** +- Consumes: `ChangeGraph.changedDeclarations` (Task 10), `ProcessRunner` +- Produces: `record OutOfDiffFanIn.Occurrence(String file, int line, String text)`; `record OutOfDiffFanIn.Result(Map> bySymbol, boolean unavailable)`; `static Result OutOfDiffFanIn.scan(Path worktree, ChangeGraph graph, Set changedFiles)` + +- [ ] **Step 1: Write the failing test** + +```java +package app.drydock.review; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The strongest entry-point signal (spec §4.3): a changed symbol called from + * OUTSIDE the change. A diff-scoped graph cannot see it, and the reference + * implementation buys it with a repository-wide ingest this codebase has + * twice refused to build. One bounded git grep gets it instead. + * + *

The locations are kept, not just counted: a fan-in with nowhere to click + * is a statistic, not comprehension, and it lands exactly when a reviewer + * wants to look.

+ */ +class OutOfDiffFanInTest { + + @Test + void parsingKeepsFileLineAndText() { + List parsed = OutOfDiffFanIn.parse( + "src/other.cpp:42: JmpCtxScope guard;\n", Set.of("src/guards.cpp")); + + assertEquals(1, parsed.size()); + assertEquals("src/other.cpp", parsed.get(0).file()); + assertEquals(42, parsed.get(0).line()); + assertTrue(parsed.get(0).text().contains("JmpCtxScope")); + } + + /** Occurrences inside the change are not "outside" it. */ + @Test + void matchesInChangedFilesAreExcluded() { + assertEquals(List.of(), OutOfDiffFanIn.parse( + "src/guards.cpp:9: JmpCtxScope guard;\n", Set.of("src/guards.cpp"))); + } + + @Test + void aMalformedLineIsSkippedRatherThanFatal() { + assertEquals(List.of(), OutOfDiffFanIn.parse("not a grep line\n", Set.of())); + } + + /** A path containing a colon must not be truncated at it. */ + @Test + void aPathContainingAColonParsesBackToItself() { + List parsed = OutOfDiffFanIn.parse( + "src/a:b.cpp:7:x();\n", Set.of()); + + assertEquals("src/a:b.cpp", parsed.get(0).file()); + assertEquals(7, parsed.get(0).line()); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :app:test --tests "app.drydock.review.OutOfDiffFanInTest"` +Expected: FAIL — `cannot find symbol: class OutOfDiffFanIn` + +- [ ] **Step 3: Write minimal implementation** + +```java +package app.drydock.review; + +import app.drydock.process.ProcessResult; +import app.drydock.process.ProcessRunner; +import app.drydock.process.ProcessTimeoutException; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Where a changed symbol is used outside the change (spec §4.3). + * + *

One spawn for the whole scope, not one per symbol: every uniquely-named + * changed declaration goes into a patterns file and {@code git grep -n -F -f} + * reads them all at once.

+ * + *

A lexical count of occurrences, not a call count -- said in the same + * voice the symbol popover already uses. The locations are kept because a + * fan-in with nowhere to click is a statistic rather than comprehension.

+ */ +public final class OutOfDiffFanIn { + + private static final Logger LOG = Logger.getLogger(OutOfDiffFanIn.class.getName()); + private static final Duration TIMEOUT = Duration.ofSeconds(30); + + public record Occurrence(String file, int line, String text) { + } + + /** {@code unavailable} means the scan could not run: absent, not zero. */ + public record Result(Map> bySymbol, boolean unavailable) { + } + + private OutOfDiffFanIn() { + } + + /** Blocking; never call on the FX thread. */ + public static Result scan(Path worktree, ChangeGraph graph, Set changedFiles) { + if (graph.changedDeclarations().isEmpty()) { + return new Result(Map.of(), false); + } + Path patterns = null; + try { + patterns = Files.createTempFile("drydock-fanin", ".txt"); + Files.writeString(patterns, String.join("\n", graph.changedDeclarations()), + StandardCharsets.UTF_8); + ProcessResult result = ProcessRunner.run(List.of("git", "grep", "-n", "-F", "-f", + patterns.toString(), "--end-of-options"), worktree, TIMEOUT); + // git grep exits 1 for "no matches", which is a valid empty answer + // and not a failure. Anything else is. + if (result.exitCode() > 1) { + LOG.log(Level.WARNING, "git grep for out-of-diff fan-in failed: " + + ProcessRunner.excerpt(result.stderr())); + return new Result(Map.of(), true); + } + Map> bySymbol = new TreeMap<>(); + List all = parse(result.stdout(), changedFiles); + for (String symbol : graph.changedDeclarations()) { + List hits = all.stream() + .filter(occurrence -> occurrence.text().contains(symbol)).toList(); + if (!hits.isEmpty()) { + bySymbol.put(symbol, hits); + } + } + return new Result(bySymbol, false); + } catch (ProcessTimeoutException e) { + LOG.log(Level.WARNING, "git grep for out-of-diff fan-in timed out", e); + return new Result(Map.of(), true); + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + LOG.log(Level.WARNING, "git grep for out-of-diff fan-in could not run", e); + return new Result(Map.of(), true); + } finally { + if (patterns != null) { + try { + Files.deleteIfExists(patterns); + } catch (IOException e) { + LOG.log(Level.FINE, "could not remove fan-in patterns file", e); + } + } + } + } + + /** Parses {@code path:line:text} rows, dropping anything inside the change. */ + static List parse(String stdout, Set changedFiles) { + List occurrences = new ArrayList<>(); + for (String row : stdout.split("\n")) { + if (row.isBlank()) { + continue; + } + // A path may contain ':', so the line number is the LAST colon + // before the text, not the first. + int second = -1; + int first = row.indexOf(':'); + while (first >= 0) { + int next = row.indexOf(':', first + 1); + if (next < 0) { + break; + } + if (isDigits(row.substring(first + 1, next))) { + second = next; + break; + } + first = next; + } + if (first < 0 || second < 0) { + continue; + } + String file = row.substring(0, first); + if (changedFiles.contains(file)) { + continue; + } + try { + occurrences.add(new Occurrence(file, + Integer.parseInt(row.substring(first + 1, second)), + row.substring(second + 1).strip())); + } catch (NumberFormatException e) { + LOG.log(Level.FINE, "skipping unparseable git grep row"); + } + } + return List.copyOf(occurrences); + } + + private static boolean isDigits(String text) { + if (text.isEmpty()) { + return false; + } + for (int i = 0; i < text.length(); i++) { + if (!Character.isDigit(text.charAt(i))) { + return false; + } + } + return true; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :app:test --tests "app.drydock.review.OutOfDiffFanInTest"` +Expected: PASS (4 tests) + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/app/drydock/review/OutOfDiffFanIn.java \ + app/src/test/java/app/drydock/review/OutOfDiffFanInTest.java +git commit -m "The strongest entry-point signal, without a repository index + +A changed symbol called from outside the change is the signal a reviewer +most wants, and a diff-scoped graph cannot see it. The reference +implementation buys it by ingesting unchanged caller files; one bounded git +grep buys it here, with every uniquely-named changed declaration in a +patterns file so it is one spawn for the whole scope rather than one per +symbol. + +The locations are kept, not just counted: a fan-in with nowhere to click is +a statistic rather than comprehension, and it lands exactly when a reviewer +wants to look. Exit code 1 is no-matches and a valid empty answer; anything +above it is a failure that is logged and reported as unavailable, because +absent and zero must not look the same." +``` + +--- + +### Task 17: `ReadingPath` — order, entry points, links + +**Files:** +- Create: `app/src/main/java/app/drydock/review/ReadingPath.java` +- Test: `app/src/test/java/app/drydock/review/ReadingPathTest.java` + +**Interfaces:** +- Consumes: `ChangeGraph` (Task 10), `Graphs.topologicalOrder` (Task 11), `OutOfDiffFanIn.Result` (Task 16), `Sections.Section` (Task 12) +- Produces: `record ReadingPath.Link(String kind, String targetHunkId, String label)`; `record ReadingPath.Step(String hunkId, String file, int sectionNumber, String reason, List links, boolean entryPoint)`; `static List ReadingPath.of(UnifiedDiff diff, ChangeGraph graph, List sections, OutOfDiffFanIn.Result fanIn)` + +- [ ] **Step 1: Write the failing test** + +```java +package app.drydock.review; + +import app.drydock.git.UnifiedDiff; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.OptionalInt; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Where to start, what follows, and why (spec §6). Entry-point rank is + * applied INSIDE the sort rather than as a marking pass afterwards: ordering + * first and marking second lets "card 1" and "START HERE" disagree, and a + * START HERE badge on card 4 reads as a bug rather than a design. + */ +class ReadingPathTest { + + private static UnifiedDiff.FileDiff file(String path, String... added) { + List lines = new java.util.ArrayList<>(); + int n = 1; + for (String text : added) { + lines.add(new UnifiedDiff.Line(UnifiedDiff.Line.Kind.ADD, + OptionalInt.empty(), OptionalInt.of(n++), text)); + } + return new UnifiedDiff.FileDiff(path, "M", added.length, 0, false, false, + List.of(new UnifiedDiff.Hunk("@@", lines))); + } + + private static List pathOf(UnifiedDiff diff, OutOfDiffFanIn.Result fanIn) { + ChangeGraph graph = ChangeGraph.of(diff); + return ReadingPath.of(diff, graph, Sections.of(diff, graph), fanIn); + } + + private static final OutOfDiffFanIn.Result NO_FAN_IN = + new OutOfDiffFanIn.Result(Map.of(), false); + + @Test + void theFoundationIsReadBeforeWhatUsesIt() { + List path = pathOf(new UnifiedDiff(List.of( + file("src/profiler.cpp", "void go() { new JmpCtxScope(); }"), + file("src/guards.cpp", "class JmpCtxScope { };"))), NO_FAN_IN); + + assertEquals("src/guards.cpp", path.get(0).file()); + } + + /** The first step and the entry point are the same step, by construction. */ + @Test + void theFirstStepIsTheEntryPoint() { + List path = pathOf(new UnifiedDiff(List.of( + file("src/profiler.cpp", "void go() { new JmpCtxScope(); }"), + file("src/guards.cpp", "class JmpCtxScope { };"))), NO_FAN_IN); + + assertTrue(path.get(0).entryPoint()); + assertTrue(path.stream().skip(1).noneMatch(ReadingPath.Step::entryPoint)); + } + + /** Called from outside the change outranks everything else. */ + @Test + void outOfDiffFanInOutranksInDegree() { + OutOfDiffFanIn.Result fanIn = new OutOfDiffFanIn.Result( + Map.of("PublicThing", List.of(new OutOfDiffFanIn.Occurrence("other.cpp", 1, "x"))), + false); + List path = pathOf(new UnifiedDiff(List.of( + file("src/api.cpp", "class PublicThing { };"), + file("src/internal.cpp", "class Internal { };"))), fanIn); + + assertEquals("src/api.cpp", path.get(0).file()); + } + + /** + * A tie-break for when the graph is silent, not an override of it: where + * a test references changed code the edge already orders it. + */ + @Test + void aTestOnlySectionDoesNotBecomeTheEntryPoint() { + List path = pathOf(new UnifiedDiff(List.of( + file("test/unrelated_ut.cpp", "void t() { somethingElse(); }"), + file("src/guards.cpp", "class JmpCtxScope { };"))), NO_FAN_IN); + + assertEquals("src/guards.cpp", path.get(0).file()); + } + + @Test + void aStepLinksToWhatCallsIt() { + List path = pathOf(new UnifiedDiff(List.of( + file("src/guards.cpp", "class JmpCtxScope { };"), + file("src/profiler.cpp", "void go() { new JmpCtxScope(); }"))), NO_FAN_IN); + + assertTrue(path.get(0).links().stream().anyMatch(link -> link.kind().equals("called by"))); + } + + /** Same-concept links name the symbol they share; a bare affinity says nothing. */ + @Test + void sameConceptLinksNameTheSharedSymbol() { + List path = pathOf(new UnifiedDiff(List.of( + file("src/guards.cpp", "class JmpCtxScope { };"), + file("src/a.cpp", "void a() { new JmpCtxScope(); }"), + file("src/b.cpp", "void b() { new JmpCtxScope(); }"))), NO_FAN_IN); + + assertTrue(path.stream().flatMap(step -> step.links().stream()) + .filter(link -> link.kind().equals("same concept")) + .anyMatch(link -> link.label().contains("JmpCtxScope"))); + } + + @Test + void everyStepStatesWhyItSitsWhereItDoes() { + List path = pathOf(new UnifiedDiff(List.of( + file("src/guards.cpp", "class JmpCtxScope { };"), + file("src/profiler.cpp", "void go() { new JmpCtxScope(); }"))), NO_FAN_IN); + + assertTrue(path.stream().noneMatch(step -> step.reason().isBlank())); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :app:test --tests "app.drydock.review.ReadingPathTest"` +Expected: FAIL — `cannot find symbol: class ReadingPath` + +- [ ] **Step 3: Write minimal implementation** + +Implement `ReadingPath.of` as: rank each file by `(out-of-diff fan-in desc, in-degree desc, non-test first, non-leaf first, path asc)`; hand that comparator to `Graphs.topologicalOrder` as the tie-break so ranking happens *inside* the sort; walk the resulting units emitting one `Step` per hunk, in file order; mark only the first step `entryPoint`; and build links from `graph.filesReferencing` (`called by`), `graph.filesReferencedBy` (`calls`), and shared uniquely-declared symbols (`same concept`, labelled `both touch `), cross-file only and deduplicated by target hunk id. + +```java + private static Comparator rank(ChangeGraph graph, OutOfDiffFanIn.Result fanIn) { + return Comparator + .comparingInt((String file) -> -fanInOf(file, graph, fanIn)) + .thenComparingInt(file -> -graph.filesReferencing(file).size()) + .thenComparingInt(file -> isTest(file) ? 1 : 0) + .thenComparingInt(file -> graph.filesReferencing(file).isEmpty() ? 1 : 0) + .thenComparing(Comparator.naturalOrder()); + } +``` + +`isTest` reuses `FallbackIntents`' path rules (promote its private `isTest` to package-private rather than writing a second copy — two copies of this vocabulary drifted the last time they existed). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :app:test --tests "app.drydock.review.ReadingPathTest"` +Expected: PASS (7 tests) + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/app/drydock/review/ReadingPath.java \ + app/src/main/java/app/drydock/review/FallbackIntents.java \ + app/src/test/java/app/drydock/review/ReadingPathTest.java +git commit -m "Where to start, what follows it, and why + +Entry-point rank is the tie-break inside the Kahn sort, not a marking pass +after it. Ordering first and marking second lets the first card and the +START HERE badge disagree, and a badge on card 4 reads as a bug rather than +as a design. + +Four signals in order: called from outside the change, then in-degree within +it, then not-a-test, then not-a-leaf. The test signal is a tie-break for +when the graph is silent rather than an override of it -- where a test +references changed code the edge already orders it, so the signal decides +only the case it should, a test-only section with nothing pointing into it. + +Links carry their reason. Same-concept names the symbol two hunks share, +because a bare affinity score cannot say why it exists and every other +marker on this surface states its reason. isTest is promoted rather than +copied: two copies of that vocabulary drifted the last time they existed." +``` + +--- + +### Task 18: The rail gets a second mode on `p` + +**Files:** +- Modify: `app/src/main/java/app/drydock/ui/review/ReviewIntentRail.java` +- Modify: `app/src/main/java/app/drydock/ui/review/SessionReviewView.java` (key handling) +- Modify: `app/src/main/java/app/drydock/ui/ShortcutsOverlay.java` +- Test: `app/src/test/java/app/drydock/ui/review/ReviewPathModeTest.java` + +**Interfaces:** +- Consumes: `ReadingPath.Step` (Task 17) +- Produces: `ReviewIntentRail.Mode { INTENTS, PATH }`; `void ReviewIntentRail.showPath(List steps)`; `SessionReviewView.railMode()` + +- [ ] **Step 1: Write the failing test** + +```java +package app.drydock.ui.review; + +import javafx.scene.input.KeyCode; +import org.junit.jupiter.api.Test; +import org.testfx.util.WaitForAsyncUtils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The reading path is a MODE of the rail, not a fourth column (spec §7.1). + * The width budget that ruled out a concept map rules out a new column just + * as firmly, and RailLayout stays untouched. + */ +class ReviewPathModeTest extends ReviewViewFixture { + + @Test + void pTogglesTheRailBetweenIntentsAndPath() { + assertEquals(ReviewIntentRail.Mode.INTENTS, view.railMode()); + + press(KeyCode.P); + WaitForAsyncUtils.waitForFxEvents(); + assertEquals(ReviewIntentRail.Mode.PATH, view.railMode()); + + press(KeyCode.P); + WaitForAsyncUtils.waitForFxEvents(); + assertEquals(ReviewIntentRail.Mode.INTENTS, view.railMode()); + } + + /** One key, not a parallel set: [ and ] step whatever the rail lists. */ + @Test + void bracketsStepHunksInPathModeAndSectionsInIntentsMode() { + press(KeyCode.P); + press(KeyCode.CLOSE_BRACKET); + WaitForAsyncUtils.waitForFxEvents(); + + assertEquals(1, view.selectedPathStepForTest()); + } + + /** n keeps meaning "next unsettled", which is a property of hunks now. */ + @Test + void nStillWalksUnsettledWorkInPathMode() { + press(KeyCode.P); + press(KeyCode.N); + WaitForAsyncUtils.waitForFxEvents(); + + assertTrue(view.selectedPathStepForTest() >= 0); + } + + @Test + void everyPathRowStatesItsReason() { + press(KeyCode.P); + WaitForAsyncUtils.waitForFxEvents(); + + assertTrue(view.pathRowTextsForTest().stream().noneMatch(String::isBlank)); + } + + /** Advertised and bound must match. */ + @Test + void theShortcutsOverlayAdvertisesP() { + assertTrue(app.drydock.ui.ShortcutsOverlay.reviewShortcutKeys().contains("p")); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :app:test --tests "app.drydock.ui.review.ReviewPathModeTest"` +Expected: FAIL — `ReviewIntentRail.Mode` not found + +- [ ] **Step 3: Write minimal implementation** + +Add `Mode { INTENTS, PATH }` and `showPath(List)` to `ReviewIntentRail`, rendering one focusable `Button` per step carrying its section number, file, reason and link count. Bind `p` in `SessionReviewView` to flip the mode and re-render; route `[`/`]` to the rail's current list; keep `n` on unsettled hunks. Add to `ShortcutsOverlay`'s `IN REVIEW` block and expose the keys for the test: + +```java + {"Reading path / intents", "p"}, +``` + +```java + /** The keys this overlay advertises for Review, so a test can hold the two in step. */ + public static java.util.List reviewShortcutKeys() { + return java.util.Arrays.stream(SECTIONS) + .filter(section -> section.title().equals("IN REVIEW")) + .flatMap(section -> java.util.Arrays.stream(section.rows())) + .map(row -> row[1]).toList(); + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :app:test --tests "app.drydock.ui.review.ReviewPathModeTest"` +Expected: PASS (5 tests) + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/app/drydock/ui/ app/src/test/java/app/drydock/ui/review/ReviewPathModeTest.java +git commit -m "p walks the change in reading order + +A mode of the rail, not a fourth column: the width budget that ruled out a +concept map rules out a new column just as firmly, and RailLayout is +untouched. [ and ] step whatever the rail is currently listing -- the rule +they already followed -- so the mode costs one key rather than a parallel +set, and n keeps meaning next-unsettled, which is a property of hunks +regardless of what the rail shows. + +Every row says why it sits where it does. A reading order the reader cannot +interrogate is just a different arbitrary order." +``` + +--- + +### Task 19: Links render under the hunk they belong to + +**Files:** +- Modify: `app/src/main/java/app/drydock/ui/review/ReviewDiffRows.java` (row model) +- Modify: `app/src/main/java/app/drydock/ui/review/ReviewDiffColumn.java` +- Test: `app/src/test/java/app/drydock/ui/review/ReviewLinkRowTest.java` + +**Interfaces:** +- Consumes: `ReadingPath.Link` (Task 17) +- Produces: `ReviewDiffRows` gains a `LINK` row kind carrying `ReadingPath.Link` + +- [ ] **Step 1: Write the failing test** + +```java +package app.drydock.ui.review; + +import org.junit.jupiter.api.Test; +import org.testfx.util.WaitForAsyncUtils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * "What does this hunk have to do with the one I just read" (spec §7.2), + * answered where the question is asked. Link rows are part of the hunk's row + * model, so folding, density and the unchanged-run collapse apply to them + * unchanged rather than needing their own copies. + */ +class ReviewLinkRowTest extends ReviewViewFixture { + + @Test + void aHunkWithLinksGetsAFooterRowBeneathIt() { + assertTrue(linkRowTexts().stream().anyMatch(text -> text.contains("called by"))); + } + + @Test + void aLinkNamesItsTargetFileAndSymbolNotARawId() { + assertTrue(linkRowTexts().stream().noneMatch(text -> text.contains("h_"))); + } + + @Test + void clickingALinkSelectsTheTargetHunk() { + clickFirstLinkRow(); + WaitForAsyncUtils.waitForFxEvents(); + + assertEquals("src/guards.cpp", view.selectedFileForTest()); + } + + /** One link per target, not one per shared symbol. */ + @Test + void linksAreDeduplicatedByTargetHunk() { + assertEquals(linkRowTexts().size(), linkRowTexts().stream().distinct().count()); + } + + @Test + void aHunkWithNoLinksGetsNoFooterRow() { + assertTrue(linkRowTextsFor("src/unrelated.cpp").isEmpty()); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :app:test --tests "app.drydock.ui.review.ReviewLinkRowTest"` +Expected: FAIL — no link rows exist + +- [ ] **Step 3: Write minimal implementation** + +In `ReviewDiffRows`, add the row kind and its payload: + +```java + /** + * A link to a related hunk, appended to its source hunk's rows so that + * density, folding and the unchanged-run collapse apply to it with no + * new cases -- a parallel rendering path would drift from this one at + * the first thing they disagreed about. + */ + public record LinkRow(ReadingPath.Link link) implements Row { + @Override + public Kind kind() { + return Kind.LINK; + } + } +``` + +In `ReviewDiffColumn`, render it as a focusable control that jumps: + +```java + private Node linkRow(ReviewDiffRows.LinkRow row) { + ReadingPath.Link link = row.link(); + // A label naming files and symbols, never a raw h__ id: the + // reader is being told where to go, not shown a key. + Button button = new Button(glyphFor(link.kind()) + " " + link.label()); + button.getStyleClass().add("review-link-row"); + button.setFocusTraversable(true); + button.setOnAction(event -> selectHunk(link.targetHunkId())); + return button; + } + + private static String glyphFor(String kind) { + return switch (kind) { + case "called by" -> "↳ called by"; + case "calls" -> "↳ calls"; + default -> "↔"; + }; + } +``` + +Build the rows when the column renders, deduplicated by target so a hunk +sharing three symbols with one target still gets one link: + +```java + Map byTarget = new LinkedHashMap<>(); + for (ReadingPath.Link link : linksFor(hunkId)) { + byTarget.putIfAbsent(link.targetHunkId(), link); + } + byTarget.values().forEach(link -> rows.add(new ReviewDiffRows.LinkRow(link))); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :app:test --tests "app.drydock.ui.review.ReviewLinkRowTest"` +Expected: PASS (5 tests) + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/app/drydock/ui/review/ app/src/test/java/app/drydock/ui/review/ReviewLinkRowTest.java +git commit -m "A hunk says what it has to do with the hunks around it + +Link rows live in the hunk's own row model, so folding, density and the +unchanged-run collapse apply to them with no new cases -- the alternative +was a parallel rendering path that would have drifted from the first one it +disagreed with. + +Labels name files and symbols rather than raw hunk ids, and there is one +link per target rather than one per shared symbol: a reviewer wants to know +where to go next, not how many reasons there are to go there." +``` + +--- + +### Task 20: The fan-in count opens the popover that already exists + +**Files:** +- Modify: `app/src/main/java/app/drydock/ui/review/ReviewDiffColumn.java` (occurrence popover) +- Modify: `app/src/main/java/app/drydock/ui/review/ReviewIntentRail.java` (the count is clickable) +- Test: `app/src/test/java/app/drydock/ui/review/ReviewFanInPopoverTest.java` + +**Interfaces:** +- Consumes: `OutOfDiffFanIn.Result` (Task 16), the existing `openExplorerAt` / `searchInExplorer` bridge + +- [ ] **Step 1: Write the failing test** + +```java +package app.drydock.ui.review; + +import org.junit.jupiter.api.Test; +import org.testfx.util.WaitForAsyncUtils; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The mechanical layer's job is not to be intelligent; it is to make sure + * the reviewer knows which question to ask and to be one key away from + * asking it (spec §7.4). A fan-in count with nowhere to click is a + * statistic. + */ +class ReviewFanInPopoverTest extends ReviewViewFixture { + + @Test + void clickingTheFanInCountListsTheCallersWithFileAndLine() { + clickFanInCount(); + WaitForAsyncUtils.waitForFxEvents(); + + assertTrue(popoverTexts().stream().anyMatch(text -> text.matches(".*:\\d+.*"))); + } + + /** No new interaction is invented: it is the same popover on a third source. */ + @Test + void thePopoverOffersUsagesAndAskTheAgent() { + clickFanInCount(); + WaitForAsyncUtils.waitForFxEvents(); + + assertTrue(popoverTexts().stream().anyMatch(text -> text.contains("usages"))); + assertTrue(popoverTexts().stream().anyMatch(text -> text.contains("agent"))); + } + + /** Absent and zero must not look the same. */ + @Test + void anUnavailableScanShowsNoCountRatherThanZero() { + withFanInUnavailable(); + WaitForAsyncUtils.waitForFxEvents(); + + assertFalse(railTexts().stream().anyMatch(text -> text.contains("0 places outside"))); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :app:test --tests "app.drydock.ui.review.ReviewFanInPopoverTest"` +Expected: FAIL — the count is a `Label`, not a control, and has no popover + +- [ ] **Step 3: Write minimal implementation** + +The count becomes a control rather than a label, and absent stays distinct +from zero: + +```java + /** + * The fan-in affordance. An unavailable scan renders NOTHING rather than + * a zero: "the scan could not run" and "nothing uses this" are different + * facts and must not look the same. + */ + private Optional fanInControl(String symbol, OutOfDiffFanIn.Result fanIn) { + if (fanIn.unavailable()) { + return Optional.empty(); + } + List occurrences = + fanIn.bySymbol().getOrDefault(symbol, List.of()); + if (occurrences.isEmpty()) { + return Optional.empty(); + } + Button button = new Button("called from " + occurrences.size() + " places outside"); + button.getStyleClass().add("review-fanin-count"); + button.setFocusTraversable(true); + button.setOnAction(event -> showOccurrencePopover(symbol, occurrences)); + return Optional.of(button); + } +``` + +`showOccurrencePopover` is the popover the symbol lens already builds; it +takes the same `(file, line, text)` shape, so the change is the source of +the rows and nothing else. Its existing handlers stay wired as they are — +`⏎` to `openExplorerAt`, `u` to `searchInExplorer`, `a` to the agent prompt — +because inventing a second interaction for the same gesture is how two +popovers start disagreeing. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :app:test --tests "app.drydock.ui.review.ReviewFanInPopoverTest"` +Expected: PASS (3 tests) + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/app/drydock/ui/review/ app/src/test/java/app/drydock/ui/review/ReviewFanInPopoverTest.java +git commit -m "Called from 7 places outside the change, and you can see which seven + +The same occurrence popover the symbol lens already uses, on a third source, +with its existing keys unchanged: enter opens the file, u lists usages, a +asks the agent -- with the question already pointed at the right file. + +This is where the design is honest about its ceiling. A lexical occurrence +list cannot tell a reviewer whether a signature change breaks the caller it +just found, and nothing mechanical and diff-scoped can. What it can do is +put them one keystroke from the party that can answer. + +An unavailable scan shows no count rather than a zero: absent and none must +not look the same." +``` + +--- + +### Task 21: `reads` — an agent may declare its own dependency order + +**Files:** +- Modify: `app/src/main/java/app/drydock/review/ReviewIntent.java` (add `reads`) +- Modify: `app/src/main/java/app/drydock/mcp/ReviewToolCodec.java` (`intentsFromJson`, ~230–253) +- Modify: `app/src/main/java/app/drydock/mcp/McpToolRouter.java` (the `review_intents` descriptor, ~100–108) +- Test: `app/src/test/java/app/drydock/mcp/ReviewIntentReadsTest.java` + +**Interfaces:** +- Consumes: `Graphs.topologicalOrder` (Task 11) +- Produces: `ReviewIntent.reads()` → `List`; `IntentGrouping.set` orders a supplied grouping by `reads` when present + +- [ ] **Step 1: Write the failing test** + +```java +package app.drydock.mcp; + +import app.drydock.review.IntentGrouping; +import app.drydock.review.ReviewIntent; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * The agent asserts, drydock renders the assertion and never verifies it -- + * the ReviewIntent.Collapse precedent (spec §8). With reads present the + * rail's order is the agent's declared dependency order; without it, the + * agent's array order stands. + */ +class ReviewIntentReadsTest { + + @Test + void readsOrdersTheRailFoundationFirst() { + IntentGrouping grouping = new IntentGrouping(); + grouping.set("scope-1", List.of( + intent("uses-it", "Crash-protected resolve()", List.of("the-guard")), + intent("the-guard", "JmpCtxScope guard", List.of()))); + + assertEquals(List.of("JmpCtxScope guard", "Crash-protected resolve()"), + grouping.intentsFor("scope-1", emptyDiff(), java.util.Optional.empty()) + .stream().map(ReviewIntent::title).toList()); + } + + @Test + void withoutReadsTheAgentsArrayOrderStands() { + IntentGrouping grouping = new IntentGrouping(); + grouping.set("scope-1", List.of( + intent("b", "Second", List.of()), intent("a", "First", List.of()))); + + assertEquals(List.of("Second", "First"), + grouping.intentsFor("scope-1", emptyDiff(), java.util.Optional.empty()) + .stream().map(ReviewIntent::title).toList()); + } + + /** A cycle among asserted dependencies is named, not broken silently. */ + @Test + void aReadsCycleIsKeptTogetherRatherThanBrokenArbitrarily() { + IntentGrouping grouping = new IntentGrouping(); + grouping.set("scope-1", List.of( + intent("a", "A", List.of("b")), intent("b", "B", List.of("a")))); + + assertEquals(2, grouping.intentsFor("scope-1", emptyDiff(), + java.util.Optional.empty()).size()); + } + + /** + * A batch is all-or-nothing, so a reads naming nothing is rejected whole. + * + *

{@code parse} is the fixture's JSON helper -- the same + * {@code JsonParser.parse(String)} the other codec tests use.

+ */ + @Test + void readsNamingAnUnknownIntentRejectsTheBatch() { + assertThrows(McpToolException.class, + () -> ReviewToolCodec.intentsFromJson(parse(""" + [{"id":"a","title":"A","hunkIds":[],"reads":["nonexistent"]}] + """))); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :app:test --tests "app.drydock.mcp.ReviewIntentReadsTest"` +Expected: FAIL — `ReviewIntent` has no `reads` component + +- [ ] **Step 3: Write minimal implementation** + +Add `List reads` as the last component of `ReviewIntent` (copied defensively in the compact constructor, defaulting to `List.of()`); decode it in `intentsFromJson` and reject the batch when an entry names an id no intent in the same call carries; and in `IntentGrouping.set`, when any intent declares `reads`, order through `Graphs.topologicalOrder` before assigning `1..N`. + +Descriptor: + +```java + .put("intents", schemaString("Array of {id, title, kind, risk, " + + "rationale, hunkIds, reads?, collapse?, autoApprove?}. " + + "reads names the intents this one is built on; drydock " + + "orders the rail by it and does not verify it.")) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :app:test --tests "app.drydock.mcp.ReviewIntentReadsTest"` +Expected: PASS (4 tests) + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/app/drydock/review/ReviewIntent.java \ + app/src/main/java/app/drydock/mcp/ app/src/test/java/app/drydock/mcp/ReviewIntentReadsTest.java +git commit -m "An agent may say which intents its intents are built on + +One optional field, no new tool. drydock renders the assertion and never +verifies it, which is the ReviewIntent.Collapse precedent: the agent +asserts, the surface shows the assertion, and the evidence stays one click +away. + +Three sources and one rendering path -- reads when it is there, the agent's +array order when it is not, the computed path when no agent ran. A reads +cycle is kept together and named rather than broken silently, for the same +reason a computed one is. And a reads naming an unknown intent rejects the +whole batch, because a batch is already all-or-nothing here: half a grouping +is worse than none." +``` + +--- + +### Task 22: `review_recheck` — the agent may add staleness, never remove it + +**Files:** +- Create: `app/src/main/java/app/drydock/review/RecheckAssessment.java` +- Modify: `app/src/main/java/app/drydock/review/AnnotationStore.java` (persist assessments) +- Modify: `app/src/main/java/app/drydock/mcp/McpToolRouter.java`, `ReviewToolCodec.java` +- Test: `app/src/test/java/app/drydock/review/RecheckAsymmetryTest.java` + +**Interfaces:** +- Consumes: `ReviewVerdict` (Task 2), `BaseMove.Delta` (Task 5) +- Produces: `record RecheckAssessment(String scopeId, String hunkDigest, String fromBase, String toBase, boolean affected, String why, Instant at)`; `void AnnotationStore.putAssessment(RecheckAssessment)`; `boolean AnnotationStore.assessedAffected(String scopeId, String hunkDigest, String fromBase, String toBase)` + +- [ ] **Step 1: Write the failing test** + +```java +package app.drydock.review; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The asymmetry (spec §9.7). "Affected" applies, because it can only ever + * ADD reading and because it closes the blind spot the file-level relevance + * filter admits to. "Unaffected" is advice, because an agent wrong THAT way + * would cost an approval on code nobody re-read -- which is the outcome the + * whole reviewed-state model refuses. + */ +class RecheckAsymmetryTest { + + private static AnnotationStore store() throws IOException { + return new AnnotationStore(Files.createTempDirectory("drydock-recheck") + .resolve("annotations.json")); + } + + private static ReviewVerdict approved(String base) { + return new ReviewVerdict("scope-1", "digest-1", ReviewVerdict.Decision.APPROVED, + Optional.empty(), Instant.EPOCH, base, "head-1"); + } + + @Test + void anAffectedAssessmentMarksAVerdictTheFilterWouldHaveMissed() throws IOException { + AnnotationStore store = store(); + store.putVerdict(approved("base-1")); + store.putAssessment(new RecheckAssessment("scope-1", "digest-1", "base-1", "base-2", + true, "resolve() now returns nullptr on failure", Instant.EPOCH)); + + assertTrue(store.assessedAffected("scope-1", "digest-1", "base-1", "base-2")); + } + + @Test + void anUnaffectedAssessmentDoesNotClearTheVerdictsStaleness() throws IOException { + AnnotationStore store = store(); + store.putVerdict(approved("base-1")); + store.putAssessment(new RecheckAssessment("scope-1", "digest-1", "base-1", "base-2", + false, "the base change is in an unrelated subsystem", Instant.EPOCH)); + + assertFalse(store.assessedAffected("scope-1", "digest-1", "base-1", "base-2")); + assertTrue(store.verdict("scope-1", "digest-1").orElseThrow().staleAgainst("base-2"), + "an agent must not clear a human's approval"); + } + + /** An assessment is about one base pair; a later move is a new question. */ + @Test + void anAssessmentDoesNotCarryToADifferentBasePair() throws IOException { + AnnotationStore store = store(); + store.putAssessment(new RecheckAssessment("scope-1", "digest-1", "base-1", "base-2", + true, "why", Instant.EPOCH)); + + assertFalse(store.assessedAffected("scope-1", "digest-1", "base-2", "base-3")); + } + + @Test + void assessmentsRoundTripThroughDisk() throws IOException { + Path file = Files.createTempDirectory("drydock-recheck").resolve("annotations.json"); + AnnotationStore store = new AnnotationStore(file); + store.putAssessment(new RecheckAssessment("scope-1", "digest-1", "base-1", "base-2", + true, "why", Instant.EPOCH)); + store.flushPendingSaves(); + + assertTrue(new AnnotationStore(file) + .assessedAffected("scope-1", "digest-1", "base-1", "base-2")); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :app:test --tests "app.drydock.review.RecheckAsymmetryTest"` +Expected: FAIL — `cannot find symbol: class RecheckAssessment` + +- [ ] **Step 3: Write minimal implementation** + +```java +package app.drydock.review; + +import java.time.Instant; +import java.util.Objects; + +/** + * An agent's statement about whether one base move affects one approved hunk + * (spec §9.7). + * + *

Keyed by the base PAIR it was made about: a later base move is a new + * question, and carrying an old answer forward would be the agent answering + * something it was never asked.

+ * + *

Only {@code affected == true} has an effect. An agent may add staleness + * -- that only ever asks for more reading, and it closes the blind spot the + * file-level relevance filter admits to -- but it may never clear an + * approval, which is the line the whole MCP surface is drawn around.

+ */ +public record RecheckAssessment(String scopeId, String hunkDigest, String fromBase, String toBase, + boolean affected, String why, Instant at) { + + public RecheckAssessment { + Objects.requireNonNull(scopeId, "scopeId"); + Objects.requireNonNull(hunkDigest, "hunkDigest"); + Objects.requireNonNull(fromBase, "fromBase"); + Objects.requireNonNull(toBase, "toBase"); + Objects.requireNonNull(why, "why"); + Objects.requireNonNull(at, "at"); + } + + /** {@code (scopeId, hunkDigest, fromBase, toBase)}. */ + public record Key(String scopeId, String hunkDigest, String fromBase, String toBase) { + } + + public Key key() { + return new Key(scopeId, hunkDigest, fromBase, toBase); + } +} +``` + +In `AnnotationStore`, add an `assessments` map keyed by `RecheckAssessment.Key`, persisted under a new `"assessments"` array (same lenient decode as verdicts), with: + +```java + /** Records an agent's recheck. Only an affected one has any effect (spec §9.7). */ + public void putAssessment(RecheckAssessment assessment) { + putAssessmentInternal(assessment); + fireChanged(null); + } + + /** Whether the agent said this base move affects this hunk. */ + public synchronized boolean assessedAffected(String scopeId, String hunkDigest, + String fromBase, String toBase) { + RecheckAssessment found = assessments.get( + new RecheckAssessment.Key(scopeId, hunkDigest, fromBase, toBase)); + return found != null && found.affected(); + } +``` + +Register the tool: + +```java + descriptor("review_recheck", + "Assesses whether a base move still leaves approved hunks valid. " + + "affected=true marks them stale; affected=false is ADVICE and " + + "never clears a human's approval.", + JsonObject.empty() + .put("scopeId", schemaString("Review scope handle.")) + .put("assessments", schemaString("Array of {hunkId, affected, why}.")), + "scopeId", "assessments"), +``` + +The staleness test in `SessionReviewView` becomes `filterSaysStale || store.assessedAffected(...)`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :app:test --tests "app.drydock.review.RecheckAsymmetryTest"` +Expected: PASS (4 tests) + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/app/drydock/review/ app/src/main/java/app/drydock/mcp/ \ + app/src/test/java/app/drydock/review/RecheckAsymmetryTest.java +git commit -m "An agent may add staleness to an approval, never take it away + +The relevance filter is file-level and lexical and names its own blind spot: +a base change that alters behaviour without touching a file this scope names +is invisible to it. An agent has no such boundary, so it can answer the one +question neither the digest nor the intersection can. + +The two directions carry different risk and are treated differently. +Affected applies -- it only adds reading, and it is how the blind spot +closes; an agent wrong that way costs a wasted re-read. Unaffected is advice +that never clears a verdict, because an agent wrong that way would cost an +approval on code nobody re-read. It is migrateLegacyVerdicts' asymmetry +pointed at a different question. + +Keyed by the base pair it was made about, so a later move is a new question +rather than an old answer carried forward." +``` + +--- + +### Task 23: The recheck dispatches itself when the base moves + +**Files:** +- Modify: `app/src/main/java/app/drydock/review/ReviewInstructions.java` +- Modify: `app/src/main/java/app/drydock/ui/review/SessionReviewView.java` (dispatch on base move) +- Test: `app/src/test/java/app/drydock/review/ReviewInstructionsRecheckTest.java` + +**Interfaces:** +- Consumes: `AgentCapabilities.supportsSubagents`, `ReviewInstructions.forScope` +- Produces: `static String ReviewInstructions.forRecheck(String scopeId, String fromBase, String toBase, boolean supportsSubagents)` + +- [ ] **Step 1: Write the failing test** + +```java +package app.drydock.review; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * A recheck is a small bounded task -- it reads one base delta and the stale + * hunks, not the change -- which is why it earns a dispatch of its own + * rather than a full re-review (spec §9.7). + */ +class ReviewInstructionsRecheckTest { + + @Test + void theSubagentFormNamesBothBasesAndTheTool() { + String instruction = ReviewInstructions.forRecheck("scope-1", "a1b2c3", "d4e5f6", true); + + assertTrue(instruction.contains("a1b2c3")); + assertTrue(instruction.contains("d4e5f6")); + assertTrue(instruction.contains("review_recheck")); + assertTrue(instruction.contains("subagent")); + } + + @Test + void theInlineFormDoesTheSameWorkWithoutASubagent() { + String instruction = ReviewInstructions.forRecheck("scope-1", "a1b2c3", "d4e5f6", false); + + assertTrue(instruction.contains("review_recheck")); + assertFalse(instruction.contains("subagent")); + } + + /** The agent must be told it cannot clear an approval, not left to infer it. */ + @Test + void bothFormsSayThatUnaffectedIsAdviceOnly() { + for (boolean subagents : new boolean[] {true, false}) { + assertTrue(ReviewInstructions.forRecheck("s", "a", "b", subagents) + .contains("does not clear")); + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :app:test --tests "app.drydock.review.ReviewInstructionsRecheckTest"` +Expected: FAIL — `forRecheck` not found + +- [ ] **Step 3: Write minimal implementation** + +```java + /** + * What drydock asks when a base move has marked approvals stale + * (spec §9.7). Bounded on purpose: the base delta and the stale hunks, + * not the change. + */ + public static String forRecheck(String scopeId, String fromBase, String toBase, + boolean supportsSubagents) { + Objects.requireNonNull(scopeId, "scopeId"); + String work = "for handle " + scopeId + ", read what changed between " + fromBase + + " and " + toBase + ", and for each approved hunk it could affect call " + + "review_recheck with affected and a one-line why. Marking a hunk affected " + + "asks the human to read it again; marking one unaffected is advice and " + + "does not clear their approval"; + return supportsSubagents + ? "Dispatch a subagent to recheck stale approvals: " + work + + ". Report only its summary back here." + : "Recheck the stale approvals in this worktree: " + work + "."; + } +``` + +In `SessionReviewView`, when a base move marks anything stale, dispatch this through the existing `TerminalBridge.sendPrompt` path on the background executor. A harness without subagent support gets the inline form; a harness whose `mcpDelivery` is `NONE` gets no dispatch and no error. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :app:test --tests "app.drydock.review.ReviewInstructionsRecheckTest"` +Expected: PASS (3 tests) + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/app/drydock/review/ReviewInstructions.java \ + app/src/main/java/app/drydock/ui/review/SessionReviewView.java \ + app/src/test/java/app/drydock/review/ReviewInstructionsRecheckTest.java +git commit -m "A base move asks the agent which approvals it actually disturbed + +Dispatched automatically, so the assessment is usually already there when +the reviewer returns rather than arriving after a wait exactly when they +wanted to move on. It is a small bounded task by construction -- one base +delta and the stale hunks, not the change -- which is why it earns its own +dispatch instead of a full re-review. + +The instruction says outright that unaffected does not clear an approval. +An agent should be told the rule rather than left to infer it from what the +tool happens to do. + +This does not fix the accepted risk: the reviewer still clicks confirm. It +changes what they are looking at when they click -- nine of twelve visibly +uninteresting, three not. It does not make the mark trustworthy, it makes it +sorted." +``` + +--- + +--- + +### Task 24: Order and links say whether they were measured or claimed + +**Files:** +- Create: `app/src/main/java/app/drydock/review/Provenance.java` +- Modify: `app/src/main/java/app/drydock/ui/review/ReviewIntentRail.java`, `ReviewDiffColumn.java` +- Modify: `app/src/main/resources/app.css` (a `.provenance-claimed` modifier) +- Test: `app/src/test/java/app/drydock/ui/review/ReviewProvenanceTest.java` + +**Interfaces:** +- Consumes: `ReadingPath.Link` (Task 17), `ReviewIntent.reads` (Task 21), `RecheckAssessment` (Task 22) +- Produces: `enum Provenance { MEASURED, CLAIMED }`; `Provenance ReadingPath.Step.provenance()`; `Provenance ReadingPath.Link.provenance()` + +**Ordering note:** this depends on Tasks 17, 18, 19 and 21 and could equally be done immediately after 21. It is last because it is the smallest change that touches the most rendering paths, and doing it once at the end beats threading it through four tasks as they land. + +- [ ] **Step 1: Write the failing test** + +```java +package app.drydock.ui.review; + +import app.drydock.review.Provenance; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * A measured edge and a claimed one fail differently (spec §6.5), and a + * reviewer deciding how hard to squint at "③ depends on ①" has to know which + * they are holding. A measured edge fails as a false unique-name match -- + * two unrelated things sharing a name -- and is checkable on the spot by + * looking. A claimed edge fails as a plausible fabrication and is checkable + * only against the code the agent says it read. + * + *

Not a new principle here, only its consistent application: + * ReviewIntent.Collapse already renders the agent's assertion AS an + * assertion, precisely because drydock does not verify it.

+ */ +class ReviewProvenanceTest extends ReviewViewFixture { + + @Test + void aComputedOrderIsMarkedMeasured() { + assertEquals(Provenance.MEASURED, view.stepProvenanceForTest(0)); + } + + @Test + void anAgentSuppliedOrderIsMarkedClaimed() { + withAgentSuppliedIntentsDeclaringReads(); + + assertEquals(Provenance.CLAIMED, view.stepProvenanceForTest(0)); + } + + @Test + void computedLinksAreMarkedMeasured() { + assertTrue(view.linksForTest().stream() + .allMatch(link -> link.provenance() == Provenance.MEASURED)); + } + + /** The distinction has to be visible, not merely modelled. */ + @Test + void aClaimedRowCarriesTheClaimedStyleClass() { + withAgentSuppliedIntentsDeclaringReads(); + + assertTrue(railRowStyleClasses().stream() + .anyMatch(classes -> classes.contains("provenance-claimed"))); + } + + @Test + void aMeasuredRowDoesNotCarryTheClaimedStyleClass() { + assertTrue(railRowStyleClasses().stream() + .noneMatch(classes -> classes.contains("provenance-claimed"))); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :app:test --tests "app.drydock.ui.review.ReviewProvenanceTest"` +Expected: FAIL — `cannot find symbol: class Provenance` + +- [ ] **Step 3: Write minimal implementation** + +```java +package app.drydock.review; + +/** + * Where an ordering or a link came from (spec §6.5). + * + *

The two fail in ways a reviewer has to tell apart. A {@link #MEASURED} + * edge fails as a false unique-name match and is checkable on the spot by + * looking; a {@link #CLAIMED} one fails as a plausible fabrication and is + * checkable only against the code the agent says it read.

+ * + *

One rendering path, two visibly different warrants -- the treatment + * {@code ReviewIntent.Collapse} already gets, applied consistently.

+ */ +public enum Provenance { + + /** Computed here from the diff, by the rules in §4.2 and §4.3. */ + MEASURED("measured"), + + /** Asserted by the reviewing agent, through {@code review_intents} or {@code review_recheck}. */ + CLAIMED("claimed"); + + private final String label; + + Provenance(String label) { + this.label = label; + } + + /** What the surface shows beside a marker carrying this warrant. */ + public String label() { + return label; + } + + /** The {@code app.css} modifier class, or none for the ordinary case. */ + public String styleClass() { + return this == CLAIMED ? "provenance-claimed" : ""; + } +} +``` + +Add `Provenance provenance()` to `ReadingPath.Step` and `ReadingPath.Link`, set to `MEASURED` where `ReadingPath` computed them and `CLAIMED` where the order came from `reads` or a `RecheckAssessment`. In the rail and the diff column, apply `provenance().styleClass()` to the row and append the label to the row's tooltip, so the distinction is legible without adding a column. + +`app.css`: + +```css +/* A claimed ordering is the agent's assertion, not drydock's measurement. + Dashed rather than coloured: the four risk encodings already compete for + colour on this surface, and a fifth would be unreadable. */ +.review-intent-card.provenance-claimed, +.review-link-row.provenance-claimed { + -fx-border-style: segments(3, 3) line-cap round; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :app:test --tests "app.drydock.ui.review.ReviewProvenanceTest"` +Expected: PASS (5 tests) + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/app/drydock/review/Provenance.java \ + app/src/main/java/app/drydock/ui/review/ app/src/main/resources/app.css \ + app/src/test/java/app/drydock/ui/review/ReviewProvenanceTest.java +git commit -m "An ordering says whether drydock measured it or an agent claimed it + +Consistency of rendering is right; consistency of warrant is not. A measured +edge fails as a false unique-name match -- two unrelated things sharing a +name -- and a reviewer can check it by looking. A claimed edge fails as a +plausible fabrication and is checkable only against the code the agent says +it read. Someone deciding how hard to squint at 'this depends on that' has +to know which of those they are holding. + +Not a new principle, only its consistent application: ReviewIntent.Collapse +already renders the agent's assertion as an assertion, precisely because +drydock does not verify it. Order and links get the same treatment. + +Dashed rather than coloured, because four risk encodings already compete for +colour on this surface and a fifth would be unreadable." +``` + +### Phase 3 gate + +- [ ] **Run the full suite:** `./gradlew :app:test` (from the controlling session) +- [ ] **Screenshots, per the visual-verification practice**, at a realistic window width — the rail has truncated before and PATH rows carry more text than an intent card: + - The rail in PATH mode. + - A hunk carrying all three link kinds, at each of the three densities. + - A named cycle. + - The fan-in popover open from a rail card. +- [ ] **One end-to-end pass on a real PR:** review it, approve some sections, move the base, confirm the recheck dispatches and that its "affected" assessments mark hunks the file-level filter missed. +- [ ] **Confirm the accepted risk is visible, not hidden:** with a stale verdict present, `⏎` must refuse with a stated reason rather than silently doing nothing. +- [ ] **Confirm provenance is legible**, not just modelled: an agent-ordered rail and a computed one must be distinguishable in a screenshot without reading the tooltip. + +--- + +## Notes for whoever executes this + +- **Do not let a subagent run the full Gradle suite.** It takes 14–20 minutes and the Bash tool's ceiling is 10; give subagents the targeted `--tests` subset for their task and run the full suite from the controlling session at each phase gate. +- **Determinism failures usually look like flakiness.** If a section order or a reading path differs between runs, the cause is almost always a `HashMap`/`HashSet` that should have been `LinkedHashMap`/`TreeSet` — check that before suspecting the algorithm. +- **The spec records what was ruled out and why.** Before proposing a change to an approach here — a graph library, a positional anchor, splitting tests onto their own card, letting an agent clear an approval — read the corresponding section: each of those was considered and rejected for a reason that is written down. diff --git a/docs/superpowers/specs/2026-08-22-review-navigation-design.md b/docs/superpowers/specs/2026-08-22-review-navigation-design.md new file mode 100644 index 00000000..88a8339e --- /dev/null +++ b/docs/superpowers/specs/2026-08-22-review-navigation-design.md @@ -0,0 +1,1041 @@ +# Review navigation: entry points, reading order and links + +*Adapted from `review-helper v2 — review-driven navigation for code changes` +(2026-08-21), an external design for a Python/web review tool. This spec keeps +that design's navigation model and replaces its machinery with what Drydock +already is: one JVM process, an agent bound to every scope, and a diff-scoped +lens that says what it is.* + +## 1. Why + +Drydock's Review surface can already show you a change. With no reviewer +configured it cannot tell you what the change is *made of*, where to start +reading it, or why one hunk follows another. + +Run against a real C++ pull request, the rail reads: + +``` +main/cpp · 12 files test/cpp · 4 files cpp/hotspot · 6 files +``` + +Those are `FallbackIntents`' (kind, directory) groups titled by the last two +path segments. Every card is individually correct and the rail as a whole +says nothing — which is precisely the failure that class was written to fix +one rung lower down, when it replaced one-intent-per-file whose titles all +clipped to the same prefix. It stopped one rung too early: **the grouping +still has no structural input at all.** + +The same change, grouped structurally, reads `JmpCtxScope guard` — its +header, its implementation and the tests that exercise it, in that order — +and then `Crash-protected resolve()`. The difference is not presentation. `guards.h` and `guards.cpp` are one idea, a directory key +splits them whenever the tree does, and no amount of better sorting or +naming recovers a group that was drawn in the wrong place. + +So there are three things missing, and they are strictly ordered — later ones +are worthless without earlier ones: + +1. **Grouping** that follows the code's own structure rather than its folders + (§5). The binding constraint. +2. **Order** over those groups: a data-model change read before the code that + uses it, an interface before its implementation (§6). +3. **Links** answering "what does this hunk have to do with the one I just + read" (§6.3). + +An earlier draft of this document addressed only (2) and (3), on the +assumption that grouping was already solved because `ReviewIntent` exists. +`ReviewIntent` is the right *container*; what fills it with no agent running +was the problem, and the rail above is what that assumption looks like in +practice. + +## 2. What this adapts, and what it does not + +The source design is a five-stage pipeline: resolve the source, build an +ephemeral SQLite knowledge graph with tree-sitter and a capped ingest, order +hunks against it, have an LLM name the sections, and serve a local web UI. + +Most of that pipeline already exists in Drydock under other names, and the +parts that do not exist are the parts worth building. + +| review-helper v2 | Drydock | +|---|---| +| `resolve-source.py` (uncommitted / branch / PR) | `SessionReviewScopes`, `ReviewScopeRegistry` — `WORKTREE`, `WORKING_TREE`, `PR` | +| `serve-ui.py`, `ui/hunk-view.js` | `SessionReviewView`, `ReviewDiffColumn` | +| Sections with title + explanation | `ReviewIntent` — but only on the agent path; the computed path is **new** (§5) | +| Pi agent naming sections | `review_intents` over MCP | +| `annotations.py`, `post-comments.py` | `AnnotationStore`, `SubmitPlan`, `GitHubReviewService` | +| **`order-hunks.py`** | **new: `ChangeGraph` + `Sections` + `ReadingPath`** | +| **Concept map, Leiden communities** | **cut — see §2.2** | +| **SQLite KG, `kg-ingest.py`, `repo-intel.py`** | **cut — see §2.1** | +| tree-sitter + `tree_sitter_languages` | `io.github.bonede:tree-sitter` + grammar jars (§9) | +| networkx | ~200 lines of Kahn and Tarjan (§2.3) | + +### 2.1 The knowledge graph is a file for a reason that does not apply here + +v2 persists a SQLite graph because it is a multi-process pipeline: +`kg-ingest.py` writes it and `order-hunks.py` reads it. Drydock is one +process. The graph becomes an in-memory object with scope lifetime, rebuilt +when the diff is re-read — structurally what `SymbolIndex` already does for +the symbol lens. + +Nothing is persisted, so nothing has to be invalidated, migrated, garbage +collected, or reconciled with a worktree that moved under it. The `rh-gc` +equivalent does not need to exist. + +### 2.2 The concept map is cut; same-concept links are not + +These were bundled in an earlier draft of this spec and they do not belong +together. + +**The map goes.** Drydock already has the overview it would be: the intent +rail is `1..N` cards with a kind tag, a risk heat bar, file badges and +click-to-filter on the diff column. A graph panel would be a second, weaker +answer to a question the rail already answers, and there is no width for it — +`RailLayout` only keeps rails expanded above 1320px, the narrow-width +`BROWSE`/`DETAIL` paging was deleted only because the queue column went away, +and the headless test screen had to be widened to 1920×1200 to stop the +Review scene overflowing the software pixel buffer. A fourth column is the +thing the scoped-session-review change just finished removing. v2 reaches the +same verdict from the other direction, calling v1's map "a visualization +looking for a purpose". + +**Same-concept links stay**, and change their source. v2 derives them from +Leiden communities. Drydock derives them from a shared changed symbol: two +hunks are same-concept linked when both mention a name that is declared +somewhere in the change. The link therefore names its own reason — `↔ both +touch ReviewScope` — where a community id cannot, and every other marker on +this surface states its reason. + +That also removes the last caller of a clustering algorithm, which is why no +community detection is specified anywhere in this document. If a clustering +tier is ever wanted, label propagation over the same `ChangeGraph` is about +sixty lines and still needs no third-party graph library. + +### 2.3 No graph library + +What this design asks of a graph is a topological sort, strongly-connected +components, and reachability, over a graph of tens of nodes. That is Kahn and +Tarjan, in one small class, fully unit-testable against hand-built graphs. + +`org.jgrapht:jgrapht-core` was considered and rejected on value, not quality: +1.27 MB plus `jheaps` and an arbitrary-precision math transitive, a new entry +in the jlink `--add-modules` list that `RuntimeImageModuleListTest` pins +against jdeps, and a POM dependency for a `jbangJar` that deliberately +bundles nothing. Three textbook algorithms do not buy that. + +## 3. Scope of this change + +In scope: a change graph over the scope's diff; a **grouping** computed from +it that replaces the fallback's directory clustering; a reading path over +that grouping; a second mode for the intent rail; per-hunk links in the diff +column; `reads` on `review_intents` and a `sections` include on +`review_scope`; overlapping section membership with reviewed state keyed to +hunk content; and tree-sitter parsing with a lexical fallback. + +Also in scope, and an earlier draft was wrong to exclude it: **the verdict +model**. Sections overlap (§5.6), so they no longer partition the change, and +a verdict keyed to a section cannot survive that — §9 moves it to the hunk. +Excluding it was not a scoping decision, it was an unexamined assumption that +sections would stay disjoint. + +Out of scope, and unchanged: scope identity, the findings margin, finding +anchors, the submit sheet's own flow, and every other `review_*` tool. + +## 4. The change graph + +### 4.1 Nodes and edges + +`app.drydock.review.ChangeGraph` — nodes are **changed symbols** (a symbol +whose declaration span overlaps a changed line in the scope's `UnifiedDiff`), +edges are **references** (source uses target). Built off the FX thread, cached +per scope, discarded and rebuilt when the diff changes. + +A hunk maps to the symbols whose spans overlap its line range. A hunk may map +to several symbols, or to none — a comment change, a resource file, a +generated blob. Hunks mapping to no symbol are not errors; they sort last, as +in v2. + +### 4.2 Two front ends, one matching rule + +Per file, whichever applies: + +- **A grammar is loaded for the file's language.** Declarations and + identifier uses come from the parse tree, with their spans. +- **No grammar.** The file is scanned lexically under the existing + `SymbolWords` rules (keywords excluded, identifiers shorter than three + characters excluded). Every occurrence is a *use*; the file contributes no + declarations, because a lexical scan cannot tell one from the other without + guessing. + +**Edge matching is the same rule either way**: a use of a name resolves to a +declaration only when **exactly one** changed declaration in the scope carries +that name, and only across files. Ambiguous names produce no edge; intra-file +edges are dropped as noise. This is the rule v2 settled on in +`_resolve_cross_file_references_v2`, and it is worth being explicit about what +it means for tree-sitter: + +> Tree-sitter tells you that a token is a declaration and another is a call. +> It does not tell you which declaration a call resolves to. The unique-name +> match survives it. Tree-sitter therefore raises the **precision of +> classification**, not the **correctness of resolution** — which is exactly +> why a file with no grammar degrades to a usable graph rather than to +> nothing, and why "we resolved this reference" is never claimed anywhere in +> the UI. + +### 4.3 The diff is the world, and the one place it is not + +The graph covers the diff plus the twelve lines of context Review already +asks git for. It does not index the repository. That is a position this +codebase has taken twice already — *"the lens indexes the diff, not the +repository"* — and reversing it would mean an index with an invalidation +story and a per-language resolver. + +It costs one thing, and the cost is the signal a reviewer most wants. v2's +strongest entry-point heuristic is "public API — a changed symbol called by +code **outside** the diff", and v2 computes it by ingesting unchanged caller +files (its caller-expansion pass, capped at five caller files per changed +symbol). Under a diff-scoped graph that signal does not exist. + +It is restored without an index, by **one bounded `git grep`**: a single +`ProcessRunner` spawn of `git grep -n -F -f -- `, listing +every uniquely-named changed declaration, excluding the changed files +themselves. One invocation for the whole scope, not one per symbol. It obeys +the house rules for spawns — argument list, `--end-of-options`, a short +timeout, `destroyForcibly` on expiry, and a failure that is logged and +distinct from an empty result. + +This is a lexical count of occurrences, not a call count, and the UI says so +in the same voice the symbol popover already uses. + +**`-n`, and the locations are kept.** An earlier draft counted the matches and +discarded the output. That is the wrong half to keep: "called from 7 places +outside the change" with nowhere to click is a statistic, not comprehension, +and it lands at precisely the moment a reviewer wants to look. The `file:line` +list costs nothing extra — the spawn has already happened — and it is what +makes the signal an entry point into the code rather than a number beside a +card. Where it surfaces is §7.4. + +## 5. Sections: grouping the change + +### 5.1 What the fallback does now, and why it fails + +`FallbackIntents` keys a group on (kind, directory) and titles it with +`shortDirectory()` — the last two segments of the parent path. On a C++ tree +laid out `src/main/cpp/...`, `src/test/cpp/...` that yields cards reading +`main/cpp · 12 files` and `test/cpp · 4 files`. + +Each card is individually correct and the rail as a whole says nothing, which +is the exact failure this class was written to fix at the level below — +it replaced one-intent-per-file for the same reason and stopped one rung too +early. **The grouping has no structural input at all.** It is worst on C and +C++, where a header and its implementation routinely sit in different trees, +so the one pairing a reviewer most wants is the one a directory key is +guaranteed to break. + +Ordering these groups better (§6) puts bad groups in a good sequence. Grouping +is the binding constraint, not order. + +### 5.2 Components, not directories + +Sections are the connected components of the **file-level** reference graph +projected from §4, plus two conventions carried over from the source design +because a C/C++ change is unreadable without them: + +- A `.h` groups with the `.cpp` of the same basename. +- A header groups with any changed `.cpp` that references it, at file level, + **even when the referenced symbol is not itself in a changed hunk** — the + case of a new macro or counter header pulled into the section that uses it. + +Sections are then ordered by dependency direction, topologically over the +file-level graph, foundation first. On the reference change that is what puts +the new RAII guard and its header in section 1, ahead of the section that +uses it — an ordering a directory sort cannot produce at any width. + +Two things are deliberately absent. There is **no `co_change` edge kind**: +the source design found it connects every file of a single-commit change into +one giant section, and Drydock mines no history to build it from. And there +is no clustering algorithm — a component is a connected component, not a +community (§2.2). + +### 5.3 Tests are not split out; the graph places them + +`FallbackIntents` makes kind part of the group key, so a test never shares a +card with its subject, on a stated ground: *"'the change' and 'the tests for +the change' are the two things a reviewer most wants to look at separately."* + +**That rule is dropped for computed sections.** An earlier draft kept it and +applied it inside each component, which was §5.1's own indictment repeated one +layer later: §5.2 groups by structure precisely because a path heuristic draws +the wrong boundaries, and then splitting the result on `/test/`, `*_ut.cpp` +and `*Test.java` is a path heuristic drawing a boundary through a structurally +sound group. + +The signal is already there and already correct. A test file references the +symbol under test, so a changed declaration in `guards.h` and its exercise in +`hotspot_crash_protection_ut.cpp` produce an edge (§4.2) and land in one +component **without a rule**. A test that genuinely does not reference +anything changed — a test-only change, or tests exercising untouched code — +forms its own component, which is the honest outcome rather than a +special case. + +This also explains the source design's apparently inconsistent output rather +than excusing it: two of its test files sit inside the core section because +they reference changed symbols in it, and one is its own section because it +is a new test file for a class whose changed surface it does not otherwise +touch. The graph is right in both cases. A path-based split would have +flattened them into the same answer. + +The rationale quoted above is not wrong so much as obsolete: it was written +for a world with no structural signal to consult, where separating tests was +the only way to stop them burying the change. With a component to place them +in, keeping a test beside the code it pins is what lets a reviewer check that +the code does what the test claims — which is the thing they were being kept +apart from. + +Two consequences, stated rather than discovered: + +- **A mixed section is `Kind.CHANGE`, not `Kind.TESTS`.** `ReviewIntent.Kind` + holds one value, and a section containing production code cannot honestly + be tagged as tests. The test files stay visible in the section's file + badges. +- **Within a section, tests sort after the code they exercise** — not by a + rule, but because the edge runs test→implementation and §6.1 sorts + foundation first. "Here is the change; here is what pins it." + +`FallbackIntents`' kind key survives untouched in the no-edges fallback +(§11), where there is no structure to consult and it remains the best +available guess. + +### 5.4 Titles and explanations: three rungs + +The grouping is Drydock's; the naming is the agent's. That is the source +design's own split — its `order-hunks.py` computes sections and its agent +titles them — and it is what keeps the floor working: with no agent a section +is still correctly grouped and correctly ordered, only plainly named. + +**Title:** + +1. **The agent's**, via `review_intents` — prose that names the concept + (`JmpCtxScope guard`, `Crash-protected resolve()`). The target quality. +2. **The hub symbol** — the highest-fan-in changed symbol in the component: + `JmpCtxScope · 2 files · 3 hunks`. Names the thing rather than the folder, + and is computable from §4 alone. +3. **The directory tail** — today's behaviour, when no symbol dominates. + +**Explanation:** the agent's `rationale`, else the structural facts — files, +hunks, ±churn, hub symbol, and **what links this section to the one before +it**, which is information today's rationale line does not have and the graph +supplies for free. + +### 5.5 The agent has to be able to see them + +`review_scope` gains a **`sections`** include: Drydock's computed grouping, +with each section's files, hunk ids and structural title. + +An earlier draft deferred exactly this, reasoning that the agent can read the +diff itself. The reference change settles it against that: Drydock now has a +grouping worth proposing, and an agent that cannot see it regroups from +scratch and loses the header convention and the dependency order — arriving +back at prose titles over structurally worse sections. + +The agent may still regroup, and its grouping still wins (§6.4). The include +is what makes **accept-and-name** the cheap path and regrouping the +deliberate one. + +### 5.6 One hunk, many sections + +**A hunk may appear in more than one section, and should.** Sections are +views for comprehension, not a partition of the change. + +This removes a forced choice §5.2 otherwise makes twice over. A header groups +with its same-basename `.cpp` **and** with every changed `.cpp` that +references it; those are different sections, and with disjoint membership one +of them has to lose. `counters.h` in the reference output has exactly this +shape. With overlap there is nothing to decide: the header appears wherever +it is needed to understand what is being read. + +It also drains most of the over-grouping risk (§14). A component previously +had to absorb everything transitively connected to it *in order to keep the +connection visible at all*; now a distant-but-relevant file can be shown in a +section without being swallowed by it. + +What overlap costs is arithmetic, and the cost is real: the sum of section +sizes now exceeds the number of hunks, so "3 of 5 intents settled" measures +nothing. **Progress is counted in hunks, not sections** (§9), and a section's +own state is derived rather than stored. A hunk already settled elsewhere +renders in place, marked with where it was settled — `✓ reviewed in ①` — so +a section is never silently incomplete and never asks for a second reading of +the same lines. + +## 6. The reading path + +`app.drydock.review.ReadingPath` computes, from a `ChangeGraph`: + +### 6.1 Order + +Kahn topological sort over changed symbols, foundation first: if changed +symbol A is referenced by changed symbol B, A comes before B. + +Among the units Kahn can emit next — those whose dependencies are all placed +— the highest-ranked entry point (§6.2) goes first, then `FallbackIntents`' +existing kind order, then path. Ranking inside the sort rather than after it +is what makes "the first card" and "the entry point" the same card by +construction; ordering first and marking second would let them disagree, and +a `START HERE` badge sitting on card 4 would read as a bug. With no edges at +all this degrades to the entry-point rank and then to today's fallback order, +rather than to alphabetical chaos. + +Cycles are found with Tarjan. Each strongly-connected component collapses to +one unit whose members are ordered by path, and **the cycle is named on +screen**. v2 breaks cycles arbitrarily and notes them in its JSON; a cycle +among changed symbols is a fact about the change worth showing a reviewer, +and a silent arbitrary break is the kind of unexplained ordering this whole +feature exists to remove. + +### 6.2 Entry points + +Ranked by, in order: + +1. **Out-of-diff fan-in** (§4.3) — called from outside the change. The places + it names are kept, not just counted, and are one keystroke from the + Explorer and from the agent (§7.4). +2. **In-degree within the changed set** — the foundation the rest builds on. +3. **Not a test** — test paths (`*_test.*`, `*Test.java`, `__tests__/`, and + the rest of v2's list) rank after production code. This is a tie-break for + when the graph is silent, **not** an override of it: where a test + references changed code the edge already orders it (§5.3), and this signal + never fires. It decides only the case it should — a test-only section with + no edges into it should not be where a reviewer is told to start. +4. **Not a leaf** — nothing changed depends on it, so it is an endpoint. + +The top-ranked unit is marked `START HERE` — by construction the first card +in a computed order (§6.1), and wherever it falls in an agent-supplied one, +because that order is the reviewer's and is not re-sorted (§6.4). + +### 6.3 Links + +Per hunk, cross-file only, deduplicated by target hunk: + +- **`calls`** — a changed symbol this hunk's symbols reference. +- **`called by`** — a changed symbol that references this hunk's symbols. +- **`same concept`** — a hunk sharing a changed symbol with this one (§2.2), + labelled with the symbol they share. The shared name must be uniquely + declared in the scope, the same test an edge passes (§4.2); an ambiguous + name links nothing. Cross-*file* only, like the other two, but **not** + restricted to crossing an intent boundary: two files inside one intent that + share a symbol are linked, because the rail groups them without saying what + they have in common, and that is the thing this link says. + +Labels name files and symbols (`③ SessionReviewScopes.java`), never raw node +ids, and carry their provenance (§6.5). + +### 6.4 The reviewer's order wins + +`ReadingPath` orders the **computed** grouping (§5) only. + +When an agent has supplied intents, `IntentGrouping.set` already renumbers +them `1..N` in the agent's own order. That array *is* the reading order, from +a reviewer that read the change; recomputing over it would be Drydock +overruling the reviewer, which is the one thing this surface is built not to +do. This mirrors `IntentGrouping`'s existing shape exactly — the reviewer's +grouping wins, the computed one is what the surface falls back to — and it +is what keeps Review fully functional with no reviewer configured. + +The same rule now governs grouping, and §5.5 is what keeps it from being a +loss: the agent is *shown* the computed sections, so overriding them is a +decision it makes having seen them, rather than the accident of never having +been offered one. + +Links and entry-point marks are computed in both cases: they are facts about +the diff, not a grouping, so they do not compete with the agent's judgement. + +### 6.5 Provenance: measured or claimed + +Every ordering and every link is one of two things, and the surface says +which. + +- **Measured** — computed here from the diff, by the rules in §4.2 and §4.3. +- **Claimed** — asserted by the reviewing agent, through `review_intents` + and its `reads` (§8). + +An earlier draft ended §8 with "three sources, one rendering path", which is +right about consistency and wrong about trust. The two fail in ways a +reviewer has to tell apart: a measured edge fails as a **false unique-name +match** — two unrelated things sharing a name — and is checkable on the spot +by looking. A claimed edge fails as a **plausible fabrication** and is not +checkable by looking at all; it is checkable only against the code the agent +says it read. A reviewer deciding how hard to squint at "③ depends on ①" +needs to know which of those they are holding. + +This is not a new principle on this surface, only its consistent +application: `ReviewIntent.Collapse` already renders the agent's assertion +*as* an assertion, with its evidence and its stated method, precisely because +drydock does not verify it. Order and links get the same treatment. One +rendering path, two visibly different warrants. + +## 7. Where it surfaces + +### 7.1 The rail has two modes + +`p` toggles the intent rail between **INTENTS** and **PATH**. A mode, not a +fourth column: the width budget that ruled out the concept map (§2.2) rules +out a new column just as firmly, and `RailLayout` is untouched. + +- **INTENTS** — today's rail, ordered per §6.4, with `START HERE` on the + first card and a named cycle marker where one exists. +- **PATH** — one row per hunk in reading order, across intent boundaries. + Each row carries its intent number, the reason it sits where it does + ("referenced by ③", "called from 7 places outside the change", "test"), and + its link count. + +Selecting a row in either mode drives the diff column, as selecting an intent +does today. + +### 7.2 Links in the diff column + +A hunk with links gains a footer row beneath it: + +``` + ↳ called by ③ SessionReviewScopes.java:forCheckout + ↔ both touch ReviewScope ⑤ ReviewScopeRegistry.java +``` + +Clicking one selects the target hunk. Footer rows are part of the hunk's row +model, so folding, density and the unchanged-run collapse all apply to them +unchanged. + +### 7.3 Keys + +`p` is free; `f d c [ ] n a r u ⏎ i m ⇧F \` are taken. `[` and `]` step +whatever the rail is currently listing — intents in INTENTS mode, hunks in +PATH mode — so the mode adds one key rather than a parallel set, and existing +muscle memory survives. `n` remains "next unsettled intent" in both modes, +because it walks unsettled work, and §9 makes that a property of hunks rather +than of whatever the rail is currently showing. + +`a` / `r` / `u` keep their keys and gain a focus-dependent unit (§9.6), and +`⇧A` / `⇧R` settle the current file. `ShortcutsOverlay` gains rows for `p`, +`⇧A` and `⇧R`, and the `a` / `r` / `u` rows are reworded to name the unit: +advertised and bound must match. + +**A collision worth catching before it is written:** `a` already means "ask +the agent" inside the occurrence popover (§7.4) and "approve" in the review +board. They do not overlap today because the popover owns the key while it is +open, and §9.5 does not change that — but the popover is now reachable from a +card as well as from a symbol, so the two are one keystroke closer together +than they were. + +### 7.4 Out-of-diff callers, and the one keystroke to the agent + +The fan-in count on a card or a path row opens the **existing occurrence +popover** — the one the symbol lens already uses, with its in-diff / +not-touched chips — listing the `file:line` matches §4.3 kept. From there, +the Explorer peek's existing keys apply unchanged: `⏎` opens the file for +real, `u` lists usages, `a` asks the agent about it. The jump goes through +the `openExplorerAt` / `searchInExplorer` bridge `ReviewDiffColumn` already +holds. + +No new interaction is invented here, and that is the point. It is the same +popover on a third source. + +It is also where this design is honest about its own ceiling. A lexical +occurrence list cannot tell a reviewer whether a signature change breaks the +caller it just found — nothing mechanical and diff-scoped can. What it can do +is put the reviewer one keystroke from the party that *can* answer, with the +question already pointed at the right file. **The mechanical layer's job is +not to be intelligent; it is to make sure the reviewer knows which question +to ask, and to be one key away from asking it.** That division is the whole +reason §4.3's boundary costs comprehension nothing: it bounds what drydock +asserts on its own authority, not what the reviewer can find out. + +## 8. MCP surface + +Two optional fields and one new tool — the tool being `review_recheck`, whose +case is made in §9.7 rather than here, because it exists to serve the +staleness model and not the navigation one. + +`review_intents` gains per-intent **`reads: [intentId]`** — the intents this +one is built on. Drydock renders the assertion and never verifies it, which +is the `ReviewIntent.Collapse` precedent: the agent asserts, drydock shows the +assertion and keeps the evidence one click away. + +With `reads` present, the rail's order is the agent's declared dependency +order (topologically sorted, cycles named as in §6.1). With `reads` absent, +the agent's array order stands (§6.4). With no agent at all, `ReadingPath` +supplies the order. Three sources, one rendering path — and the first two are +marked **claimed** while the third is marked **measured**, for the reasons in +§6.5. + +`review_scope` gains one optional include, **`sections`** (§5.5) — the +computed grouping, so an agent can accept-and-name it rather than regroup +from scratch. An earlier draft deferred this; the reference change reversed +it, for the reasons in §5.5. + +The computed *links* are still not exposed. An include existing so the agent +can correct Drydock's lexical guesses remains a feature that should be asked +for before it is built (§15). + +## 9. Reviewed state: keyed to content, not to a grouping + +### 9.1 The unit moves from the section to the hunk + +`ReviewVerdict` is keyed `(scopeId, intentId)` and the verdict bar reads +`n/m intents settled`. Both assume sections partition the change, which §5.6 +ends. + +The key moves to the hunk, keeping all three decisions — `APPROVED`, +`CHANGES`, `AUTO_APPROVED`. **A section's state is derived, not stored**, by +the merge `AnnotationStore.migrateLegacyVerdicts` already implements and +already argues for: + +- any `CHANGES` among a section's hunks makes the section `CHANGES` — + "something in here needs work" stays true of a section however it is drawn; +- `APPROVED` requires **every** hunk settled, because approving a section + claims the human read all of it. + +That rule stops being a migration and becomes the live derivation. It was +written for exactly this question — how a group's decision follows from its +members — and the only thing that changes is that its members are hunks and +it runs on every render rather than once. + +`AUTO_APPROVED` counts as settled for the derivation and is rendered as +*claimed* rather than *measured* (§6.5), so a section approved entirely on +the agent's assertion reads as one. + +### 9.2 An approval is valid only for the content and the base it was given against + +The obvious key is the existing stable line key (`n` / `o`) that +findings already use. **It is the wrong one here.** It is positional: an +author pushing one commit shifts every key below the insertion, so a clean +flag recorded at `n42` comes back covering lines nobody read. That is the one +outcome `migrateLegacyVerdicts`' merge exists to refuse — *"silently +approving code nobody looked at is the one outcome this must never +produce."* A finding landing a few lines off is a visible annoyance; an +approval landing a few lines off is a silent lie about what was reviewed. + +**The digest.** A hunk's verdict is keyed by a digest over its file path, its +changed lines **and the context lines around them**. Context is included +because a hunk means what it means in place: change the line above it and its +changed lines are byte-identical, so a changed-lines-only digest would leave +the approval standing over code whose surroundings moved. Review already +fetches twelve lines of context per hunk, so this costs nothing to compute. + +That makes a re-diff correct by construction: + +| Change | Effect | +|---|---| +| Edit inside the hunk | Unsettled | +| Edit within the hunk's context | Unsettled | +| Edit elsewhere in the same file | Stays approved | +| Hunk moves, text identical | Stays approved | +| Same hunk in three sections | One digest, one flag (§5.6) | + +The third row is the deliberate limit. A file-wide digest would unsettle every +hunk whenever a file is touched again, which re-reviews code nobody changed; +the context window is where the line is drawn, and it is drawn at the point +the reviewer could actually see while reading. + +**The base.** A diff means what it means *against a base*, and a digest over +its own text cannot see the base move. A rebase, or the base branch +advancing, can leave every hunk byte-identical while the code they sit on +changed underneath — so a content digest alone reproduces, one level up, the +same mistake as the positional line key. + +Every verdict therefore records the `(base, head)` it was given against. When +the scope's base moves, verdicts are **not deleted**: they are marked stale, +render as `⚠ approved against base a1b2c3 · base is now d4e5f6 (+7 +commits)`, and offer *confirm still good* / *re-review*. A stale verdict does +not count toward "everything settled", so the review cannot be submitted on +it — through `ReviewVerdictBar`'s existing `submitRefusalLabel`, which is +already the mechanism for "you cannot submit yet, and here is why". + +**Only when the base move could matter.** Marking stale on *any* base move +treats "main advanced by seven commits in an unrelated subsystem" +identically to "main advanced by a commit rewriting a function this hunk +calls". Only the second can invalidate a reading, and on an active repository +the first is the overwhelming majority. + +So the base delta is intersected before anything is marked: one +`git diff --name-only ..` through `ProcessRunner`, against the +scope's own files **and** the files declaring symbols the scope's hunks +reference — the same unique-name rule and the same graph §4.2 already +produces. An empty intersection updates the recorded base and marks nothing. + +Two ways this is deliberately imprecise, both erring the same way: + +- If the old base cannot be resolved — a force-push, a garbage-collected + commit — everything is marked stale. Failing to the safe side is the only + defensible default for a signal about what was read. +- The intersection is file-level and lexical. A base change that alters + behaviour without touching a file this scope names or references will not + mark anything, which is §4.3's boundary reappearing: drydock does not index + the repository, so it cannot see that far. + +This narrows when the mark fires. It does not fix what happens when it fires +often anyway — see §14. + +Deleting them instead was considered and rejected: a rebase is routine, and a +tool that discards a forty-hunk review every time the base branch advances +teaches reviewers not to mark anything, which costs more than it protects. +Keeping them silently was rejected for the reason the whole section exists. +Stale is a third state because the honest answer is neither "still valid" nor +"never happened". + +### 9.3 No migration, and one thing that follows from that + +Re-keying verdicts would normally need a one-way rewrite of the human's +records, and an earlier draft carried one as this design's least recoverable +step. **There are no recorded verdicts to migrate**, so it is deleted rather +than written — the new key simply starts empty. + +One consequence worth acting on rather than parking: with no verdicts under +the old `file:` scheme either, `AnnotationStore.migrateLegacyVerdicts` has no +remaining caller once this lands. Its `merge` helper is kept and promoted +(§9.1); the migration wrapper around it is dead code, and AGENTS.md is +explicit that dead code is deleted rather than parked. Flagged here rather +than assumed, since confirming "no verdicts anywhere" is a check, not a +reading of the source. + +### 9.4 What this supersedes + +An earlier draft of this section derived **intent ids** from content and +re-anchored verdicts across a regrouping by hunk overlap. Both existed for +one reason: verdicts were keyed to the grouping, so a probabilistic agent +regrouping destroyed the human's work. + +Keying to hunk content removes the reason. Nothing durable is keyed to a +grouping any more, so an agent may regroup freely, twice, differently — the +reviewed state does not notice. The earlier concern that agent sections are +probabilistic while structural ones are stable is answered at its root rather +than compensated for: **it stops mattering how stable the grouping is.** + +Content-derived intent ids are kept only where they still earn their place: +`reads` (§8) references intents within one call, and resolving those to +content-derived ids keeps an ordering assertion meaningful across a re-run. +Nothing persists under them. + +### 9.5 Determinism is a requirement, not a property + +Calling the computed layer stable is a claim the code has to keep: + +- The sort's tie-break is total (§6.1), so no two runs can order equal units + differently. +- **No `HashMap` or `HashSet` iteration order** anywhere in graph + construction, edge matching, grouping or the sort. Insertion-ordered or + sorted collections only. This is the cheapest way to lose the property and + the hardest to notice, because a single-JVM test run will usually agree + with itself. +- The same diff produces a byte-identical grouping and reading path, twice in + one process and across two processes (§13). + +### 9.6 Settling more than one hunk at once + +Reading is per hunk; settling is often not. Three units, one action each: + +- **Section** — `a` / `r` / `u` with the rail focused, as today. Expands to + the section's unsettled hunks, so the existing key keeps its existing + meaning and simply now has a defined effect on overlapping sections. +- **File** — `⇧A` / `⇧R`, every hunk of the current file in this scope. +- **Hunk** — `a` / `r` / `u` with the diff column focused. + +The unit follows focus rather than adding a parallel key set, which is the +same rule `[` / `]` already follow (§7.3). The verdict bar names the unit an +action will hit, because a key whose target depends on focus must say what it +is about to do. + +**Settling a section settles its shared hunks everywhere**, by construction — +there is one flag. That is the intended behaviour and the reason the +`✓ reviewed in ①` marker exists: the effect has to be visible in the other +section, or it reads as state changing on its own. + +### 9.7 The agent rechecks staleness, and may only add to it + +§9.2's relevance filter is file-level and lexical, and it names its own blind +spot: a base change that alters behaviour without touching a file this scope +names is invisible to it. An agent has no such boundary — it can read the +base delta and the approved hunk and say whether the change actually +undermines the reading, which is the one thing neither the digest nor the +intersection can do. + +**It may not clear an approval.** That is the line the whole MCP surface is +drawn around: the human-side writes were kept off the tool list because +exposing them *"would let an agent approve its own work"*, `propose*` is +recorded and never applied, and `AUTO_APPROVED` is labelled as the agent's +assertion rather than the human's. A recheck does not get an exception. + +**But the two directions are not equally risky, and treating them alike would +waste the capability:** + +| Assessment | Effect | +|---|---| +| **Affected** | Applied. The verdict is marked stale, or stays stale, with the agent's reason. | +| **Unaffected** | Rendered as advice beside the stale mark. Never clears it; the human still confirms. | + +"Affected" applies because it can only ever *add* staleness — it asks for +more reading, never less — and because it is exactly how §9.2's blind spot +gets closed. An agent wrong in that direction costs a wasted re-read. An +agent wrong in the other direction would cost an approval on code nobody +re-read, which is the outcome this whole section refuses, so that direction +stays advisory. + +This is `migrateLegacyVerdicts`' asymmetry — any `CHANGES` wins, `APPROVED` +needs everything — applied to a different question. + +**Trigger.** A base move that marks anything stale dispatches a bounded +recheck through the subagent review form (`AgentCapabilities.supportsSubagents`; +inline harnesses simply do not get one), so the assessment is usually already +present when the reviewer returns. It is a small, bounded task by +construction — it reads one base delta and the stale hunks, not the change — +which is why it is worth a dispatch of its own rather than a full re-review. + +**Tool.** `review_recheck(scopeId, assessments[{hunkId, affected, why}])`. +Assessments render as **claimed**, not measured (§6.5). It is the only new +tool in this design, and it is here because nothing existing carries a +statement *about a verdict* — findings are about code and are anchored to +line keys, and folding this into `review_finding` would put staleness opinions +into the open-findings count that drives the `◨n` badge. + +**What it does not fix.** The reviewer still clicks *confirm still good* +(§14). What changes is what they are looking at when they click: "9 of these +12 are untouched by this base move; these 3 are, and here is why". Reflexive +confirmation of nine uninteresting items is a far smaller loss than reflexive +confirmation of all twelve, and the three get read. + +## 10. Parsing and packaging + +### 10.1 The binding + +`io.github.bonede:tree-sitter:0.25.3`, plus one artifact per grammar. Its jar +bundles `aarch64-macos`, `x86_64-macos`, `x86_64-windows`, and both Linux +natives — precisely Drydock's supported set, including the Windows path the +JediTermFX backend serves. + +`ch.usi.si.seart:java-tree-sitter:1.12.0` is the alternative binding and was +not chosen: the bonede artifacts carry the grammars as sibling Maven +coordinates, which is what makes §9.2 a packaging decision rather than a +build-a-grammar-toolchain project. + +### 10.2 `GrammarRegistry` + +Extension to grammar, resolved by lookup at first use. **A grammar absent +from the classpath is the lexical path (§4.2), not an error.** That single +rule is what keeps the shipped language set a packaging decision instead of +an architectural one, lets the `.app` and the `jbangJar` ship different sets, +and means an unsupported language never produces a broken surface — only a +coarser one. + +Starter set and jar sizes, from Maven Central: java 324 KB, kotlin 1706 KB, +python 402 KB, javascript 304 KB, typescript 750 KB, go 255 KB, rust 617 KB, +c 436 KB, cpp 1456 KB, plus the 774 KB core — about 7.0 MB. + +### 10.3 Deviations, stated rather than discovered + +- **The loader writes outside Drydock's profile directory.** + `NativeUtils.loadLib` extracts the platform-matched library from the jar to + `~/.tree-sitter/tree-sitter-lib/`, rooted at `user.home`, CRC32-verifies it + and `System.load`s it. There is no system property to redirect it; the path + is a compiled-in constant. Removing this would mean forking the binding. + It is recorded here because a file appearing under a user's home directory + that Drydock did not obviously create is exactly the sort of thing that + should be written down before it is found. +- **First load is disk I/O and a native load**, so it runs on a background + executor and never on the FX thread. +- **JNI, not FFM.** The AGENTS.md native rules govern FFM upcalls and AppKit + threading; there are no upcalls and no callbacks here, so they do not + apply. `--enable-native-access=ALL-UNNAMED` is already in + `applicationDefaultJvmArgs` and covers JDK 26's restricted `System.load`. +- **`RuntimeImageModuleListTest` runs jdeps against the app jar and its + runtime classpath.** The jlink `--add-modules` list may need to move; the + test is the thing that will say so. + +## 11. Degradation + +Every failure has one stated outcome, and none of them is a silently empty +reading path. + +| Failure | Outcome | +|---|---| +| No grammar for a file's language | Lexical scan (§4.2). Not logged — it is the normal case. | +| Native library fails to load | Every file lexical. WARNING once per process, not per file. | +| Unsupported OS/arch (`Does not support arch`) | Same as above. | +| `git grep` missing, failing or timed out | Out-of-diff fan-in absent; entry points rank on the remaining three signals. WARNING with an stderr excerpt. | +| Cycle among changed symbols | Named on screen (§6.1). | +| No edges at all (nothing references anything) | Grouping and order both fall back to today's `FallbackIntents` (kind, directory) behaviour, unchanged. | +| Edges, but no agent | Sections are grouped and ordered structurally, titled by hub symbol, explained from structural facts (§5.4). The floor this design is really about. | +| A component with no dominant symbol | Titled by directory tail, as today. | + +## 12. Deletions and additions + +**Added**: `ChangeGraph`, `Sections` (§5), `ReadingPath`, `GrammarRegistry`, a graph-algorithm +class (Kahn, Tarjan), the rail's PATH mode, per-hunk link footer rows, the +`p` shortcut and its overlay row, `reads` on `review_intents`, provenance +marking on order and links (§6.5), the out-of-diff caller popover source +(§7.4), overlapping section membership (§5.6), hunk-keyed reviewed state +(§9), file- and section-level settle actions (§9.6), the `review_recheck` +tool and its subagent dispatch (§9.7), the +`sections` include on `review_scope` (§5.5), and the tree-sitter +dependencies. + +**Changed**: `FallbackIntents` (graph-backed grouping, with today's +directory clustering kept as its own fallback), `ReviewVerdict` (keyed by +hunk content digest and carrying the `(base, head)` it was given against, not +`intentId`), `AnnotationStore` (verdicts stored per hunk; +`migrateLegacyVerdicts`' merge promoted from a one-off migration to the live +section derivation), `ReviewVerdictBar` (progress counted in hunks; the +acting unit named; stale verdicts refuse submit), `ReviewIntentRail` (two modes, derived section state, `✓ reviewed in +①` markers), `ReviewDiffColumn` (footer +rows), `ReviewDiffRows` (the row model gains a link row), `ReviewToolCodec` +and `McpToolRouter` (`reads`, and content-derived ids at decode), +`ShortcutsOverlay`, `app/build.gradle.kts`. + +**Deleted**: nothing. This is additive to a surface that works. + +**Not built**: no concept map, no community detection, no persisted graph, no +repository index, no graph library, no new MCP tool. + +## 13. Verification + +Headless tests: + +- `ChangeGraph`: unique-name match produces an edge; an ambiguous name does + not; an intra-file reference does not; a file with no grammar contributes + uses but no declarations. +- `Sections`: a `.h` groups with its same-basename `.cpp`; a header with no + changed symbol groups with the changed `.cpp` that references it; two + components stay two sections; a test referencing a changed symbol lands in + that symbol's section and sorts after it; a test referencing nothing changed + forms its own section; a mixed section is tagged `Kind.CHANGE`; and an + edgeless diff reproduces today's (kind, directory) clustering exactly, + tests separated included. +- Section titles: agent title wins; hub symbol when there is one; directory + tail when no symbol dominates. +- `ReadingPath`: foundation-before-dependent on a hand-built graph; a cycle + becomes one named unit rather than an arbitrary break; an edgeless graph + reproduces `FallbackIntents`' existing order exactly (a pinned regression — + this is what "no reviewer configured still works" means). +- Entry-point ranking: each of the four signals in isolation, and the tie + order between them. +- Fan-in: `git grep` absent, failing, and timing out are three distinct + logged outcomes, and none of them empties the path. A successful run keeps + its `file:line` matches, and a changed file's own occurrences are excluded + from them. +- Provenance: a measured order and a `reads`-claimed order render with + different warrants, and a scope carrying both an agent grouping and a + computed link set marks each correctly. +- Overlap: a header appearing in two sections is one flag; settling it in one + shows it settled in the other with a `reviewed in` marker; a section's + derived state follows the asymmetric merge (any `CHANGES` wins, `APPROVED` + needs every hunk); progress counts distinct hunks, not the sum of section + sizes. +- Re-diff: a hunk that only moved keeps its verdict; a hunk whose changed + lines or context changed loses it; an edit elsewhere in the same file does + not; and a scope whose every hunk changed comes back fully unsettled rather + than fully settled. +- Settle actions: `a` on a focused rail settles the section's unsettled + hunks, `⇧A` the current file, `a` on a focused diff column one hunk; each + is visible in the other sections that share those hunks. +- Recheck: an "affected" assessment marks a verdict stale even when the + file-level intersection found nothing; an "unaffected" assessment never + clears one, and a scope where every assessment says unaffected still + refuses submit until the human confirms; a harness without subagent support + gets no dispatch and no error. +- Relevance: a base move touching only unrelated files marks nothing and + updates the recorded base; an unresolvable old base marks everything. +- Staleness: a base move marks verdicts stale rather than deleting them, + stale verdicts do not count as settled, submit refuses with a reason, and + *confirm still good* clears the mark without re-opening the hunk. +- Stability: the same diff yields a byte-identical grouping and reading path twice in one + process **and** across two processes (the cross-process run is what catches + a hash-ordered collection). +- Regrouping: an agent re-run that produces a completely different grouping + changes no reviewed state at all — the regression that pins §9.4. +- `GrammarRegistry`: a missing grammar takes the lexical path and logs + nothing; a failing native load logs once and takes the lexical path. +- `review_intents` with `reads`: order follows it; a `reads` cycle is named; + a `reads` entry naming an unknown intent is rejected with the batch, since + a batch is all-or-nothing. +- The rail's PATH mode: `[`/`]` step hunks, `n` still steps unsettled + intents, and the mode round-trips through `p`. + +In the running app, with screenshots rather than assertions about them: + +- The rail in PATH mode at a realistic window width — rails have truncated + before, and this mode's rows carry more text than an intent card. +- A hunk with all three link kinds, at each density. +- A named cycle. + +## 14. Risks + +- **The links are lexical and will sometimes be wrong.** The unique-name rule + makes a false edge unlikely rather than impossible, and the mitigation is + honesty rather than accuracy: the UI calls them occurrences, as the symbol + popover already does, and the agent's `reads` overrides the computed order + where it matters. +- **7 MB of grammars** in the `.app` and the `.dmg`, growing with every + language added. §10.2 is what keeps this a decision that can be revisited + per artifact rather than a commitment. +- **A second native-loading path** beside libghostty, with its own extraction + directory and failure mode. It is JNI and callback-free, which is what + keeps it from interacting with the FFM rules, but it is still a second way + for a launch to fail on someone's machine. +- **`git grep` on a large repository.** One spawn with a short timeout, whose + failure costs one ranking signal and nothing else. +- **Over-grouping.** The source design flags this as an open item and its + own reference output shows it: one section carrying 17 hunks across 9 + files, explained as "the core of the PR" and then enumerating five + unrelated sub-changes. A connected component is as large as the call graph + makes it, and nothing here splits it. No rule is invented for this (§15) — + it is recorded so that a 9-file section is recognised as the known failure + rather than as a bug in the grouping. Overlap (§5.6) drains most of it — a + component no longer has to absorb a file merely to keep it visible — and + placing tests in context (§5.3) pushes modestly the other way. +- **A digest is unforgiving, deliberately, and including context widens + that.** Reformatting a file, or a rebase that rewrites whitespace, + unsettles a review that was substantively finished — and with context in + the digest, an edit *near* a hunk unsettles it too. The alternative, a + fuzzier anchor, buys comfort by risking the one outcome §9.2 refuses, so + the strictness is chosen rather than accepted. Whether to normalise + whitespace before digesting is left open (§15). +- **Staleness noise has no solution, and this is accepted rather than + mitigated.** A reviewer working in a hot area will be marked stale + repeatedly and will learn to click *confirm still good* without reading, + at which point the mark is worth less than no mark. The relevance filter + (§9.2) narrows *when* it fires; it cannot change what a human does when it + fires often anyway, and no arrangement of this feature can — the signal's + value comes from being rare, and whether it is rare is a property of the + repository, not of the design. + + What follows from accepting it has to be said rather than left implied: + **submit-blocking rests on a signal that degrades exactly where it matters + most.** In a fast-moving area — the area most likely to invalidate a + reading — reflexive confirmation is the expected behaviour, so a blocked + submit is theatre precisely there. The block is kept because it is right in + the ordinary case and because the alternative is silence, but it is not a + guarantee, and nothing else in this design may be built on the assumption + that a confirmed verdict was re-read. + + The agent recheck (§9.7) is the one thing that meaningfully helps, and it + helps by changing what the reviewer is looking at rather than by removing + the click. It does not make the mark trustworthy; it makes the mark + *sorted*. + +## 15. Open items + +- **Whether the hunk digest normalises whitespace** before hashing. Doing so + survives reformatting; not doing so keeps the guarantee exact. No evidence + yet either way. +- **Splitting an over-large component** (§14). Articulation points and a + size cap are the obvious candidates and both can split a section through + the middle of one idea, which is worse than a large honest section. + Deliberately unresolved. +- **Exposing computed *links* to the agent** through a `review_scope` + include, so a reviewer can correct a bad lexical edge. Still deferred; the + `sections` include (§5.5) is not this. +- **Hunk-to-symbol mapping is by line-range overlap**, which is coarse for a + hunk touching two adjacent declarations. Carried over from v2 unresolved. +- **Entry-point ranking is a first cut.** Four signals in a fixed order, with + no weighting and no evidence yet that the order is right. +- **A clustering tier** for same-concept links, if shared-symbol proves too + noisy (§2.2). +- **Cross-language edges** — a Java call into a native symbol, a template + referencing a handler. The unique-name rule spans languages by accident + rather than by design, and nothing here decides whether that is a feature.