From f2116cf582bd67ae5322e37453236233bcace723 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 01:36:29 +0200 Subject: [PATCH 001/113] Review navigation gets a design: entry points, order and links Adapts an external design (review-helper v2, 2026-08-21) that solves the problem drydock's Review board has left open: the rail is ordered by FallbackIntents' kind heuristic, which is a better order than the diff's own and still not the order the change was made in. The adaptation is mostly subtraction, because most of that pipeline already exists here under other names -- its resolve-source is SessionReviewScopes, its sections are ReviewIntent, its LLM section-namer is review_intents, its served UI is SessionReviewView. What is missing is order-hunks.py, which becomes ChangeGraph + ReadingPath. Four decisions are recorded with the reasoning that produced them, because none is recoverable from the result: - The SQLite KG is cut. It is a file in v2 only because that is a multi-process pipeline; drydock is one process, so the graph is in-memory with scope lifetime and nothing needs invalidating or collecting. - The concept map is cut and same-concept links are kept. An earlier draft bundled them and that was a dependency chain, not an argument. The map loses to the intent rail on redundancy and to RailLayout on width; the links survive with a shared changed symbol as their source, which names its own reason where a community id cannot -- and that removes the last caller of a clustering algorithm. - JGraphT is rejected on value, not quality: 1.27MB plus transitives, a jlink module-list entry RuntimeImageModuleListTest pins, and a POM dependency jbangJar bundles nothing of, for Kahn and Tarjan. - Tree-sitter is taken (io.github.bonede, whose natives are exactly our platform set) with a grammar-absent-means-lexical rule, so the shipped language set stays a packaging decision. The one honest loss is written down rather than engineered around: a diff-scoped graph cannot see a caller in an untouched file, so v2's best entry-point signal is restored by one bounded git grep instead of by the repository-wide index this codebase has twice refused to build. Co-Authored-By: Claude Opus 5 (1M context) --- .../2026-08-22-review-navigation-design.md | 449 ++++++++++++++++++ 1 file changed, 449 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-22-review-navigation-design.md 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..c63061a9 --- /dev/null +++ b/docs/superpowers/specs/2026-08-22-review-navigation-design.md @@ -0,0 +1,449 @@ +# 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. It cannot tell you +where to start reading it, or why one hunk follows another. + +The intent rail is ordered — but by `FallbackIntents`' kind heuristic +(production before refactors before config before tests) and then +alphabetically by directory. That is a better order than the diff's own, and +it is still not the order the change was *made* in. A reviewer opening a +45-file branch lands on intent 1 because it sorted first, not because it is +the thing the rest depends on. + +What is missing is the middle of a review: a data-model change read before +the code that uses it, an interface read before its implementation, and a +visible answer to "what does this hunk have to do with the one I just read". + +## 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` (title, kind, risk, rationale) | +| Pi agent naming sections | `review_intents` over MCP | +| `annotations.py`, `post-comments.py` | `AnnotationStore`, `SubmitPlan`, `GitHubReviewService` | +| **`order-hunks.py`** | **new: `ChangeGraph` + `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 (§8) | +| 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 reading path computed from +it, a second mode for the intent rail, per-hunk links in the diff column, one +optional field on `review_intents`, and tree-sitter parsing with a lexical +fallback. + +Out of scope, and unchanged: scope identity, the findings margin, verdicts, +progress counting, the submit sheet, the annotation store, and every other +`review_*` tool. Nothing here changes what a human settles or how it is +recorded. This adds a reading order to a board that already works. + +## 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 -F -f -- `, listing every +uniquely-named changed declaration, excluding the changed files themselves, +counting out-of-diff occurrences per name. 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. + +## 5. The reading path + +`app.drydock.review.ReadingPath` computes, from a `ChangeGraph`: + +### 5.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 (§5.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. + +### 5.2 Entry points + +Ranked by, in order: + +1. **Out-of-diff fan-in** (§4.3) — called from outside the change. +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 the one place + this design takes a side where v2 offers a preference: Drydock's fallback + grouping already puts tests last, and two orderings disagreeing about it + would be worse than either. +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 (§5.1), and wherever it falls in an agent-supplied one, +because that order is the reviewer's and is not re-sorted (§5.4). + +### 5.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. + +### 5.4 The reviewer's order wins + +`ReadingPath` orders `FallbackIntents` **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. + +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. Where it surfaces + +### 6.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 §5.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. + +### 6.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. + +### 6.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 progress is intent-keyed regardless of what the rail is showing. + +`ShortcutsOverlay` gains the `p` row: advertised and bound must match. + +## 7. MCP surface + +One optional field, no new tool. + +`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 §5.1). With `reads` absent, +the agent's array order stands (§5.4). With no agent at all, `ReadingPath` +supplies the order. Three sources, one rendering path. + +`review_scope` is unchanged. Exposing the computed links to the agent as an +optional include was considered and deferred (§13): the agent can already +read the diff, and an include that exists so the agent can correct Drydock's +lexical guesses is a feature that should be asked for before it is built. + +## 8. Parsing and packaging + +### 8.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 §8.2 a packaging decision rather than a +build-a-grammar-toolchain project. + +### 8.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. + +### 8.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. + +## 9. 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 (§5.1). | +| No edges at all (nothing references anything) | Order falls back to the kind heuristic — i.e. exactly today's `FallbackIntents` behaviour. | + +## 10. Deletions and additions + +**Added**: `ChangeGraph`, `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`, and the +tree-sitter dependencies. + +**Changed**: `ReviewIntentRail` (two modes), `ReviewDiffColumn` (footer +rows), `ReviewDiffRows` (the row model gains a link row), `ReviewToolCodec` +and `McpToolRouter` (`reads`), `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. + +## 11. 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. +- `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. +- `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. + +## 12. 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. §8.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. + +## 13. Open items + +- **Exposing computed links to the agent** through a `review_scope` include, + so a reviewer can correct a bad lexical edge. Deferred until asked for + (§7). +- **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. From df1e53c7f917f337ebf03e1837b559f2be12ac94 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 01:57:50 +0200 Subject: [PATCH 002/113] Review navigation design: grouping is the binding constraint, not order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three amendments, each from a question the first draft could not answer. Grouping was the assumption that broke. The draft mapped v2's "sections" onto ReviewIntent and called the row done -- true of the agent path, false of the computed one. Run on a real C++ PR the rail reads "main/cpp · 12 files", "test/cpp · 4 files", "cpp/hotspot · 6 files": FallbackIntents' (kind, directory) key titled by shortDirectory(), with no structural input at all. Ordering that better sequences bad groups and stops. So sections now come from connected components of the file-level reference graph, with the two conventions a C/C++ change is unreadable without -- a .h with its same-basename .cpp, and a header with any changed .cpp that references it even when the referenced symbol is not itself in a changed hunk. Drydock draws the sections; the agent names them, which is v2's own split and what keeps the floor working with no agent: correctly grouped and ordered, only plainly named. review_scope gains a `sections` include so the agent can accept-and-name rather than regroup blind -- reversing this document's own deferral of that include, on the evidence. v2's inconsistent test handling is NOT inherited: drydock's rule that tests get their own card is kept and applied inside a component, so it holds by decision rather than by luck. Assisted review was the second question, and it exposed a hole: §4.3 ran git grep, counted the matches and threw the locations away. A fan-in with nowhere to click is a statistic, not comprehension. It keeps -n now and feeds the occurrence popover the symbol lens already has, so `u` and `a` reach usages and the agent 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. Stability was the third. Verdicts are keyed (scopeId, intentId), IntentGrouping.set replaces wholesale, and agent ids are agent strings -- so a re-review orphans every approval the human gave. AnnotationStore already carries the scar of the structural version of this bug, with an asymmetric merge that refuses to approve code nobody read; it only knows the `file:` prefix. Intent ids become content-derived, regrouping re-anchors by hunk overlap on that same merge, and determinism stops being a property and becomes a requirement with a test. Order and links now also carry provenance, because a measured edge and a claimed one fail differently and a reviewer has to tell them apart. Co-Authored-By: Claude Opus 5 (1M context) --- .../2026-08-22-review-navigation-design.md | 476 +++++++++++++++--- 1 file changed, 396 insertions(+), 80 deletions(-) diff --git a/docs/superpowers/specs/2026-08-22-review-navigation-design.md b/docs/superpowers/specs/2026-08-22-review-navigation-design.md index c63061a9..799bc9e7 100644 --- a/docs/superpowers/specs/2026-08-22-review-navigation-design.md +++ b/docs/superpowers/specs/2026-08-22-review-navigation-design.md @@ -8,19 +8,44 @@ lens that says what it is.* ## 1. Why -Drydock's Review surface can already show you a change. It cannot tell you -where to start reading it, or why one hunk follows another. +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. -The intent rail is ordered — but by `FallbackIntents`' kind heuristic -(production before refactors before config before tests) and then -alphabetically by directory. That is a better order than the diff's own, and -it is still not the order the change was *made* in. A reviewer opening a -45-file branch lands on intent 1 because it sorted first, not because it is -the thing the rest depends on. +Run against a real C++ pull request, the rail reads: -What is missing is the middle of a review: a data-model change read before -the code that uses it, an interface read before its implementation, and a -visible answer to "what does this hunk have to do with the one I just read". +``` +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` (2 files), +then `Crash-protected resolve()`, then the tests for each. 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 @@ -35,13 +60,13 @@ parts that do not exist are the parts worth building. |---|---| | `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` (title, kind, risk, rationale) | +| 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` + `ReadingPath`** | +| **`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 (§8) | +| 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 @@ -99,15 +124,22 @@ 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 reading path computed from -it, a second mode for the intent rail, per-hunk links in the diff column, one -optional field on `review_intents`, and tree-sitter parsing with a lexical -fallback. +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`; content-derived intent ids; and tree-sitter parsing with a +lexical fallback. -Out of scope, and unchanged: scope identity, the findings margin, verdicts, -progress counting, the submit sheet, the annotation store, and every other -`review_*` tool. Nothing here changes what a human settles or how it is -recorded. This adds a reading order to a board that already works. +Out of scope, and unchanged: scope identity, the findings margin, the verdict +model, the submit sheet, and every other `review_*` tool. Nothing here changes +*what* a human settles — only how the things to be settled are drawn, ordered +and named. + +One caveat on that last line, stated because it is the sort of thing that is +easier to find in a spec than in a bug report: redrawing the groups changes +what a verdict is *about*, which is why §9 is not optional and why +`AnnotationStore` appears in §12 despite the paragraph above. ## 4. The change graph @@ -165,27 +197,130 @@ 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 -F -f -- `, listing every -uniquely-named changed declaration, excluding the changed files themselves, -counting out-of-diff occurrences per name. 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. +`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. -## 5. The reading path +**`-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: Drydock's rule, applied deliberately + +Drydock's fallback 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."* + +The reference output does both — two test files get their own sections while +two others stay inside the core section. That is not a principle, it is +whatever the components fell out as, and inheriting it would make Drydock's +behaviour depend on the shape of the call graph rather than on a decision. + +**The rule is kept, and moved inside the component.** A component splits into +at most one production section and one test section, adjacent, production +first. That preserves the existing stated preference and produces the good +half of the reference output — a test section next to the subject it covers — +uniformly rather than by luck. + +### 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. + +## 6. The reading path `app.drydock.review.ReadingPath` computes, from a `ChangeGraph`: -### 5.1 Order +### 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 (§5.2) goes first, then `FallbackIntents`' +— 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 @@ -200,11 +335,13 @@ 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. -### 5.2 Entry points +### 6.2 Entry points Ranked by, in order: -1. **Out-of-diff fan-in** (§4.3) — called from outside the change. +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 the one place @@ -214,10 +351,10 @@ Ranked by, in order: 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 (§5.1), and wherever it falls in an agent-supplied one, -because that order is the reviewer's and is not re-sorted (§5.4). +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). -### 5.3 Links +### 6.3 Links Per hunk, cross-file only, deduplicated by target hunk: @@ -232,11 +369,11 @@ Per hunk, cross-file only, deduplicated by target hunk: they have in common, and that is the thing this link says. Labels name files and symbols (`③ SessionReviewScopes.java`), never raw node -ids. +ids, and carry their provenance (§6.5). -### 5.4 The reviewer's order wins +### 6.4 The reviewer's order wins -`ReadingPath` orders `FallbackIntents` **only**. +`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 @@ -246,18 +383,47 @@ 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. Where it surfaces +### 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. -### 6.1 The rail has two modes +## 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 §5.4, with `START HERE` on the +- **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 @@ -267,7 +433,7 @@ out a new column just as firmly, and `RailLayout` is untouched. Selecting a row in either mode drives the diff column, as selecting an intent does today. -### 6.2 Links in the diff column +### 7.2 Links in the diff column A hunk with links gains a footer row beneath it: @@ -280,7 +446,7 @@ 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. -### 6.3 Keys +### 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 @@ -290,7 +456,30 @@ because progress is intent-keyed regardless of what the rail is showing. `ShortcutsOverlay` gains the `p` row: advertised and bound must match. -## 7. MCP surface +### 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 One optional field, no new tool. @@ -300,18 +489,101 @@ 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 §5.1). With `reads` absent, -the agent's array order stands (§5.4). With no agent at all, `ReadingPath` -supplies the order. Three sources, one rendering path. - -`review_scope` is unchanged. Exposing the computed links to the agent as an -optional include was considered and deferred (§13): the agent can already -read the diff, and an include that exists so the agent can correct Drydock's -lexical guesses is a feature that should be asked for before it is built. - -## 8. Parsing and packaging - -### 8.1 The binding +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. Stability: what survives a re-run + +### 9.1 Two layers that move for different reasons + +The computed layer is deterministic given a diff — parse, unique-name match, +and a sort with a total tie-break (§6.1) — so it moves only when the code +moves. The agent layer moves when you **ask again**. + +That asymmetry is not a quality judgement; an agent's grouping is the better +semantic answer and §6.4 stands. It matters because the human's accumulated +work is keyed to the grouping, and a layer that moves under a reviewer who +changed nothing will take that work with it. + +### 9.2 The defect this closes + +`ReviewVerdict` is keyed `(scopeId, intentId)` and `IntentGrouping.set` +replaces a scope's whole grouping. Agent intent ids are whatever string the +agent emitted. So the routine case — the author pushes a fix, the reviewer +asks for a re-review — mints new ids, and **every approval the human gave is +orphaned**. A finished review asks to be done again. + +The structural version of this bug has already been paid for once. +`AnnotationStore.migrateLegacyVerdicts` exists because the fallback grouping +stopped emitting one intent per file, and its merge is deliberately +asymmetric: any `CHANGES` among a group's files makes the group `CHANGES`, +but `APPROVED` requires **every** file settled, because *"silently approving +code nobody looked at is the one outcome this must never produce."* That +machinery only recognises the `file:` prefix, so it cannot help an agent +regrouping. + +This predates the present design. It is closed here rather than deferred +because §8's `reads` makes the **ordering** depend on agent ids too, which +turns a persistence bug into a navigation one. + +### 9.3 Structure supplies identity, the agent supplies semantics + +**Intent ids are content-derived**: a digest over the intent's ordered hunk +id set, computed at the MCP boundary, not trusted from the agent's string. A +re-run that produces the same grouping produces the same ids, so verdicts +survive by construction rather than by migration. + +The agent's own `id` degrades to a **label local to one call** — what `reads` +references — and is resolved to content ids at decode time. It is not a +persisted key, which is the property that made it unsafe. + +Two consequences worth stating rather than discovering: + +- **A re-run with the same hunks and a better title is an upsert.** Same id, + new title and rationale, verdict intact — the same contract `review_finding` + already keeps, where a re-run refreshes the reviewer's statement and the + human's decisions survive. +- **An intent naming no hunks has nothing to key on.** `ReviewIntent` already + permits one, and it keeps the agent's string as its id and does not survive + a re-run. Documented rather than papered over: there is no content to hash, + and inventing stability for it would mean carrying a verdict onto a group + whose membership is unknowable. + +**Regrouping re-anchors by hunk overlap**, generalising +`migrateLegacyVerdicts` from its one-off `file:` case to any change of +grouping, and keeping its asymmetric merge verbatim — any `CHANGES` carries, +`APPROVED` needs full coverage, a partial group is left for the human to +re-settle and its old verdicts stay on disk. + +### 9.4 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 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 reading path, twice in one process + and across two processes (§13). + +## 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 @@ -320,10 +592,10 @@ 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 §8.2 a packaging decision rather than a +coordinates, which is what makes §9.2 a packaging decision rather than a build-a-grammar-toolchain project. -### 8.2 `GrammarRegistry` +### 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 @@ -336,7 +608,7 @@ 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. -### 8.3 Deviations, stated rather than discovered +### 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 @@ -356,7 +628,7 @@ c 436 KB, cpp 1456 KB, plus the 774 KB core — about 7.0 MB. runtime classpath.** The jlink `--add-modules` list may need to move; the test is the thing that will say so. -## 9. Degradation +## 11. Degradation Every failure has one stated outcome, and none of them is a silently empty reading path. @@ -367,32 +639,48 @@ reading path. | 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 (§5.1). | -| No edges at all (nothing references anything) | Order falls back to the kind heuristic — i.e. exactly today's `FallbackIntents` behaviour. | +| 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. | -## 10. Deletions and additions +## 12. Deletions and additions -**Added**: `ChangeGraph`, `ReadingPath`, `GrammarRegistry`, a graph-algorithm +**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`, and the -tree-sitter dependencies. - -**Changed**: `ReviewIntentRail` (two modes), `ReviewDiffColumn` (footer +`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), content-derived intent ids and overlap re-anchoring (§9), 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), `ReviewIntentRail` (two +modes), `ReviewDiffColumn` (footer rows), `ReviewDiffRows` (the row model gains a link row), `ReviewToolCodec` -and `McpToolRouter` (`reads`), `ShortcutsOverlay`, `app/build.gradle.kts`. +and `McpToolRouter` (`reads`, and content-derived ids at decode), +`AnnotationStore` (re-anchor by hunk overlap, generalising +`migrateLegacyVerdicts`), `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. -## 11. Verification +## 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 component splits into adjacent + production/test sections, production first; and an edgeless diff reproduces + today's (kind, directory) clustering exactly. +- 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 — @@ -400,7 +688,19 @@ Headless tests: - 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. + 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. +- Stability: the same diff yields a byte-identical reading path twice in one + process **and** across two processes (the cross-process run is what catches + a hash-ordered collection). +- Intent identity: a re-run with the same hunks keeps its verdicts and + refreshes its titles; a re-run that regroups re-anchors by overlap, with + `CHANGES` carrying and a partially-settled group left unapproved; an intent + naming no hunks keeps the agent's id and is documented as not surviving. - `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; @@ -416,7 +716,7 @@ In the running app, with screenshots rather than assertions about them: - A hunk with all three link kinds, at each density. - A named cycle. -## 12. Risks +## 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 @@ -424,7 +724,7 @@ In the running app, with screenshots rather than assertions about them: 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. §8.2 is what keeps this a decision that can be revisited + 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 @@ -432,12 +732,28 @@ In the running app, with screenshots rather than assertions about them: 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. - -## 13. Open items - -- **Exposing computed links to the agent** through a `review_scope` include, - so a reviewer can correct a bad lexical edge. Deferred until asked for - (§7). +- **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. +- **Content-derived ids change the meaning of a stored key.** Verdicts + written under an agent's string id before this lands are re-anchored by + overlap on first read, which is the same shape as the migration that + already exists — but it is a one-way rewrite of the human's records, and it + is the place in this design where a mistake is least recoverable. + +## 15. Open items + +- **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 From 69b2cfe4e08b1f02fc975a8a415187132db24222 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 02:04:39 +0200 Subject: [PATCH 003/113] Tests are reviewed beside the code they pin, not on their own card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FallbackIntents makes kind part of the group key so a test never shares a card with its subject. An earlier draft of this design kept that rule and applied it inside each computed 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. Dropping the rule needs nothing to replace it. A test references the symbol under test, so a changed declaration in guards.h and its exercise in hotspot_crash_protection_ut.cpp already share a component, and the edge runs test->implementation so the implementation sorts first: here is the change, here is what pins it. A test that references nothing changed forms its own component, which is the honest outcome rather than a special case -- and that is also what the reference implementation's apparently inconsistent output actually was, the graph being right twice about two different kinds of test file. The quoted rationale is obsolete rather than wrong. Separating tests was the only way to stop them burying the change when there was no structure to consult; 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. Two consequences written down rather than left to be found: a mixed section is Kind.CHANGE because ReviewIntent.Kind holds one value and a section with production code in it cannot honestly be tagged tests, and the entry-point ranking's "not a test" signal is demoted to a tie-break for when the graph is silent -- it now decides only the case it should, a test-only section with no edges into it. The kind key survives untouched in the no-edges fallback, where there is still nothing better to guess with. Co-Authored-By: Claude Opus 5 (1M context) --- .../2026-08-22-review-navigation-design.md | 82 +++++++++++++------ 1 file changed, 59 insertions(+), 23 deletions(-) diff --git a/docs/superpowers/specs/2026-08-22-review-navigation-design.md b/docs/superpowers/specs/2026-08-22-review-navigation-design.md index 799bc9e7..787e3787 100644 --- a/docs/superpowers/specs/2026-08-22-review-navigation-design.md +++ b/docs/superpowers/specs/2026-08-22-review-navigation-design.md @@ -25,9 +25,9 @@ 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` (2 files), -then `Crash-protected resolve()`, then the tests for each. The difference is -not presentation. `guards.h` and `guards.cpp` are one idea, a directory key +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. @@ -257,22 +257,54 @@ 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: Drydock's rule, applied deliberately +### 5.3 Tests are not split out; the graph places them -Drydock's fallback makes kind part of the group key, so a test never shares a +`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."* -The reference output does both — two test files get their own sections while -two others stay inside the core section. That is not a principle, it is -whatever the components fell out as, and inheriting it would make Drydock's -behaviour depend on the shape of the call graph rather than on a decision. - -**The rule is kept, and moved inside the component.** A component splits into -at most one production section and one test section, adjacent, production -first. That preserves the existing stated preference and produces the good -half of the reference output — a test section next to the subject it covers — -uniformly rather than by luck. +**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 @@ -344,10 +376,11 @@ Ranked by, in order: 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 the one place - this design takes a side where v2 offers a preference: Drydock's fallback - grouping already puts tests last, and two orderings disagreeing about it - would be worse than either. + 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 @@ -676,9 +709,11 @@ Headless tests: 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 component splits into adjacent - production/test sections, production first; and an edgeless diff reproduces - today's (kind, directory) clustering exactly. + 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 @@ -738,7 +773,8 @@ In the running app, with screenshots rather than assertions about them: 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. + rather than as a bug in the grouping. Placing tests in context (§5.3) makes + a section modestly larger, which trades against this deliberately. - **Content-derived ids change the meaning of a stored key.** Verdicts written under an agent's string id before this lands are re-anchored by overlap on first read, which is the same shape as the migration that From f4447217e14d353c6fbedb2e4e9a4564300dde04 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 02:13:07 +0200 Subject: [PATCH 004/113] A hunk belongs to every section that explains it, and is reviewed once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sections stop partitioning the change. A header groups with its same-basename .cpp AND with every changed .cpp that references it; those are different sections, and disjoint membership made one of them lose -- which is what counters.h looks like in the reference output. Overlap removes the choice: a file appears wherever it is needed to understand what is being read. It also drains most of the over-grouping risk, since a component no longer has to swallow a file merely to keep the connection visible. What overlap breaks is arithmetic. Verdicts are keyed (scopeId, intentId) and the bar reads "n/m intents settled"; both assume a partition. So the verdict moves to the hunk, keeping all three decisions, and a section's state becomes derived by the merge migrateLegacyVerdicts already implements -- any CHANGES makes the section CHANGES, APPROVED needs every hunk. That rule was written for exactly this question and stops being a migration. The anchor is a content digest, not the existing stable line key. The line key is positional, so one pushed commit shifts every key below an insertion and a clean flag comes back covering lines nobody read -- precisely what migrateLegacyVerdicts refuses to produce. A finding landing a few lines off is an annoyance; an approval landing a few lines off is a silent lie about what was reviewed. Digesting the changed lines makes a re-diff correct by construction: a hunk that only moved stays settled, a hunk whose content changed does not, and a hunk in three sections is one flag because it is one digest. This supersedes most of the previous section. Content-derived intent ids and re-anchor-by-overlap existed only because verdicts were keyed to the grouping; nothing durable is keyed to it now, so an agent may regroup twice and differently and the reviewed state does not notice. The worry that agent sections are probabilistic where structural ones are stable is answered at its root instead of compensated for: it stops mattering. Content-derived ids survive only for `reads`, where nothing persists under them. Scope §3 said the verdict model was out of scope. That was not a scoping decision, it was an unexamined assumption that sections stay disjoint, and it is corrected rather than widened. Co-Authored-By: Claude Opus 5 (1M context) --- .../2026-08-22-review-navigation-design.md | 273 ++++++++++++------ 1 file changed, 178 insertions(+), 95 deletions(-) diff --git a/docs/superpowers/specs/2026-08-22-review-navigation-design.md b/docs/superpowers/specs/2026-08-22-review-navigation-design.md index 787e3787..2fe0f2cb 100644 --- a/docs/superpowers/specs/2026-08-22-review-navigation-design.md +++ b/docs/superpowers/specs/2026-08-22-review-navigation-design.md @@ -131,15 +131,14 @@ column; `reads` on `review_intents` and a `sections` include on `review_scope`; content-derived intent ids; and tree-sitter parsing with a lexical fallback. -Out of scope, and unchanged: scope identity, the findings margin, the verdict -model, the submit sheet, and every other `review_*` tool. Nothing here changes -*what* a human settles — only how the things to be settled are drawn, ordered -and named. +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. -One caveat on that last line, stated because it is the sort of thing that is -easier to find in a spec than in a bug report: redrawing the groups changes -what a verdict is *about*, which is why §9 is not optional and why -`AnnotationStore` appears in §12 despite the paragraph above. +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 @@ -342,6 +341,31 @@ 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`: @@ -485,9 +509,20 @@ unchanged. 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 progress is intent-keyed regardless of what the rail is showing. +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.5), 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. -`ShortcutsOverlay` gains the `p` row: 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 @@ -537,68 +572,70 @@ 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. Stability: what survives a re-run - -### 9.1 Two layers that move for different reasons - -The computed layer is deterministic given a diff — parse, unique-name match, -and a sort with a total tie-break (§6.1) — so it moves only when the code -moves. The agent layer moves when you **ask again**. - -That asymmetry is not a quality judgement; an agent's grouping is the better -semantic answer and §6.4 stands. It matters because the human's accumulated -work is keyed to the grouping, and a layer that moves under a reviewer who -changed nothing will take that work with it. - -### 9.2 The defect this closes - -`ReviewVerdict` is keyed `(scopeId, intentId)` and `IntentGrouping.set` -replaces a scope's whole grouping. Agent intent ids are whatever string the -agent emitted. So the routine case — the author pushes a fix, the reviewer -asks for a re-review — mints new ids, and **every approval the human gave is -orphaned**. A finished review asks to be done again. - -The structural version of this bug has already been paid for once. -`AnnotationStore.migrateLegacyVerdicts` exists because the fallback grouping -stopped emitting one intent per file, and its merge is deliberately -asymmetric: any `CHANGES` among a group's files makes the group `CHANGES`, -but `APPROVED` requires **every** file settled, because *"silently approving -code nobody looked at is the one outcome this must never produce."* That -machinery only recognises the `file:` prefix, so it cannot help an agent -regrouping. - -This predates the present design. It is closed here rather than deferred -because §8's `reads` makes the **ordering** depend on agent ids too, which -turns a persistence bug into a navigation one. - -### 9.3 Structure supplies identity, the agent supplies semantics - -**Intent ids are content-derived**: a digest over the intent's ordered hunk -id set, computed at the MCP boundary, not trusted from the agent's string. A -re-run that produces the same grouping produces the same ids, so verdicts -survive by construction rather than by migration. - -The agent's own `id` degrades to a **label local to one call** — what `reads` -references — and is resolved to content ids at decode time. It is not a -persisted key, which is the property that made it unsafe. - -Two consequences worth stating rather than discovering: - -- **A re-run with the same hunks and a better title is an upsert.** Same id, - new title and rationale, verdict intact — the same contract `review_finding` - already keeps, where a re-run refreshes the reviewer's statement and the - human's decisions survive. -- **An intent naming no hunks has nothing to key on.** `ReviewIntent` already - permits one, and it keeps the agent's string as its id and does not survive - a re-run. Documented rather than papered over: there is no content to hash, - and inventing stability for it would mean carrying a verdict onto a group - whose membership is unknowable. - -**Regrouping re-anchors by hunk overlap**, generalising -`migrateLegacyVerdicts` from its one-off `file:` case to any change of -grouping, and keeping its asymmetric merge verbatim — any `CHANGES` carries, -`APPROVED` needs full coverage, a partial group is left for the human to -re-settle and its old verdicts stay on disk. +## 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 The anchor is a content digest, not a position + +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` 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. + +So a hunk's verdict is keyed by a **digest of its changed lines** (with its +file path), and that makes a re-diff correct by construction: + +- a hunk that only moved keeps its digest, and stays settled; +- a hunk whose content changed gets a new digest, and is unsettled — which is + right, because it is not the code that was read; +- a hunk appearing in three sections is one digest, so it is one flag, which + is what makes §5.6 work at all. + +### 9.3 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.4 Determinism is a requirement, not a property @@ -607,12 +644,32 @@ 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 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 reading path, twice in one process - and across two processes (§13). + 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.5 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. ## 10. Parsing and packaging @@ -683,17 +740,22 @@ reading path. 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), content-derived intent ids and overlap re-anchoring (§9), the +(§7.4), overlapping section membership (§5.6), hunk-keyed reviewed state +(§9), file- and section-level settle actions (§9.5), 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), `ReviewIntentRail` (two -modes), `ReviewDiffColumn` (footer +directory clustering kept as its own fallback), `ReviewVerdict` (keyed by +hunk content digest, not `intentId`), `AnnotationStore` (verdicts stored per +hunk; `migrateLegacyVerdicts`' merge promoted from a one-off migration to the +live section derivation, plus a one-way migration of existing intent-keyed +verdicts), `ReviewVerdictBar` (progress counted in hunks; the acting unit +named), `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), -`AnnotationStore` (re-anchor by hunk overlap, generalising -`migrateLegacyVerdicts`), `ShortcutsOverlay`, `app/build.gradle.kts`. +`ShortcutsOverlay`, `app/build.gradle.kts`. **Deleted**: nothing. This is additive to a surface that works. @@ -729,13 +791,25 @@ Headless tests: - 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. -- Stability: the same diff yields a byte-identical reading path twice in one +- 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 content + changed loses it; 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. +- Migration: verdicts stored under the old `(scopeId, intentId)` key are + carried onto hunks once, and a partially-covered section is left unapproved + rather than approved. +- 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). -- Intent identity: a re-run with the same hunks keeps its verdicts and - refreshes its titles; a re-run that regroups re-anchors by overlap, with - `CHANGES` carrying and a partially-settled group left unapproved; an intent - naming no hunks keeps the agent's id and is documented as not surviving. +- Regrouping: an agent re-run that produces a completely different grouping + changes no reviewed state at all — the regression that pins §9.3. - `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; @@ -773,16 +847,25 @@ In the running app, with screenshots rather than assertions about them: 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. Placing tests in context (§5.3) makes - a section modestly larger, which trades against this deliberately. -- **Content-derived ids change the meaning of a stored key.** Verdicts - written under an agent's string id before this lands are re-anchored by - overlap on first read, which is the same shape as the migration that - already exists — but it is a one-way rewrite of the human's records, and it - is the place in this design where a mistake is least recoverable. + 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. +- **Re-keying verdicts rewrites the human's records once.** Verdicts stored + under `(scopeId, intentId)` are carried onto hunks on first read. It is the + same shape as the migration that already exists and it is one-way, which + makes it the place in this design where a mistake is least recoverable. +- **A digest is unforgiving, deliberately.** Reformatting a file, or a + rebase that rewrites whitespace, changes every digest and unsettles a + review that was substantively finished. 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). ## 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. From b1fc1eff9366b32355c801a4be64a11726b44d5f Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 02:13:31 +0200 Subject: [PATCH 005/113] Scope names the reviewed-state change, not the id scheme it replaced The in-scope list still read "content-derived intent ids", which survived only as an implementation detail of `reads` once verdicts stopped being keyed to the grouping. What is actually in scope is overlapping section membership and reviewed state keyed to hunk content. Co-Authored-By: Claude Opus 5 (1M context) --- docs/superpowers/specs/2026-08-22-review-navigation-design.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-08-22-review-navigation-design.md b/docs/superpowers/specs/2026-08-22-review-navigation-design.md index 2fe0f2cb..228252f9 100644 --- a/docs/superpowers/specs/2026-08-22-review-navigation-design.md +++ b/docs/superpowers/specs/2026-08-22-review-navigation-design.md @@ -128,8 +128,8 @@ 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`; content-derived intent ids; and tree-sitter parsing with a -lexical fallback. +`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 From acce500928b6fd40e8b5607b2803076e9082d2d6 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 02:19:58 +0200 Subject: [PATCH 006/113] An approval is valid only for the content and base it was given against Two holes in the digest, one of them the same mistake the section was written to avoid. Context is now hashed alongside the changed lines. A changed-lines-only digest leaves an approval standing when the line above the hunk moves: the hunk is byte-identical, its surroundings are not, and a hunk means what it means in place. Review already fetches twelve lines of context, so this costs nothing. The limit is deliberate -- an edit elsewhere in the same file does not unsettle, because a file-wide digest re-reviews code nobody touched, and the context window is drawn where the reviewer could actually see while reading. The base is now recorded and can go stale. A diff means what it means against a base, and a digest over its own text cannot see the base move: a rebase leaves every hunk byte-identical while the code underneath them changed, which reproduces one level up exactly the positional-line-key mistake this section rejects. Verdicts carry the (base, head) they were given against; a base move marks them stale rather than deleting them, renders what moved, and refuses submit through the verdict bar's existing submitRefusalLabel. Deleting was rejected because a rebase is routine and a tool that discards a forty-hunk review on every base advance teaches reviewers not to mark anything. Keeping silently was rejected for the reason the section exists. Stale is a third state because neither of the two simple answers is true. There are no recorded verdicts, so the migration is deleted rather than written -- which removes what this design had called its least recoverable step. It follows that migrateLegacyVerdicts has no remaining caller once this lands: its merge helper is kept and promoted to the live section derivation, and the migration wrapper around it is dead code under AGENTS.md's rule. Flagged rather than assumed, since "no verdicts anywhere" is a check, not a reading of the source. Co-Authored-By: Claude Opus 5 (1M context) --- .../2026-08-22-review-navigation-design.md | 131 ++++++++++++------ 1 file changed, 92 insertions(+), 39 deletions(-) diff --git a/docs/superpowers/specs/2026-08-22-review-navigation-design.md b/docs/superpowers/specs/2026-08-22-review-navigation-design.md index 228252f9..fe10e3fa 100644 --- a/docs/superpowers/specs/2026-08-22-review-navigation-design.md +++ b/docs/superpowers/specs/2026-08-22-review-navigation-design.md @@ -512,7 +512,7 @@ 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.5), and +`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. @@ -599,27 +599,76 @@ it runs on every render rather than once. *claimed* rather than *measured* (§6.5), so a section approved entirely on the agent's assertion reads as one. -### 9.2 The anchor is a content digest, not a position +### 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` 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. +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. -So a hunk's verdict is keyed by a **digest of its changed lines** (with its -file path), and that makes a re-diff correct by construction: +**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. -- a hunk that only moved keeps its digest, and stays settled; -- a hunk whose content changed gets a new digest, and is unsettled — which is - right, because it is not the code that was read; -- a hunk appearing in three sections is one digest, so it is one flag, which - is what makes §5.6 work at all. +That makes a re-diff correct by construction: -### 9.3 What this supersedes +| 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". + +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 @@ -637,7 +686,7 @@ Content-derived intent ids are kept only where they still earn their place: content-derived ids keeps an ordering assertion meaningful across a re-run. Nothing persists under them. -### 9.4 Determinism is a requirement, not a property +### 9.5 Determinism is a requirement, not a property Calling the computed layer stable is a claim the code has to keep: @@ -651,7 +700,7 @@ Calling the computed layer stable is a claim the code has to keep: - The same diff produces a byte-identical grouping and reading path, twice in one process and across two processes (§13). -### 9.5 Settling more than one hunk at once +### 9.6 Settling more than one hunk at once Reading is per hunk; settling is often not. Three units, one action each: @@ -741,17 +790,17 @@ 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.5), the +(§9), file- and section-level settle actions (§9.6), 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, not `intentId`), `AnnotationStore` (verdicts stored per -hunk; `migrateLegacyVerdicts`' merge promoted from a one-off migration to the -live section derivation, plus a one-way migration of existing intent-keyed -verdicts), `ReviewVerdictBar` (progress counted in hunks; the acting unit -named), `ReviewIntentRail` (two modes, derived section state, `✓ reviewed in +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), @@ -796,20 +845,21 @@ Headless tests: 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 content - changed loses it; and a scope whose every hunk changed comes back fully - unsettled rather than fully settled. +- 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. -- Migration: verdicts stored under the old `(scopeId, intentId)` key are - carried onto hunks once, and a partially-covered section is left unapproved - rather than approved. +- 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.3. + 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; @@ -850,16 +900,19 @@ In the running app, with screenshots rather than assertions about them: 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. -- **Re-keying verdicts rewrites the human's records once.** Verdicts stored - under `(scopeId, intentId)` are carried onto hunks on first read. It is the - same shape as the migration that already exists and it is one-way, which - makes it the place in this design where a mistake is least recoverable. -- **A digest is unforgiving, deliberately.** Reformatting a file, or a - rebase that rewrites whitespace, changes every digest and unsettles a - review that was substantively finished. 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). +- **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 could become noise on an active base.** A scope whose base + branch moves several times a day will mark verdicts stale repeatedly, and a + *confirm still good* button clicked reflexively is worth less than no + button. The mitigation is that staleness is per-scope and one click, not + per-hunk — but if the base moves faster than the review is read, the signal + degrades, and nothing here prevents that. ## 15. Open items From 3879457cae02a1b4d8edfc40b3d315abd43b7195 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 02:34:01 +0200 Subject: [PATCH 007/113] Staleness fires only when the base move could matter, and says what it cannot fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marking every verdict stale on any base move treats "main advanced by seven commits in an unrelated subsystem" the same as "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 nearly all of them. The base delta is now intersected against the scope's own files and the files declaring symbols its hunks reference -- one git diff --name-only, and the same unique-name rule §4.2 already produces. An unresolvable old base marks everything, because failing safe is the only defensible default for a signal about what was read. The intersection is file-level and lexical, so a base change that alters behaviour without touching a file this scope names is missed: §4.3's boundary reappearing. That narrows when the mark fires and fixes nothing about what happens when it fires often anyway, so the risk is now recorded as accepted rather than mitigated. A reviewer in a hot area learns to confirm without reading, and no arrangement of this feature prevents that -- the mark's value comes from being rare, and rarity is a property of the repository, not of the design. The consequence is stated rather than implied: submit-blocking rests on a signal that degrades exactly where it matters most, so in a fast-moving area a blocked submit is theatre. 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 assume a confirmed verdict was re-read. Co-Authored-By: Claude Opus 5 (1M context) --- .../2026-08-22-review-navigation-design.md | 48 ++++++++++++++++--- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/specs/2026-08-22-review-navigation-design.md b/docs/superpowers/specs/2026-08-22-review-navigation-design.md index fe10e3fa..ce3b5ac4 100644 --- a/docs/superpowers/specs/2026-08-22-review-navigation-design.md +++ b/docs/superpowers/specs/2026-08-22-review-navigation-design.md @@ -646,6 +646,31 @@ 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. @@ -907,12 +932,23 @@ In the running app, with screenshots rather than assertions about them: 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 could become noise on an active base.** A scope whose base - branch moves several times a day will mark verdicts stale repeatedly, and a - *confirm still good* button clicked reflexively is worth less than no - button. The mitigation is that staleness is per-scope and one click, not - per-hunk — but if the base moves faster than the review is read, the signal - degrades, and nothing here prevents that. +- **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. ## 15. Open items From 567487f88b99a506f42fcdd0b50bfa5cccbb5733 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 02:39:51 +0200 Subject: [PATCH 008/113] An agent may add staleness to an approval, never take it away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §9.2's 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. What it may not do is clear an approval. That is the line the 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 the agent's assertion rather than the human's. A recheck gets no exception. But the two directions carry different risk, and treating them alike wastes the capability. "Affected" applies: it can only add staleness, asks for more reading rather than less, and is exactly how the blind spot closes; an agent wrong that way costs a wasted re-read. "Unaffected" stays advisory, because an agent wrong THAT way costs an approval on code nobody re-read, which is the outcome the section exists to refuse. It is migrateLegacyVerdicts' asymmetry -- any CHANGES wins, APPROVED needs everything -- pointed at a different question. A base move that marks anything stale dispatches a bounded recheck through the existing subagent review form, so the assessment is usually already there when the reviewer returns; it reads one base delta and the stale hunks, not the change, which is why it earns a dispatch rather than a full re-review. review_recheck is the only new tool in this design, and it exists because nothing carries a statement ABOUT a verdict: findings are about code, are anchored to line keys, and folding staleness opinions into them would put them in the open-findings count behind the ◨n badge. It does not fix the accepted risk. The reviewer still clicks confirm; what changes is that nine of the twelve are now visibly uninteresting and three are not. It does not make the mark trustworthy, it makes it sorted. Co-Authored-By: Claude Opus 5 (1M context) --- .../2026-08-22-review-navigation-design.md | 72 ++++++++++++++++++- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-08-22-review-navigation-design.md b/docs/superpowers/specs/2026-08-22-review-navigation-design.md index ce3b5ac4..88a8339e 100644 --- a/docs/superpowers/specs/2026-08-22-review-navigation-design.md +++ b/docs/superpowers/specs/2026-08-22-review-navigation-design.md @@ -549,7 +549,9 @@ asserts on its own authority, not what the reviewer can find out. ## 8. MCP surface -One optional field, no new tool. +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 @@ -745,6 +747,59 @@ 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 @@ -815,7 +870,8 @@ 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 +(§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. @@ -877,6 +933,13 @@ Headless tests: - 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. @@ -950,6 +1013,11 @@ In the running app, with screenshots rather than assertions about them: 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 From f9bdc9ac71a65675d68e470beb3c9d0210b5aebb Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 02:59:03 +0200 Subject: [PATCH 009/113] Review navigation gets an implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twenty-four tasks in three phases, each phase shippable on its own, in an order the spec forces rather than one chosen for convenience: reviewed state has to move to the hunk before sections can overlap, and sections have to overlap before §5.2's header conventions are expressible at all -- a header belongs with its implementation AND with everything referencing it, and disjoint membership makes one of those lose. One dependency is deliberately split across a phase boundary and says so in both halves: §9.2's relevance filter intersects a base delta against the scope's files and against the files declaring symbols its hunks reference. The second half needs the change graph, so Task 5 takes a Collection for exactly that reason and Task 15 widens it without moving a caller. Self-review caught one spec section with no task -- §6.5, provenance -- and Task 24 covers it. It is last because it is the smallest change touching the most rendering paths, and doing it once beats threading it through four tasks as they land. The notes at the end carry the two things that will otherwise be rediscovered the hard way: the full suite takes 14-20 minutes and must not be handed to a subagent under a 10-minute ceiling, and a section order that differs between runs is a hash-ordered collection rather than a bad algorithm. Co-Authored-By: Claude Opus 5 (1M context) --- .../plans/2026-08-22-review-navigation.md | 4593 +++++++++++++++++ 1 file changed, 4593 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-22-review-navigation.md 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. From fe5463506cf91b509c8d7594dcc846d99ebf3ecb Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 03:05:02 +0200 Subject: [PATCH 010/113] 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. --- .../java/app/drydock/review/HunkDigest.java | 55 +++++++++++ .../app/drydock/review/HunkDigestTest.java | 97 +++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 app/src/main/java/app/drydock/review/HunkDigest.java create mode 100644 app/src/test/java/app/drydock/review/HunkDigestTest.java 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/test/java/app/drydock/review/HunkDigestTest.java b/app/src/test/java/app/drydock/review/HunkDigestTest.java new file mode 100644 index 00000000..b37b6fc7 --- /dev/null +++ b/app/src/test/java/app/drydock/review/HunkDigestTest.java @@ -0,0 +1,97 @@ +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); + } +} From 80d8ef3cd894c36d5c929d45748a1138c28bd411 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 03:08:40 +0200 Subject: [PATCH 011/113] Fix: use import for Locale instead of fully-qualified class name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace java.util.Locale.ROOT with Locale.ROOT and add import to comply with the constraint 'Never inline fully-qualified class names — use imports'. --- app/src/test/java/app/drydock/review/HunkDigestTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/app/drydock/review/HunkDigestTest.java b/app/src/test/java/app/drydock/review/HunkDigestTest.java index b37b6fc7..437058ef 100644 --- a/app/src/test/java/app/drydock/review/HunkDigestTest.java +++ b/app/src/test/java/app/drydock/review/HunkDigestTest.java @@ -4,6 +4,7 @@ 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; @@ -92,6 +93,6 @@ 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); + assertEquals(digest.toLowerCase(Locale.ROOT), digest); } } From 04cf57b8e37a15f7ef39f563097a5743c3f86ccc Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 03:19:58 +0200 Subject: [PATCH 012/113] 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. --- .../app/drydock/review/ReviewVerdict.java | 56 ++++++++++++---- .../app/drydock/review/ReviewVerdictTest.java | 64 +++++++++++++++++++ 2 files changed, 108 insertions(+), 12 deletions(-) create mode 100644 app/src/test/java/app/drydock/review/ReviewVerdictTest.java 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/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")); + } +} From 799445de617c479884b8051233a00d8873ce241d Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 03:20:09 +0200 Subject: [PATCH 013/113] 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. --- .../java/app/drydock/mcp/McpToolRouter.java | 2 +- .../app/drydock/review/AnnotationStore.java | 142 +++-------- .../java/app/drydock/ui/MainWorkspace.java | 16 +- .../drydock/mcp/McpToolRouterReviewTest.java | 2 +- .../drydock/review/AnnotationStoreTest.java | 14 +- .../review/AnnotationStoreVerdictKeyTest.java | 92 +++++++ .../review/LegacyVerdictMigrationTest.java | 237 ------------------ .../app/drydock/ui/review/FakeReviewHost.java | 11 +- .../review/ReviewCarriedOverVerdictTest.java | 167 ------------ .../ui/review/ReviewVerdictBarFitTest.java | 2 +- 10 files changed, 142 insertions(+), 543 deletions(-) create mode 100644 app/src/test/java/app/drydock/review/AnnotationStoreVerdictKeyTest.java delete mode 100644 app/src/test/java/app/drydock/review/LegacyVerdictMigrationTest.java delete mode 100644 app/src/test/java/app/drydock/ui/review/ReviewCarriedOverVerdictTest.java diff --git a/app/src/main/java/app/drydock/mcp/McpToolRouter.java b/app/src/main/java/app/drydock/mcp/McpToolRouter.java index 3adcccff..02409172 100644 --- a/app/src/main/java/app/drydock/mcp/McpToolRouter.java +++ b/app/src/main/java/app/drydock/mcp/McpToolRouter.java @@ -362,7 +362,7 @@ private JsonValue reviewState(ManagedSessionId caller, JsonValue arguments) thro List intents = context.verdictsOf(scope.id()).stream() .map(verdict -> (JsonValue) JsonObject.empty() - .put("id", new JsonString(verdict.intentId())) + .put("id", new JsonString(verdict.hunkDigest())) .put("verdict", new JsonString(verdict.decision().wireName())) .put("note", verdict.note() .map(JsonString::new).orElse(JsonNull.INSTANCE))) diff --git a/app/src/main/java/app/drydock/review/AnnotationStore.java b/app/src/main/java/app/drydock/review/AnnotationStore.java index 7c83de03..1c3162ff 100644 --- a/app/src/main/java/app/drydock/review/AnnotationStore.java +++ b/app/src/main/java/app/drydock/review/AnnotationStore.java @@ -69,10 +69,13 @@ 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}. 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. */ - private static final int SCHEMA_VERSION = 3; + private static final int SCHEMA_VERSION = 4; private static final SecureRandom RANDOM = new SecureRandom(); /** @@ -94,7 +97,7 @@ 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<>(); /** Scopes whose review has been submitted. */ @@ -175,8 +178,8 @@ 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) { @@ -298,7 +301,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); @@ -310,104 +313,15 @@ private synchronized void putVerdictInternal(ReviewVerdict verdict) { } /** - * 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. + * The group's decision, or empty when its files do not support one. * - *

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++; - } - if (migrated > 0) { - persistAsync(); - } - return migrated; - } - - /** - * 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. + *

Deliberately asymmetric, because the two directions carry different + * risk: any {@code CHANGES} among the group's members makes the group + * {@code CHANGES} -- "something in here needs work" stays true of a + * group however it is drawn -- while {@code APPROVED} needs EVERY member + * to have been settled, since approving a group is a claim that the + * human read all of it. Silently approving code nobody looked at is the + * one outcome this must never produce.

*/ private static Optional merge(List covering) { if (covering.stream().anyMatch(verdict -> verdict != null @@ -425,15 +339,15 @@ private static Optional merge(List coveri : ReviewVerdict.Decision.AUTO_APPROVED); } - /** {@code u}: undoes the verdict on one intent. */ - public void clearVerdict(String scopeId, String intentId) { - if (clearVerdictInternal(scopeId, intentId)) { + /** {@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 intentId) { - if (verdicts.remove(new ReviewVerdict.Key(scopeId, intentId)) != null) { + private synchronized boolean clearVerdictInternal(String scopeId, String hunkDigest) { + if (verdicts.remove(new ReviewVerdict.Key(scopeId, hunkDigest)) != null) { persistAsync(); return true; } @@ -602,10 +516,12 @@ 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)); @@ -864,11 +780,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()); } diff --git a/app/src/main/java/app/drydock/ui/MainWorkspace.java b/app/src/main/java/app/drydock/ui/MainWorkspace.java index 4794edda..99789f9e 100644 --- a/app/src/main/java/app/drydock/ui/MainWorkspace.java +++ b/app/src/main/java/app/drydock/ui/MainWorkspace.java @@ -1952,15 +1952,7 @@ 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; + return intentGrouping.intentsFor(scope.id(), diff); } @Override @@ -1983,8 +1975,12 @@ public void setVerdict(ReviewScope scope, ReviewIntent intent, && blockingFindingOpen(scope, intent)) { return; } + // intent.id() stands in for the hunk digest until Task 6 moves + // this seam onto hunk keys; see the controller note in the + // task 2+3 brief. scope.base()/scope.head() are what this was + // actually judged against, so those are wired for real already. annotationStore.putVerdict(new ReviewVerdict(scope.id(), intent.id(), decision.get(), - Optional.empty(), Instant.now())); + Optional.empty(), Instant.now(), scope.base(), scope.head())); } @Override diff --git a/app/src/test/java/app/drydock/mcp/McpToolRouterReviewTest.java b/app/src/test/java/app/drydock/mcp/McpToolRouterReviewTest.java index 778c3962..a542211a 100644 --- a/app/src/test/java/app/drydock/mcp/McpToolRouterReviewTest.java +++ b/app/src/test/java/app/drydock/mcp/McpToolRouterReviewTest.java @@ -366,7 +366,7 @@ void answeringAnUnknownFindingIsRejected() { 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)); + 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)); 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/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/ui/review/FakeReviewHost.java b/app/src/test/java/app/drydock/ui/review/FakeReviewHost.java index 844a9eb8..ab003ce2 100644 --- a/app/src/test/java/app/drydock/ui/review/FakeReviewHost.java +++ b/app/src/test/java/app/drydock/ui/review/FakeReviewHost.java @@ -96,12 +96,7 @@ 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; + return intents.intentsFor(scope.id(), diff); } @Override @@ -119,8 +114,10 @@ public void setVerdict(ReviewScope scope, ReviewIntent intent, if (decision.get() == ReviewVerdict.Decision.APPROVED && blocked(scope, intent)) { return; } + // intent.id() stands in for the hunk digest until Task 6 moves this + // seam onto hunk keys (see the controller note in the task 2+3 brief). store.putVerdict(new ReviewVerdict(scope.id(), intent.id(), decision.get(), - Optional.empty(), Instant.now())); + Optional.empty(), Instant.now(), scope.base(), scope.head())); } private boolean blocked(ReviewScope scope, ReviewIntent intent) { 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/ReviewVerdictBarFitTest.java b/app/src/test/java/app/drydock/ui/review/ReviewVerdictBarFitTest.java index b370ee79..046acdeb 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewVerdictBarFitTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewVerdictBarFitTest.java @@ -86,7 +86,7 @@ void everyActionIsFullyLegibleAtTheCodeColumnFloor() { 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.empty(), java.time.Instant.EPOCH, "base-1", "head-1"))); assertNothingTruncated(); } From 47c64c58b3f61eedd909b842429042e91babce8f Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 03:44:31 +0200 Subject: [PATCH 014/113] review_state reports a verdict under the intent's own id, not its storage key McpToolRouter's review_state forwarded verdict.hunkDigest() straight onto the wire as intents[].id. That field is what an agent correlates against the ids it sent to review_intents, and today hunkDigest happens to equal the intent id (MainWorkspace/FakeReviewHost's placeholder), so nothing looked broken -- but nothing enforced it either, and the join would have silently broken the moment real hunk digests land. review_state now joins the scope's intents (McpSessionContext.intentsOf, mirroring IntentGrouping.intentsFor) against their verdicts by digest internally, and emits the intent's own id on the wire. AnnotationStore.merge is deleted rather than kept: Task 4 turned out to write a new VerdictMerge from scratch rather than move it, so keeping it only parked untested dead code. The setVerdict placeholders in MainWorkspace and FakeReviewHost get grep-able TODO(task-6) markers instead of a prose comment claiming scope.base()/head() are already correct -- they are ref names, not commit shas, so staleness is inert until Task 6 resolves them. A schema-4 entry carrying these decodes cleanly, so nothing else will surface the gap. --- .../app/drydock/mcp/McpSessionContext.java | 13 +++++- .../java/app/drydock/mcp/McpToolRouter.java | 41 +++++++++++++++---- .../mcp/WorkspaceMcpSessionContext.java | 5 +++ .../app/drydock/review/AnnotationStore.java | 27 ------------ .../java/app/drydock/ui/MainWorkspace.java | 7 ++-- .../drydock/mcp/FakeMcpSessionContext.java | 17 ++++++-- .../drydock/mcp/McpToolRouterReviewTest.java | 33 +++++++++++++++ .../app/drydock/ui/review/FakeReviewHost.java | 5 ++- 8 files changed, 103 insertions(+), 45 deletions(-) diff --git a/app/src/main/java/app/drydock/mcp/McpSessionContext.java b/app/src/main/java/app/drydock/mcp/McpSessionContext.java index c53847e6..8047f672 100644 --- a/app/src/main/java/app/drydock/mcp/McpSessionContext.java +++ b/app/src/main/java/app/drydock/mcp/McpSessionContext.java @@ -93,13 +93,24 @@ 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); /** Whether the human has submitted this scope's review. */ diff --git a/app/src/main/java/app/drydock/mcp/McpToolRouter.java b/app/src/main/java/app/drydock/mcp/McpToolRouter.java index 02409172..427a6577 100644 --- a/app/src/main/java/app/drydock/mcp/McpToolRouter.java +++ b/app/src/main/java/app/drydock/mcp/McpToolRouter.java @@ -10,6 +10,7 @@ import app.drydock.review.ReviewAnnotation; import app.drydock.review.ReviewIntent; import app.drydock.review.ReviewScope; +import app.drydock.review.ReviewVerdict; import app.drydock.review.Severity; import app.drydock.state.json.JsonValue; import app.drydock.state.json.JsonValue.JsonArray; @@ -24,7 +25,9 @@ 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.Optional; import java.util.logging.Level; import java.util.logging.Logger; @@ -355,18 +358,42 @@ 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. + * + *

TODO(task-6): intent.id() stands in for the hunk digest a verdict + * is actually looked up by (same placeholder as {@code + * MainWorkspace}/{@code FakeReviewHost}'s {@code setVerdict}); once an + * intent's verdict is derived from its hunks' real digests, this lookup + * becomes a real many-to-one join instead of an identity one.

+ */ 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.hunkDigest())) - .put("verdict", new JsonString(verdict.decision().wireName())) - .put("note", verdict.note() - .map(JsonString::new).orElse(JsonNull.INSTANCE))) - .toList(); + 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)) { + ReviewVerdict verdict = verdictsByDigest.get(intent.id()); + if (verdict == null) { + continue; + } + intents.add(JsonObject.empty() + .put("id", new JsonString(intent.id())) + .put("verdict", new JsonString(verdict.decision().wireName())) + .put("note", verdict.note() + .map(JsonString::new).orElse(JsonNull.INSTANCE))); + } return JsonObject.empty() .put("intents", new JsonArray(intents)) .put("findings", new JsonArray(context.findingsOf(scope.id()).stream() diff --git a/app/src/main/java/app/drydock/mcp/WorkspaceMcpSessionContext.java b/app/src/main/java/app/drydock/mcp/WorkspaceMcpSessionContext.java index a951f474..c28ca7ba 100644 --- a/app/src/main/java/app/drydock/mcp/WorkspaceMcpSessionContext.java +++ b/app/src/main/java/app/drydock/mcp/WorkspaceMcpSessionContext.java @@ -324,6 +324,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); diff --git a/app/src/main/java/app/drydock/review/AnnotationStore.java b/app/src/main/java/app/drydock/review/AnnotationStore.java index 1c3162ff..1c1912b2 100644 --- a/app/src/main/java/app/drydock/review/AnnotationStore.java +++ b/app/src/main/java/app/drydock/review/AnnotationStore.java @@ -312,33 +312,6 @@ private synchronized void putVerdictInternal(ReviewVerdict verdict) { persistAsync(); } - /** - * The group's decision, or empty when its files do not support one. - * - *

Deliberately asymmetric, because the two directions carry different - * risk: any {@code CHANGES} among the group's members makes the group - * {@code CHANGES} -- "something in here needs work" stays true of a - * group however it is drawn -- while {@code APPROVED} needs EVERY member - * to have been settled, since approving a group is a claim that the - * human read all of it. Silently approving code nobody looked at is the - * one outcome this must never produce.

- */ - 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 hunk. */ public void clearVerdict(String scopeId, String hunkDigest) { if (clearVerdictInternal(scopeId, hunkDigest)) { diff --git a/app/src/main/java/app/drydock/ui/MainWorkspace.java b/app/src/main/java/app/drydock/ui/MainWorkspace.java index 99789f9e..c6c24d57 100644 --- a/app/src/main/java/app/drydock/ui/MainWorkspace.java +++ b/app/src/main/java/app/drydock/ui/MainWorkspace.java @@ -1975,10 +1975,9 @@ public void setVerdict(ReviewScope scope, ReviewIntent intent, && blockingFindingOpen(scope, intent)) { return; } - // intent.id() stands in for the hunk digest until Task 6 moves - // this seam onto hunk keys; see the controller note in the - // task 2+3 brief. scope.base()/scope.head() are what this was - // actually judged against, so those are wired for real already. + // TODO(task-6): intent.id() stands in for HunkDigest.of(...). + // TODO(task-6): scope.base()/head() are ref names, not commit + // shas; staleness is inert until these resolve. annotationStore.putVerdict(new ReviewVerdict(scope.id(), intent.id(), decision.get(), Optional.empty(), Instant.now(), scope.base(), scope.head())); } diff --git a/app/src/test/java/app/drydock/mcp/FakeMcpSessionContext.java b/app/src/test/java/app/drydock/mcp/FakeMcpSessionContext.java index db984e75..d985b52b 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,10 @@ 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()); /** 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 +122,21 @@ 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) { 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) { diff --git a/app/src/test/java/app/drydock/mcp/McpToolRouterReviewTest.java b/app/src/test/java/app/drydock/mcp/McpToolRouterReviewTest.java index a542211a..b38a579a 100644 --- a/app/src/test/java/app/drydock/mcp/McpToolRouterReviewTest.java +++ b/app/src/test/java/app/drydock/mcp/McpToolRouterReviewTest.java @@ -365,6 +365,7 @@ void answeringAnUnknownFindingIsRejected() { @Test void reviewStateReportsVerdictsFindingsAndSubmission() throws Exception { context.annotations.add(finding("f1", Severity.BLOCKING)); + router.call(caller, "review_intents", intentsArgs(intentJson("i1", "Change", "MED"))); context.verdicts.add(new ReviewVerdict(SCOPE, "i1", ReviewVerdict.Decision.CHANGES, Optional.of("needs a test"), Instant.EPOCH, "base-1", "head-1")); context.submitted.add(SCOPE); @@ -378,6 +379,30 @@ 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, looked up by (today, placeholder) digest -- + * never whatever key a verdict happens to be stored under. A verdict + * stored under a key that names no registered intent -- exactly what a + * real hunk digest looks like once Task 6 wires one -- 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"))); + context.verdicts.add(new ReviewVerdict(SCOPE, "i1", 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"); + } + /** So a follow-up run fixes the right things and does not re-flag settled ones. */ @Test void reviewStateShowsAResolvedFindingAsResolved() throws Exception { @@ -425,6 +450,14 @@ private static JsonObject intentJson(String id, String title, String risk) { return obj; } + /** {@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/ui/review/FakeReviewHost.java b/app/src/test/java/app/drydock/ui/review/FakeReviewHost.java index ab003ce2..b0a5bc2c 100644 --- a/app/src/test/java/app/drydock/ui/review/FakeReviewHost.java +++ b/app/src/test/java/app/drydock/ui/review/FakeReviewHost.java @@ -114,8 +114,9 @@ public void setVerdict(ReviewScope scope, ReviewIntent intent, if (decision.get() == ReviewVerdict.Decision.APPROVED && blocked(scope, intent)) { return; } - // intent.id() stands in for the hunk digest until Task 6 moves this - // seam onto hunk keys (see the controller note in the task 2+3 brief). + // TODO(task-6): intent.id() stands in for HunkDigest.of(...). + // TODO(task-6): scope.base()/head() are ref names, not commit shas; + // staleness is inert until these resolve. store.putVerdict(new ReviewVerdict(scope.id(), intent.id(), decision.get(), Optional.empty(), Instant.now(), scope.base(), scope.head())); } From 8ea5a128837a7ff787ba3be20d9f97c46052370f Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 03:51:53 +0200 Subject: [PATCH 015/113] review_state degrades gracefully when a scope's diff cannot be produced Joining intents against verdicts made review_state call context.reviewDiff(scope) unconditionally, so a PR with no local checkout, or a git failure, now failed the whole call -- a live regression, since findings and submission status never depended on a diff before. review_state now reports those two regardless, and omits the intents key entirely (never an empty array) when the diff fails, logging the failure at WARNING with an excerpt. An empty array would read as "nothing is settled"; an absent key correctly says "cannot be known right now", the same absent-vs-zero rule the sidebar's badge follows. FakeMcpSessionContext.reviewDiff never threw, which is why no test caught this. It can now be made to throw, and a new test pins the degraded shape: findings and submission present, intents absent. --- .../java/app/drydock/mcp/McpToolRouter.java | 32 +++++++++++++++---- .../drydock/mcp/FakeMcpSessionContext.java | 15 ++++++++- .../drydock/mcp/McpToolRouterReviewTest.java | 23 +++++++++++++ 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/app/drydock/mcp/McpToolRouter.java b/app/src/main/java/app/drydock/mcp/McpToolRouter.java index 427a6577..de153dd9 100644 --- a/app/src/main/java/app/drydock/mcp/McpToolRouter.java +++ b/app/src/main/java/app/drydock/mcp/McpToolRouter.java @@ -366,6 +366,15 @@ private JsonValue reviewAnswer(ManagedSessionId caller, JsonValue arguments) thr * 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).

+ * *

TODO(task-6): intent.id() stands in for the hunk digest a verdict * is actually looked up by (same placeholder as {@code * MainWorkspace}/{@code FakeReviewHost}'s {@code setVerdict}); once an @@ -377,6 +386,22 @@ private JsonValue reviewState(ManagedSessionId caller, JsonValue arguments) thro JsonObject args = asObject(arguments); ReviewScope scope = requireScope(caller, args); + 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); @@ -394,12 +419,7 @@ private JsonValue reviewState(ManagedSessionId caller, JsonValue arguments) thro .put("note", verdict.note() .map(JsonString::new).orElse(JsonNull.INSTANCE))); } - return JsonObject.empty() - .put("intents", new JsonArray(intents)) - .put("findings", new JsonArray(context.findingsOf(scope.id()).stream() - .map(ReviewToolCodec::findingStateToJson) - .toList())) - .put("submitted", new JsonBoolean(context.reviewSubmitted(scope.id()))); + return intents; } // ---- review_comments ----------------------------------------------- diff --git a/app/src/test/java/app/drydock/mcp/FakeMcpSessionContext.java b/app/src/test/java/app/drydock/mcp/FakeMcpSessionContext.java index d985b52b..00268227 100644 --- a/app/src/test/java/app/drydock/mcp/FakeMcpSessionContext.java +++ b/app/src/test/java/app/drydock/mcp/FakeMcpSessionContext.java @@ -99,6 +99,16 @@ public List annotations(ManagedSessionId caller) { /** The diff {@link #reviewDiff} returns. */ 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<>(); @@ -122,7 +132,10 @@ public Optional reviewScope(String scopeId, Mana } @Override - public UnifiedDiff reviewDiff(app.drydock.review.ReviewScope scope) { + public UnifiedDiff reviewDiff(app.drydock.review.ReviewScope scope) throws McpToolException { + if (reviewDiffFailure != null) { + throw reviewDiffFailure; + } return reviewDiff; } diff --git a/app/src/test/java/app/drydock/mcp/McpToolRouterReviewTest.java b/app/src/test/java/app/drydock/mcp/McpToolRouterReviewTest.java index b38a579a..b51683d7 100644 --- a/app/src/test/java/app/drydock/mcp/McpToolRouterReviewTest.java +++ b/app/src/test/java/app/drydock/mcp/McpToolRouterReviewTest.java @@ -403,6 +403,29 @@ void reviewStateReportsTheIntentIdNotTheVerdictsStorageKey() throws Exception { 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 { From 5a38b01f155ee5d750c7c970d11893ecb66fca48 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 03:57:03 +0200 Subject: [PATCH 016/113] 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. --- .../java/app/drydock/review/VerdictMerge.java | 66 ++++++++++++++++ .../app/drydock/review/VerdictMergeTest.java | 78 +++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 app/src/main/java/app/drydock/review/VerdictMerge.java create mode 100644 app/src/test/java/app/drydock/review/VerdictMergeTest.java 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/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())); + } +} From 36d4faef2e3ac9479b906bb13f3e910c3ed29469 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 04:01:56 +0200 Subject: [PATCH 017/113] 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. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/app/drydock/review/BaseMove.java | 105 ++++++++++++++++++ .../java/app/drydock/review/BaseMoveTest.java | 60 ++++++++++ 2 files changed, 165 insertions(+) create mode 100644 app/src/main/java/app/drydock/review/BaseMove.java create mode 100644 app/src/test/java/app/drydock/review/BaseMoveTest.java 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..acadde8f --- /dev/null +++ b/app/src/main/java/app/drydock/review/BaseMove.java @@ -0,0 +1,105 @@ +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; + } +} 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..01402eb0 --- /dev/null +++ b/app/src/test/java/app/drydock/review/BaseMoveTest.java @@ -0,0 +1,60 @@ +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())); + } +} From 6bd4de9256099b5f7aefc8254bf5dbcaef29fea5 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 04:06:45 +0200 Subject: [PATCH 018/113] Parsing of git diff output handles non-ASCII filenames correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git diff --name-only without -z emits C-style-quoted paths for files containing non-ASCII bytes or special characters. A file named "café.txt" would arrive as the literal string with quotes and octal escapes, never matching the scope's unquoted name, so a base move that genuinely touched the file would silently be recorded as not touching it. With -z, git emits raw unquoted paths NUL-separated and avoids quoting entirely. The parsing is extracted to a package-private static method so it can be tested directly without spawning git. A test confirms non-ASCII filenames round-trip intact. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/app/drydock/review/BaseMove.java | 25 ++++++++++++------- .../java/app/drydock/review/BaseMoveTest.java | 13 ++++++++++ 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/app/drydock/review/BaseMove.java b/app/src/main/java/app/drydock/review/BaseMove.java index acadde8f..954dac7a 100644 --- a/app/src/main/java/app/drydock/review/BaseMove.java +++ b/app/src/main/java/app/drydock/review/BaseMove.java @@ -51,7 +51,7 @@ public record Delta(boolean unresolvable, SortedSet 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", + List command = List.of("git", "diff", "--name-only", "-z", "--end-of-options", oldBase + ".." + newBase); try { ProcessResult result = ProcessRunner.run(command, worktree, TIMEOUT); @@ -60,14 +60,7 @@ public static Delta between(Path worktree, String oldBase, String newBase) { + 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); + 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<>()); @@ -80,6 +73,20 @@ public static Delta between(Path worktree, String oldBase, String newBase) { } } + /** + * 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}. diff --git a/app/src/test/java/app/drydock/review/BaseMoveTest.java b/app/src/test/java/app/drydock/review/BaseMoveTest.java index 01402eb0..2d83e345 100644 --- a/app/src/test/java/app/drydock/review/BaseMoveTest.java +++ b/app/src/test/java/app/drydock/review/BaseMoveTest.java @@ -57,4 +57,17 @@ void anEmptyDeltaCannotMatter() { 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); + } } From 9e9f0ca8551fef4e480ae64d330e38e5721f7d89 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 04:58:29 +0200 Subject: [PATCH 019/113] 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. The Host seam moves with it. verdict() is keyed by a hunk digest and setVerdict() takes the section's digests, computed by the view: only the view knows which diff the human is looking at, and a host free to re-derive them is free to derive them from a different one. The intent stays on setVerdict because the refusal of an approval over an open blocking finding is stated in terms of the intent. IntentHunks.digestsOf is the one walk from a section to its digests -- the view, review_state and the recording path all needed it, and three copies of that walk would drift. Base and head are resolved from ref names to commits. scope.base() is a branch name, so a verdict recorded against "main" and compared against "main" could never be stale and staleness was an inert no-op. GitStatusService gains commitForRefBlocking, a sibling of headCommitBlocking with the same shape and the same empty-rather-than-throwing rationale, and MainWorkspace resolves both refs off the FX thread. A ref that will not resolve is stored as the literal "unresolved" rather than as the ref name: staleAgainst already asks !baseCommit.equals(currentBase), so no real sha can equal it and the verdict reads as stale until a human confirms -- fail-safe with no second code path. Storing the ref name would have been the silent-approval bug. Whether a base move could actually matter is memoized per (scope, oldBase, newBase) and computed off the FX thread; until the answer lands the delta is unresolvable, which is "could matter". Absent must not read as clean. A failed lookup is recorded as unresolvable rather than left absent, or the next render would spawn the same git again, forever. Considered deriving a section's decision through the full section state when asking whether a sibling settled a shared hunk; that recurses between two sections sharing one. The sibling is asked only for its merged decision. Considered leaving the digests uncached; every card of the rail asks for its section on every rebuild, which is thousands of SHA-256s per keystroke on the FX thread, so they are memoized against the diff instance and keyed by the whole intent -- a reviewer may re-issue an id over different hunks, and an id-keyed memo would answer with a grouping that no longer exists. --- .../app/drydock/git/GitStatusService.java | 38 ++ .../java/app/drydock/mcp/McpToolRouter.java | 30 +- .../java/app/drydock/review/IntentHunks.java | 53 +++ .../java/app/drydock/ui/MainWorkspace.java | 172 ++++++++- .../drydock/ui/review/ReviewIntentRail.java | 66 +++- .../drydock/ui/review/ReviewVerdictBar.java | 53 ++- .../drydock/ui/review/SessionReviewView.java | 324 +++++++++++++++-- app/src/main/resources/app/drydock/ui/app.css | 18 + .../app/drydock/git/GitStatusServiceTest.java | 49 +++ .../drydock/mcp/McpToolRouterReviewTest.java | 39 +- .../ui/OpenSessionTabReviewSubTabTest.java | 17 +- .../app/drydock/ui/review/FakeReviewHost.java | 47 ++- .../review/ReviewFindingsAndVerdictsTest.java | 24 +- .../ui/review/ReviewHunkProgressTest.java | 343 ++++++++++++++++++ .../ui/review/ReviewVerdictBarFitTest.java | 10 +- .../ReviewVerdictBarNavigationTest.java | 6 +- 16 files changed, 1178 insertions(+), 111 deletions(-) create mode 100644 app/src/main/java/app/drydock/review/IntentHunks.java create mode 100644 app/src/test/java/app/drydock/ui/review/ReviewHunkProgressTest.java 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/McpToolRouter.java b/app/src/main/java/app/drydock/mcp/McpToolRouter.java index de153dd9..99a195bf 100644 --- a/app/src/main/java/app/drydock/mcp/McpToolRouter.java +++ b/app/src/main/java/app/drydock/mcp/McpToolRouter.java @@ -7,11 +7,13 @@ import app.drydock.mcp.McpSessionContext.RenameOutcome; import app.drydock.git.UnifiedDiff; import app.drydock.review.AnnotationStatus; +import app.drydock.review.IntentHunks; import app.drydock.review.ReviewAnnotation; import app.drydock.review.ReviewIntent; import app.drydock.review.ReviewScope; import app.drydock.review.ReviewVerdict; import app.drydock.review.Severity; +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; @@ -375,11 +377,9 @@ private JsonValue reviewAnswer(ManagedSessionId caller, JsonValue arguments) thr * be known right now" (the same absent-vs-zero rule the sidebar's * {@code ◨n} badge follows).

* - *

TODO(task-6): intent.id() stands in for the hunk digest a verdict - * is actually looked up by (same placeholder as {@code - * MainWorkspace}/{@code FakeReviewHost}'s {@code setVerdict}); once an - * intent's verdict is derived from its hunks' real digests, this lookup - * becomes a real many-to-one join instead of an identity one.

+ *

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); @@ -409,15 +409,25 @@ private List intentsStateToJson(ReviewScope scope) throws McpToolExce UnifiedDiff diff = context.reviewDiff(scope); List intents = new ArrayList<>(); for (ReviewIntent intent : context.intentsOf(scope.id(), diff)) { - ReviewVerdict verdict = verdictsByDigest.get(intent.id()); - if (verdict == null) { + 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(verdict.decision().wireName())) - .put("note", verdict.note() - .map(JsonString::new).orElse(JsonNull.INSTANCE))); + .put("verdict", new JsonString(decision.get().wireName())) + .put("note", note.map(JsonString::new).orElse(JsonNull.INSTANCE))); } return intents; } 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/ui/MainWorkspace.java b/app/src/main/java/app/drydock/ui/MainWorkspace.java index c6c24d57..9b3c8e81 100644 --- a/app/src/main/java/app/drydock/ui/MainWorkspace.java +++ b/app/src/main/java/app/drydock/ui/MainWorkspace.java @@ -47,6 +47,7 @@ import app.drydock.mcp.WorkspaceMcpSessionContext; import app.drydock.process.SshCommandBuilder; import app.drydock.review.AnnotationStore; +import app.drydock.review.BaseMove; import app.drydock.review.IntentGrouping; import app.drydock.review.ReviewAnnotation; import app.drydock.review.ReviewIntent; @@ -122,6 +123,7 @@ 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; @@ -185,6 +187,16 @@ public final class MainWorkspace extends BorderPane implements WorkspaceNavigato private static final java.util.concurrent.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 java.util.concurrent.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; @@ -1913,6 +1925,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(); @@ -1956,15 +1974,17 @@ public List intents(ReviewScope scope, UnifiedDiff diff) { } @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, + public void setVerdict(ReviewScope scope, ReviewIntent intent, List hunkDigests, Optional decision) { 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 @@ -1975,11 +1995,21 @@ public void setVerdict(ReviewScope scope, ReviewIntent intent, && blockingFindingOpen(scope, intent)) { return; } - // TODO(task-6): intent.id() stands in for HunkDigest.of(...). - // TODO(task-6): scope.base()/head() are ref names, not commit - // shas; staleness is inert until these resolve. - annotationStore.putVerdict(new ReviewVerdict(scope.id(), intent.id(), decision.get(), - Optional.empty(), Instant.now(), scope.base(), scope.head())); + 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 String currentBase(ReviewScope scope) { + return baselineOf(scope).base(); + } + + @Override + public BaseMove.Delta baseMove(ReviewScope scope, String recordedBase) { + return reviewBaseMove(scope, recordedBase); } @Override @@ -2249,6 +2279,130 @@ private String reviewInstruction(ReviewScope scope) { return ReviewInstructions.forScope(scope.id(), supportsSubagents); } + /** + * 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) { + LOG.log(Level.WARNING, "Could not resolve the review base of scope " + + scope.id() + "; its verdicts read as stale", failure); + 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 (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); + } + } + private boolean blockingFindingOpen(ReviewScope scope, ReviewIntent intent) { return annotationStore.forScope(scope.id()).stream() .filter(finding -> finding.intentId() 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..8396c4a3 100644 --- a/app/src/main/java/app/drydock/ui/review/ReviewIntentRail.java +++ b/app/src/main/java/app/drydock/ui/review/ReviewIntentRail.java @@ -60,8 +60,13 @@ final class ReviewIntentRail extends VBox { private final Map buttonsByIntentId = new LinkedHashMap<>(); 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 java.util.function.Function stateLookup = + intent -> SessionReviewView.SectionState.unknown(); private Consumer onSelected = intent -> { }; private Runnable onToggleCollapse = () -> { }; private String selectedId; @@ -97,8 +102,11 @@ 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( + java.util.function.Function lookup) { + this.stateLookup = lookup == null + ? intent -> SessionReviewView.SectionState.unknown() + : lookup; } /** @@ -229,10 +237,12 @@ private void rebuild() { header.setTitleVisible(!collapsed); header.setHintVisible(!collapsed); + // 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"); @@ -267,11 +277,15 @@ private Button buildCard(ReviewIntent 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(); + SessionReviewView.SectionState state = stateLookup.apply(intent); + Optional decision = state.decision(); + boolean settled = decision.isPresent() || intent.autoApprove(); if (settled) { card.getStyleClass().add("settled"); } + if (state.stale()) { + card.getStyleClass().add("stale"); + } Region heat = new Region(); heat.getStyleClass().addAll("review-intent-heat", intent.risk().styleClass()); @@ -284,7 +298,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 +346,33 @@ 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.settledHunks() > 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. + Label progress = new Label(state.settledHunks() + "/" + state.totalHunks() + " hunks"); + progress.getStyleClass().add("review-intent-hunk-progress"); + content.getChildren().add(progress); + } + if (!state.settledElsewhere().isEmpty()) { + // Settling one section settles hunks another shares. Naming where + // it happened 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); + } + if (state.stale()) { + Label stale = new Label("⚠ base moved — confirm"); + stale.getStyleClass().add("review-intent-stale"); + 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 +387,12 @@ 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: how many cards the rail drew, and how tall each one is. */ 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..15734cc7 100644 --- a/app/src/main/java/app/drydock/ui/review/ReviewVerdictBar.java +++ b/app/src/main/java/app/drydock/ui/review/ReviewVerdictBar.java @@ -84,10 +84,16 @@ interface Host { private final HBox actionRow = new HBox(10); 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; ReviewVerdictBar(Host host) { this.host = host; @@ -178,17 +184,17 @@ private void withIntent(java.util.function.Consumer action) { } /** - * Updates everything the bar shows. + * 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 @@ -197,6 +203,17 @@ void update(ReviewIntent currentIntent, Optional currentVerdict, 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 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 @@ -234,14 +251,14 @@ 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"); + : (totalHunks - settledHunks) + " hunks left · n jumps to the next"); - if (verdict.isPresent()) { - settledLabel.setText(verdict.get().decision().label()); + 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 { @@ -256,13 +273,13 @@ private void render() { } 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)"); } /** 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..eb3d7277 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -4,6 +4,8 @@ import app.drydock.git.ReviewBase; import app.drydock.git.UnifiedDiff; import app.drydock.mcp.McpActivityLog; +import app.drydock.review.BaseMove; +import app.drydock.review.IntentHunks; import app.drydock.review.ReviewAnnotation; import app.drydock.review.ReviewIntent; import app.drydock.review.ReviewScope; @@ -11,6 +13,7 @@ import app.drydock.review.SessionReviewScopes; import app.drydock.review.Severity; import app.drydock.review.SubmitPlan; +import app.drydock.review.VerdictMerge; import javafx.application.Platform; import javafx.geometry.Pos; @@ -29,9 +32,13 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; import java.util.function.Consumer; /** @@ -91,13 +98,57 @@ public interface Host { */ List intents(ReviewScope scope, UnifiedDiff diff); - /** The verdict recorded on one intent, if any. */ - Optional verdict(ReviewScope scope, ReviewIntent intent); + /** + * 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 a verdict; {@code decision} empty undoes it. */ - void setVerdict(ReviewScope scope, ReviewIntent intent, + /** + * 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 intent} still comes along because the host + * refuses an approval over an open blocking finding, and that refusal + * is stated in terms of the intent (spec §4.6).

+ */ + void setVerdict(ReviewScope scope, ReviewIntent intent, List hunkDigests, Optional decision); + /** + * 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); + /** Resolve / Reopen one finding. */ void setResolved(ReviewScope scope, ReviewAnnotation finding, boolean resolved); @@ -162,6 +213,48 @@ 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"; + + /** + * One section's rendered state, derived from its hunks (spec §9.1). + * + * @param decision what its hunks merge to, empty while any is unread + * @param settledHunks how many of its hunks carry a verdict + * @param totalHunks how many hunks it covers at all + * @param stale whether a base move since a verdict could have changed + * what was approved + * @param settledElsewhere the marks of the other sections that settled + * hunks this one shares, so settling one section does not + * silently change another + */ + record SectionState(Optional decision, int settledHunks, + int totalHunks, boolean stale, List settledElsewhere) { + + 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, false, List.of()); + } + } + private final Host host; private final ReviewScopeSwitcher switcher = new ReviewScopeSwitcher(); private final ReviewDiffColumn diffColumn; @@ -188,6 +281,21 @@ void setVerdict(ReviewScope scope, ReviewIntent intent, */ private final Map outcomeByScope = new HashMap<>(); + /** + * 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<>(); + /** The scopes this session offers, once {@link SessionReviewScopes} has measured them. */ private Optional scopes = Optional.empty(); @@ -291,8 +399,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); @@ -628,7 +735,8 @@ public void refreshReviewState() { 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). @@ -762,11 +870,148 @@ private Optional currentIntent() { return Optional.of(intents.get(Math.clamp(intentIndex, 0, intents.size() - 1))); } + /** + * 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(); + } + + /** + * The content digests of the hunks {@code intent} covers, memoized for + * the diff they were taken from (see {@link #digestsByIntent}). + */ + private List digestsOf(ReviewIntent intent) { + UnifiedDiff diff = loadedDiff().orElse(null); + if (diff == null) { + return List.of(); + } + if (diff != digestedDiff) { + digestedDiff = diff; + digestsByIntent.clear(); + } + return digestsByIntent.computeIfAbsent(intent, + key -> IntentHunks.digestsOf(key, diff)); + } + + /** + * 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 List filesOf(ReviewIntent intent) { + List named = intent.files(); + if (!named.isEmpty()) { + return named; + } + return loadedDiff().map(diff -> diff.files().stream() + .map(UnifiedDiff.FileDiff::path).toList()).orElse(List.of()); + } + + /** The commit the selected scope's base ref resolves to; see {@link #UNRESOLVED_BASE}. */ + private String currentBase() { + return selectedScope().map(host::currentBase).orElse(UNRESOLVED_BASE); + } + + /** What moved between {@code recordedBase} and {@link #currentBase()}. */ + private BaseMove.Delta baseDelta(String recordedBase) { + return selectedScope() + .map(scope -> host.baseMove(scope, recordedBase)) + // No scope means nothing to compare; unresolvable rather than + // an empty delta, so an absent answer is never read as "clean". + .orElseGet(() -> new BaseMove.Delta(true, new TreeSet<>())); + } + + /** + * What a section's hunks merge to (spec §9.1) -- {@link VerdictMerge}'s + * rule, over the verdicts of the hunks it covers. + * + *

Deliberately free of {@link #settlingSectionOf}: that asks whether + * ANOTHER section is settled, and answering it through the full {@link + * #sectionState} would recurse between two sections that share a hunk.

+ */ + private Optional decisionOf(ReviewIntent intent) { + Optional scope = selectedScope(); + if (scope.isEmpty()) { + return Optional.empty(); + } + return VerdictMerge.derive(digestsOf(intent).stream() + .map(digest -> host.verdict(scope.get(), digest)) + .toList()); + } + + /** + * The mark of the first OTHER settled section covering {@code digest}, so + * a card can say "reviewed in (1)" instead of appearing to settle itself. + * + *

{@code sections} is passed in rather than read from {@link + * #intents()}: that regroups the whole diff on every call, and this runs + * once per settled hunk of every card the rail draws.

+ */ + private Optional settlingSectionOf(String digest, ReviewIntent self, + List sections) { + for (ReviewIntent other : sections) { + if (other.id().equals(self.id()) || !other.countsTowardProgress()) { + continue; + } + if (digestsOf(other).contains(digest) && decisionOf(other).isPresent()) { + return Optional.of(sectionMark(other.number())); + } + } + return Optional.empty(); + } + + /** How a section is named in another section's card: its number, circled. */ + private 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; + } + + /** One section's rendered state, derived from its hunks (spec §9.1). */ + private SectionState sectionState(ReviewIntent intent) { + Optional scope = selectedScope(); + List digests = digestsOf(intent); + if (scope.isEmpty() || digests.isEmpty()) { + return SectionState.unknown(); + } + String base = currentBase(); + List sections = intents(); + List files = filesOf(intent); + List> perHunk = new ArrayList<>(); + Set elsewhere = new LinkedHashSet<>(); + boolean stale = false; + int settled = 0; + for (String digest : digests) { + Optional verdict = host.verdict(scope.get(), digest); + perHunk.add(verdict); + if (verdict.isPresent()) { + settled++; + if (verdict.get().staleAgainst(base) + && BaseMove.couldMatter(baseDelta(verdict.get().baseCommit()), files)) { + stale = true; + } + settlingSectionOf(digest, intent, sections).ifPresent(elsewhere::add); + } + } + return new SectionState(VerdictMerge.derive(perHunk), settled, digests.size(), + stale, List.copyOf(elsewhere)); + } + private void renderVerdictBar(ReviewScope scope) { List intents = intents(); Optional current = currentIntent(); if (current.isEmpty()) { - verdictBar.update(null, Optional.empty(), false, 0, 0); + verdictBar.update(null, Optional.empty(), false); + verdictBar.showProgress(0, 0); return; } // Collapsed intents do not count toward progress: the point of the @@ -774,14 +1019,24 @@ private void renderVerdictBar(ReviewScope scope) { List counted = intents.stream() .filter(ReviewIntent::countsTowardProgress) .toList(); - long settled = counted.stream() - .filter(intent -> host.verdict(scope, intent).isPresent()) - .count(); + // The UNION of the counted sections' hunks, counted once. 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). + Set distinct = new LinkedHashSet<>(); + for (ReviewIntent intent : counted) { + distinct.addAll(digestsOf(intent)); + } + int settled = 0; + for (String digest : distinct) { + if (host.verdict(scope, digest).isPresent()) { + settled++; + } + } 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()); + verdictBar.update(current.get(), sectionState(current.get()).decision(), blocked); + verdictBar.showProgress(settled, distinct.size()); } /** @@ -826,7 +1081,7 @@ private void nextUnsettledIntent() { 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 (intent.countsTowardProgress() && decisionOf(intent).isEmpty()) { intentIndex = candidate; refreshReviewState(); revealCurrentIntent(); @@ -910,14 +1165,14 @@ public void setPostToPr(ReviewAnnotation finding, boolean post) { 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))); + selectedScope().ifPresent(scope -> host.setVerdict(scope, intent, + digestsOf(intent), Optional.of(ReviewVerdict.Decision.APPROVED))); } @Override public void requestChanges(ReviewIntent intent) { - selectedScope().ifPresent(scope -> - host.setVerdict(scope, intent, Optional.of(ReviewVerdict.Decision.CHANGES))); + selectedScope().ifPresent(scope -> host.setVerdict(scope, intent, + digestsOf(intent), Optional.of(ReviewVerdict.Decision.CHANGES))); } @Override @@ -931,7 +1186,8 @@ public void askAgentToFix(ReviewIntent intent) { @Override public void undo(ReviewIntent intent) { - selectedScope().ifPresent(scope -> host.setVerdict(scope, intent, Optional.empty())); + selectedScope().ifPresent(scope -> + host.setVerdict(scope, intent, digestsOf(intent), Optional.empty())); } @Override @@ -1004,8 +1260,8 @@ private void submitReview() { .toList(); 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(); @@ -1013,7 +1269,7 @@ private void submitReview() { "an intent still needs a verdict (approve or request changes); jumped to it"); return; } - decisions.add(verdict.get().decision()); + decisions.add(decision.get()); } host.submit(scope.get(), buildDiffIndex(diffColumn.displayedDiff()), decisions); } @@ -1172,9 +1428,9 @@ private void verdictAction(ReviewVerdict.Decision decision) { 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()) { + host.setVerdict(scope.get(), intent.get(), digestsOf(intent.get()), + Optional.of(decision)); + if (decisionOf(intent.get()).filter(decision::equals).isPresent()) { lastSettledIntentId = Optional.of(intent.get().id()); nextUnsettledIntent(); } @@ -1214,7 +1470,8 @@ private void undoVerdict() { // sane to undo or jump to. return; } - host.setVerdict(scope.get(), current.get(index), Optional.empty()); + host.setVerdict(scope.get(), current.get(index), digestsOf(current.get(index)), + Optional.empty()); intentIndex = index; refreshReviewState(); revealCurrentIntent(); @@ -1452,6 +1709,21 @@ 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. + */ + SectionState diagSectionState(int index) { + return ReviewDiagFxThread.call(() -> { + List current = intents(); + return index >= 0 && index < current.size() + ? sectionState(current.get(index)) + : SectionState.unknown(); + }); + } + /** * 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..d3b84e80 100644 --- a/app/src/main/resources/app/drydock/ui/app.css +++ b/app/src/main/resources/app/drydock/ui/app.css @@ -3181,6 +3181,9 @@ /* 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; } .review-intent-number { -fx-text-fill: -drydock-text-faint; @@ -3260,6 +3263,21 @@ .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; +} + /* Collapsed: a status dot, never a clipped label. */ .review-intent-dot { -fx-min-width: 5px; -fx-max-width: 5px; 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/McpToolRouterReviewTest.java b/app/src/test/java/app/drydock/mcp/McpToolRouterReviewTest.java index b51683d7..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,8 @@ void answeringAnUnknownFindingIsRejected() { @Test void reviewStateReportsVerdictsFindingsAndSubmission() throws Exception { context.annotations.add(finding("f1", Severity.BLOCKING)); - router.call(caller, "review_intents", intentsArgs(intentJson("i1", "Change", "MED"))); - context.verdicts.add(new ReviewVerdict(SCOPE, "i1", ReviewVerdict.Decision.CHANGES, + 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); @@ -381,17 +382,16 @@ void reviewStateReportsVerdictsFindingsAndSubmission() throws Exception { /** * Pins the id-space of {@code review_state}'s intents: the wire {@code - * id} is the intent's own id, looked up by (today, placeholder) digest -- - * never whatever key a verdict happens to be stored under. A verdict - * stored under a key that names no registered intent -- exactly what a - * real hunk digest looks like once Task 6 wires one -- must never - * surface as an "intent" id; the old code (reporting {@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"))); - context.verdicts.add(new ReviewVerdict(SCOPE, "i1", ReviewVerdict.Decision.APPROVED, + 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")); @@ -473,6 +473,27 @@ 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(); diff --git a/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java b/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java index b8298eb2..0ba29dc5 100644 --- a/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java +++ b/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java @@ -4,6 +4,7 @@ import app.drydock.domain.ManagedSessionId; import app.drydock.git.DiffService; import app.drydock.git.UnifiedDiff; +import app.drydock.review.BaseMove; import app.drydock.review.ReviewAnnotation; import app.drydock.review.ReviewIntent; import app.drydock.review.ReviewScope; @@ -33,6 +34,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; @@ -490,12 +492,23 @@ public List intents(ReviewScope scope, UnifiedDiff diff) { } @Override - public Optional verdict(ReviewScope scope, ReviewIntent intent) { + 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) { + } + + @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 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 b0a5bc2c..b60d9bce 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,7 @@ import app.drydock.git.UnifiedDiff; import app.drydock.review.AnnotationStatus; import app.drydock.review.AnnotationStore; +import app.drydock.review.BaseMove; import app.drydock.review.IntentGrouping; import app.drydock.review.ReviewAnnotation; import app.drydock.review.ReviewIntent; @@ -16,6 +17,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} @@ -43,6 +45,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; @@ -100,25 +118,36 @@ public List intents(ReviewScope scope, UnifiedDiff diff) { } @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, + public void setVerdict(ReviewScope scope, ReviewIntent intent, List hunkDigests, Optional decision) { 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)) { return; } - // TODO(task-6): intent.id() stands in for HunkDigest.of(...). - // TODO(task-6): scope.base()/head() are ref names, not commit shas; - // staleness is inert until these resolve. - store.putVerdict(new ReviewVerdict(scope.id(), intent.id(), decision.get(), - Optional.empty(), Instant.now(), scope.base(), scope.head())); + for (String digest : hunkDigests) { + store.putVerdict(new ReviewVerdict(scope.id(), digest, decision.get(), + Optional.empty(), Instant.now(), baseCommit, headCommit)); + } + } + + @Override + public String currentBase(ReviewScope scope) { + return baseCommit; + } + + @Override + public BaseMove.Delta baseMove(ReviewScope scope, String recordedBase) { + return baseDelta; } private boolean blocked(ReviewScope scope, ReviewIntent intent) { 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..a23811b9 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewFindingsAndVerdictsTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewFindingsAndVerdictsTest.java @@ -4,6 +4,7 @@ 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; @@ -268,7 +269,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 +279,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 +289,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 +307,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 +318,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 @@ -622,6 +623,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..cc6d6245 --- /dev/null +++ b/app/src/test/java/app/drydock/ui/review/ReviewHunkProgressTest.java @@ -0,0 +1,343 @@ +package app.drydock.ui.review; + +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.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; + +/** + * 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, marked with where. + * + *

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()); + stage.setScene(scene); + stage.show(); + } + + @AfterEach + void tearDown() { + diffService.close(); + host.store.close(); + } + + // ---- progress counts hunks, not section slots --------------------------- + + /** + * 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()); + } + + // ---- a section's decision is derived from its hunks --------------------- + + @Test + void anUnsettledHunkLeavesItsSectionUnsettled() { + showOverlappingSections(); + + approve(GUARDS_H); + + assertEquals(Optional.empty(), view.diagSectionState(0).decision(), + "guards.cpp is still unread, so the section cannot be approved"); + assertEquals(1, view.diagSectionState(0).settledHunks()); + assertEquals(2, view.diagSectionState(0).totalHunks()); + } + + @Test + void aSectionWithEveryHunkSettledIsApproved() { + showOverlappingSections(); + + approve(GUARDS_H); + approve(GUARDS_CPP); + + assertEquals(Optional.of(ReviewVerdict.Decision.APPROVED), + view.diagSectionState(0).decision()); + } + + /** Any changes request wins over the rest of the section (VerdictMerge). */ + @Test + void oneChangeRequestMakesTheWholeSectionChanges() { + showOverlappingSections(); + + record(GUARDS_CPP, ReviewVerdict.Decision.CHANGES); + + assertEquals(Optional.of(ReviewVerdict.Decision.CHANGES), + view.diagSectionState(0).decision()); + } + + // ---- 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 it was settled, ②'s state changes with no visible cause. + */ + @Test + void aHunkSettledElsewhereSaysWhereItWasSettled() { + showOverlappingSections(); + + approve(GUARDS_H); + approve(GUARDS_CPP); + + assertEquals(List.of("①"), view.diagSectionState(1).settledElsewhere()); + assertTrue(railText().contains("✓ reviewed in ①"), + "the rail must name the section that settled it, got: " + railText()); + } + + // ---- carry-forward (a): 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"); + } + + // ---- carry-forward (b): staleness is measured against a commit ---------- + + /** + * A verdict given against an older base, where the move touched a file + * the section covers, reads as stale. + */ + @Test + void aBaseMoveTouchingTheSectionMarksItStale() { + 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)); + + assertTrue(view.diagSectionState(0).stale(), + "the base moved under a file this section covers"); + } + + /** A base move that provably could not matter must not spend the reader's attention. */ + @Test + void aBaseMoveElsewhereLeavesTheSectionFresh() { + host.baseDelta = new BaseMove.Delta(false, + new TreeSet<>(List.of("docs/README.md"))); + showOverlappingSections(); + recordAgainstBase(GUARDS_H, "0".repeat(40)); + recordAgainstBase(GUARDS_CPP, "0".repeat(40)); + + assertFalse(view.diagSectionState(0).stale(), + "nothing this section covers moved"); + } + + /** A verdict recorded against the current base is never stale. */ + @Test + void aFreshVerdictIsNotStale() { + showOverlappingSections(); + + approve(GUARDS_H); + + assertFalse(view.diagSectionState(0).stale()); + } + + // ---- helpers ------------------------------------------------------------ + + /** + * Section ① covers guards.h and guards.cpp; section ② covers guards.h + * again and profiler.cpp. Three hunks, four slots. + */ + private void showOverlappingSections() { + 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( + section("section-1", "Guards", GUARDS_H, GUARDS_CPP), + section("section-2", "Profiler", GUARDS_H, PROFILER))); + 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) { + record(file, ReviewVerdict.Decision.APPROVED); + } + + private void record(String file, ReviewVerdict.Decision decision) { + put(file, decision, 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() { + List labels = new ArrayList<>(); + interact(() -> labels.addAll(lookup(".review-verdict-progress-label").queryAll())); + return labels.stream().map(node -> ((Label) node).getText()) + .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(); + } + + private String railText() { + List labels = new ArrayList<>(); + interact(() -> labels.addAll(lookup(".review-intent-settled-elsewhere").queryAll())); + return labels.stream().map(node -> ((Label) node).getText()) + .reduce("", (a, b) -> a + " " + b); + } + + 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/ReviewVerdictBarFitTest.java b/app/src/test/java/app/drydock/ui/review/ReviewVerdictBarFitTest.java index 046acdeb..c1d82cf1 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewVerdictBarFitTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewVerdictBarFitTest.java @@ -85,8 +85,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, "base-1", "head-1"))); + Optional.of(ReviewVerdict.Decision.APPROVED)); assertNothingTruncated(); } @@ -131,8 +130,11 @@ private boolean hintShowing() { } - 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(); 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..c219c248 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewVerdictBarNavigationTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewVerdictBarNavigationTest.java @@ -48,7 +48,7 @@ public void start(Stage stage) { @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 +56,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 +66,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()); From 1e0fdfac94f744645a58963b4f89d461fcdc9f20 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 05:17:41 +0200 Subject: [PATCH 020/113] Unknown staleness is silent, and a drifted section cannot deadlock Submit Four things a settled card could say that were not true. "We cannot tell yet" was rendered as "the base moved". Before a scope's baseline lands, currentBase is the literal "unresolved", which is not a revision -- reviewBaseMove handed it to git diff anyway, spawned a command that always fails, logged a warning describing no real problem, and got back an unresolvable delta. couldMatter answers true for unresolvable, so every settled card of a review nobody had touched showed a confirm-me banner until the answer arrived. The spawn is now short-circuited on either side of the comparison being unresolved, and SectionState carries a three-state Staleness rather than a boolean: FRESH, MOVED, and UNKNOWN, which renders as nothing. couldMatter's true-on-unresolvable is right for a DECISION and wrong as evidence of a move, and the card is the place that distinction has to land. A banner clicked reflexively is worth less than no banner. A section whose hunk ids named nothing in the diff was silently unapprovable. Ids are positional (h__), so a re-diff strands a grouping an agent supplied earlier, and digestsOf then returns nothing: the card looked untouched, pressing a wrote no verdict so the cursor never advanced, n kept landing on it, and Submit refused forever while jumping to the one card that could not be settled. This worked under the old intent-id keying, so it was a regression, not a pre-existing gap. Such a section is now excluded from the counted set that progress and Submit are measured over, skipped by n, and says "hunks are no longer in this diff" on its card. Considered leaving it counted and letting the human dismiss it; there is no gesture that would mean anything -- there is nothing there to settle. refreshBaseline's failure branch left the cache empty, so the next baselineOf -- once per card, per rail rebuild, on every store change -- spawned git again. The identical hazard was already guarded fourteen lines later in reviewBaseMove with the reason written down; the guard was simply missing here. The failure now records UNRESOLVED_BASELINE, and bodyFor still re-reads the scope's baseline whenever the board renders it, so a transient failure is not permanent. settledElsewhere fired only when the sibling section was FULLY settled. A sibling that settles one shared hunk moves this card's count by exactly as much, so marking only the full case solved the easy half of "state changing on its own" and left the other half as mysterious as before. It is populated per shared settled hunk now, and rendered only while this section is itself unsettled -- on a settled card its own verdict already explains the state. --- .../java/app/drydock/ui/MainWorkspace.java | 22 ++- .../drydock/ui/review/ReviewIntentRail.java | 33 +++- .../drydock/ui/review/SessionReviewView.java | 154 ++++++++++++++---- app/src/main/resources/app/drydock/ui/app.css | 10 ++ .../ui/review/ReviewHunkProgressTest.java | 138 +++++++++++++++- 5 files changed, 312 insertions(+), 45 deletions(-) diff --git a/app/src/main/java/app/drydock/ui/MainWorkspace.java b/app/src/main/java/app/drydock/ui/MainWorkspace.java index 9b3c8e81..cc33c203 100644 --- a/app/src/main/java/app/drydock/ui/MainWorkspace.java +++ b/app/src/main/java/app/drydock/ui/MainWorkspace.java @@ -127,6 +127,7 @@ 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; @@ -184,7 +185,7 @@ 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); /** @@ -194,7 +195,7 @@ public final class MainWorkspace extends BorderPane implements WorkspaceNavigato * it; separate from {@link #HANDOFF_EXECUTOR} only so a stack trace says * which of the two is stuck. */ - private static final java.util.concurrent.Executor REVIEW_GIT_EXECUTOR = + 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. */ @@ -2337,8 +2338,14 @@ private void refreshBaseline(ReviewScope scope) { .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 read as stale", failure); + + scope.id() + "; its verdicts cannot be dated", failure); + baselineByScope.put(scope.id(), UNRESOLVED_BASELINE); + refreshReviewBoards(); return; } baselineByScope.put(scope.id(), resolved); @@ -2367,6 +2374,15 @@ private String resolveRef(Path root, String ref) { */ 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. 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 8396c4a3..a6af4d25 100644 --- a/app/src/main/java/app/drydock/ui/review/ReviewIntentRail.java +++ b/app/src/main/java/app/drydock/ui/review/ReviewIntentRail.java @@ -26,6 +26,7 @@ import java.util.Map; import java.util.Optional; import java.util.function.Consumer; +import java.util.function.Function; /** * The intent rail (spec §4.2): one card per intent, with its number, title, @@ -65,7 +66,7 @@ final class ReviewIntentRail extends VBox { * of its own now -- overlapping sections cannot own one -- so the rail is * handed the derived state rather than a stored {@link ReviewVerdict}. */ - private java.util.function.Function stateLookup = + private Function stateLookup = intent -> SessionReviewView.SectionState.unknown(); private Consumer onSelected = intent -> { }; private Runnable onToggleCollapse = () -> { }; @@ -102,8 +103,7 @@ void setOnToggleCollapse(Runnable handler) { this.onToggleCollapse = handler == null ? () -> { } : handler; } - void setSectionStateLookup( - java.util.function.Function lookup) { + void setSectionStateLookup(Function lookup) { this.stateLookup = lookup == null ? intent -> SessionReviewView.SectionState.unknown() : lookup; @@ -280,12 +280,16 @@ private Button buildCard(ReviewIntent intent) { SessionReviewView.SectionState state = stateLookup.apply(intent); Optional decision = state.decision(); boolean settled = decision.isPresent() || intent.autoApprove(); + boolean moved = state.staleness() == SessionReviewView.Staleness.MOVED; if (settled) { card.getStyleClass().add("settled"); } - if (state.stale()) { + 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()); @@ -350,6 +354,14 @@ protected double computePrefHeight(double width) { .orElse(ReviewVerdict.Decision.AUTO_APPROVED.label())); 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.settledHunks() > 0) { // Part-settled reads as untouched otherwise: the card looks // exactly like one nobody has opened, and the reader re-reads @@ -358,9 +370,12 @@ protected double computePrefHeight(double width) { progress.getStyleClass().add("review-intent-hunk-progress"); content.getChildren().add(progress); } - if (!state.settledElsewhere().isEmpty()) { - // Settling one section settles hunks another shares. Naming where - // it happened is what keeps that from reading as state changing + // 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())); @@ -368,7 +383,9 @@ protected double computePrefHeight(double width) { elsewhere.setWrapText(true); content.getChildren().add(elsewhere); } - if (state.stale()) { + // 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) { Label stale = new Label("⚠ base moved — confirm"); stale.getStyleClass().add("review-intent-stale"); stale.setWrapText(true); 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 eb3d7277..3f1c6fa8 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -225,20 +225,47 @@ void setVerdict(ReviewScope scope, ReviewIntent intent, List hunkDigests */ public static final String UNRESOLVED_BASE = "unresolved"; + /** + * 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 * @param settledHunks how many of its hunks carry a verdict * @param totalHunks how many hunks it covers at all - * @param stale whether a base move since a verdict could have changed + * @param staleness whether a base move since a verdict could have changed * what was approved - * @param settledElsewhere the marks of the other sections that settled - * hunks this one shares, so settling one section does not - * silently change another + * @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 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 totalHunks, boolean stale, List settledElsewhere) { + int totalHunks, Staleness staleness, List settledElsewhere, + boolean hunksMissing) { SectionState { settledElsewhere = List.copyOf(settledElsewhere); @@ -251,7 +278,18 @@ record SectionState(Optional decision, int settledHunks, * not the same claim. */ static SectionState unknown() { - return new SectionState(Optional.empty(), 0, 0, false, List.of()); + return new SectionState(Optional.empty(), 0, 0, Staleness.UNKNOWN, List.of(), false); + } + + /** + * 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, Staleness.FRESH, List.of(), true); } } @@ -932,9 +970,10 @@ private BaseMove.Delta baseDelta(String recordedBase) { * What a section's hunks merge to (spec §9.1) -- {@link VerdictMerge}'s * rule, over the verdicts of the hunks it covers. * - *

Deliberately free of {@link #settlingSectionOf}: that asks whether - * ANOTHER section is settled, and answering it through the full {@link - * #sectionState} would recurse between two sections that share a hunk.

+ *

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

*/ private Optional decisionOf(ReviewIntent intent) { Optional scope = selectedScope(); @@ -947,24 +986,42 @@ private Optional decisionOf(ReviewIntent intent) { } /** - * The mark of the first OTHER settled section covering {@code digest}, so - * a card can say "reviewed in (1)" instead of appearing to settle itself. + * 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.

+ */ + private boolean hasResolvableHunks(ReviewIntent intent) { + return !digestsOf(intent).isEmpty(); + } + + /** + * The marks of the OTHER sections sharing {@code digest}, 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.

* *

{@code sections} is passed in rather than read from {@link * #intents()}: that regroups the whole diff on every call, and this runs * once per settled hunk of every card the rail draws.

*/ - private Optional settlingSectionOf(String digest, ReviewIntent self, - List sections) { + private void collectSharingSections(String digest, ReviewIntent self, + List sections, Set into) { for (ReviewIntent other : sections) { if (other.id().equals(self.id()) || !other.countsTowardProgress()) { continue; } - if (digestsOf(other).contains(digest) && decisionOf(other).isPresent()) { - return Optional.of(sectionMark(other.number())); + if (digestsOf(other).contains(digest)) { + into.add(sectionMark(other.number())); } } - return Optional.empty(); } /** How a section is named in another section's card: its number, circled. */ @@ -976,34 +1033,70 @@ private static String sectionMark(int number) { : "#" + number; } + /** + * 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. + */ + private Staleness stalenessOf(ReviewVerdict verdict, String base, List files) { + if (!verdict.staleAgainst(base)) { + return Staleness.FRESH; + } + BaseMove.Delta delta = baseDelta(verdict.baseCommit()); + if (delta.unresolvable()) { + return Staleness.UNKNOWN; + } + return BaseMove.couldMatter(delta, files) ? Staleness.MOVED : Staleness.FRESH; + } + /** One section's rendered state, derived from its hunks (spec §9.1). */ private SectionState sectionState(ReviewIntent intent) { Optional scope = selectedScope(); List digests = digestsOf(intent); if (scope.isEmpty() || digests.isEmpty()) { - return SectionState.unknown(); + // 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() && loadedDiff().isPresent() + ? SectionState.notInDiff() + : SectionState.unknown(); } String base = currentBase(); List sections = intents(); List files = filesOf(intent); List> perHunk = new ArrayList<>(); Set elsewhere = new LinkedHashSet<>(); - boolean stale = false; + Staleness staleness = Staleness.FRESH; int settled = 0; for (String digest : digests) { Optional verdict = host.verdict(scope.get(), digest); perHunk.add(verdict); if (verdict.isPresent()) { settled++; - if (verdict.get().staleAgainst(base) - && BaseMove.couldMatter(baseDelta(verdict.get().baseCommit()), files)) { - stale = true; + // MOVED outranks UNKNOWN outranks FRESH: one hunk known to + // have moved is the strongest thing true of the section. + Staleness hunk = stalenessOf(verdict.get(), base, files); + if (hunk == Staleness.MOVED + || (hunk == Staleness.UNKNOWN && staleness == Staleness.FRESH)) { + staleness = hunk; } - settlingSectionOf(digest, intent, sections).ifPresent(elsewhere::add); + collectSharingSections(digest, intent, sections, elsewhere); } } return new SectionState(VerdictMerge.derive(perHunk), settled, digests.size(), - stale, List.copyOf(elsewhere)); + staleness, List.copyOf(elsewhere), false); + } + + /** + * 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. + */ + private List countedSections(List all) { + return all.stream() + .filter(ReviewIntent::countsTowardProgress) + .filter(this::hasResolvableHunks) + .toList(); } private void renderVerdictBar(ReviewScope scope) { @@ -1015,10 +1108,10 @@ private void renderVerdictBar(ReviewScope scope) { 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(); + // 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 = countedSections(intents); // The UNION of the counted sections' hunks, counted once. 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). @@ -1081,7 +1174,8 @@ private void nextUnsettledIntent() { for (int offset = 1; offset <= intents.size(); offset++) { int candidate = (intentIndex + offset) % intents.size(); ReviewIntent intent = intents.get(candidate); - if (intent.countsTowardProgress() && decisionOf(intent).isEmpty()) { + if (intent.countsTowardProgress() && hasResolvableHunks(intent) + && decisionOf(intent).isEmpty()) { intentIndex = candidate; refreshReviewState(); revealCurrentIntent(); @@ -1255,9 +1349,7 @@ private void submitReview() { return; } } - List counted = intents().stream() - .filter(ReviewIntent::countsTowardProgress) - .toList(); + List counted = countedSections(intents()); List decisions = new ArrayList<>(); for (int i = 0; i < counted.size(); i++) { Optional decision = decisionOf(counted.get(i)); diff --git a/app/src/main/resources/app/drydock/ui/app.css b/app/src/main/resources/app/drydock/ui/app.css index d3b84e80..f8701f59 100644 --- a/app/src/main/resources/app/drydock/ui/app.css +++ b/app/src/main/resources/app/drydock/ui/app.css @@ -3184,6 +3184,11 @@ /* 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; @@ -3277,6 +3282,11 @@ -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 { diff --git a/app/src/test/java/app/drydock/ui/review/ReviewHunkProgressTest.java b/app/src/test/java/app/drydock/ui/review/ReviewHunkProgressTest.java index cc6d6245..36041ae7 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewHunkProgressTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewHunkProgressTest.java @@ -189,6 +189,86 @@ void aHunkSettledElsewhereSaysWhereItWasSettled() { "the rail must name the section that settled it, got: " + railText()); } + /** + * A sibling that settled ONE shared hunk moves this card's count by + * exactly as much as a fully settled sibling 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() { + showOverlappingSections(); + + approve(GUARDS_H); + + assertEquals(List.of("②"), view.diagSectionState(0).settledElsewhere(), + "section ① shares its settled hunk with ②, which is not itself settled"); + assertEquals(List.of("①"), view.diagSectionState(1).settledElsewhere()); + assertTrue(railText().contains("✓ reviewed in ①"), 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()); + } + + // ---- 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 aSectionWhoseHunksLeftTheDiffIsNotCountedAndSaysSo() { + 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(view.diagSectionState(1).hunksMissing() + && intentLabel().startsWith("2 "), + "n must not land on the adrift section, got: " + intentLabel()); + } + // ---- carry-forward (a): verdicts are keyed by a real digest ------------- @Test @@ -232,8 +312,9 @@ void aBaseMoveTouchingTheSectionMarksItStale() { recordAgainstBase(GUARDS_H, "0".repeat(40)); recordAgainstBase(GUARDS_CPP, "0".repeat(40)); - assertTrue(view.diagSectionState(0).stale(), + assertEquals(SessionReviewView.Staleness.MOVED, view.diagSectionState(0).staleness(), "the base moved under a file this section covers"); + assertTrue(railLabels(".review-intent-stale").contains("⚠ base moved — confirm")); } /** A base move that provably could not matter must not spend the reader's attention. */ @@ -245,7 +326,7 @@ void aBaseMoveElsewhereLeavesTheSectionFresh() { recordAgainstBase(GUARDS_H, "0".repeat(40)); recordAgainstBase(GUARDS_CPP, "0".repeat(40)); - assertFalse(view.diagSectionState(0).stale(), + assertEquals(SessionReviewView.Staleness.FRESH, view.diagSectionState(0).staleness(), "nothing this section covers moved"); } @@ -256,7 +337,26 @@ void aFreshVerdictIsNotStale() { approve(GUARDS_H); - assertFalse(view.diagSectionState(0).stale()); + assertEquals(SessionReviewView.Staleness.FRESH, view.diagSectionState(0).staleness()); + } + + /** + * 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(SessionReviewView.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"); } // ---- helpers ------------------------------------------------------------ @@ -278,6 +378,25 @@ private void showOverlappingSections() { WaitForAsyncUtils.waitForFxEvents(); } + /** + * 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() { + 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())); + 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)); + 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) { @@ -327,6 +446,19 @@ private long settledCardCount() { 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) { + List labels = new ArrayList<>(); + interact(() -> labels.addAll(lookup(selector).queryAll())); + return labels.stream().map(node -> ((Label) node).getText()).toList(); + } + + private String intentLabel() { + List labels = new ArrayList<>(); + interact(() -> labels.addAll(lookup(".review-verdict-intent").queryAll())); + return labels.stream().map(node -> ((Label) node).getText()).findFirst().orElse(""); + } + private String railText() { List labels = new ArrayList<>(); interact(() -> labels.addAll(lookup(".review-intent-settled-elsewhere").queryAll())); From 6b80e9c8aaa55fdf8d17e0d3440486041880a199 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 05:28:22 +0200 Subject: [PATCH 021/113] A section's derivation lives in SectionStates, outside the view Pure move. digestsOf, filesOf, currentBase, baseDelta, decisionOf, hasResolvableHunks, the sharing-section walk, sectionMark, the staleness reading and sectionState were ~130 lines of SessionReviewView whose only inputs are a Host, a UnifiedDiff and a list of ReviewIntent -- none of it scene graph. They sat in a class that was 1470 lines before this feature and 1742 after, and four more tasks land in the same file. SessionReviewView is 1587 now, and what remains of the derivation in it is four one-line delegations. The board context (scope, diff, sections) is a parameter rather than mutable state on the new class. A setContext seam would let a caller derive one section against the scope now selected and its neighbour against the one before it, which is precisely the class of bug that has no symptom until a chip switch. The view assembles the Board from the same three things the rail is built from, and answers "no board" for itself at each call site rather than letting a default hide the case. SectionState and Staleness move with it, so the rail names them SectionStates.SectionState. UNRESOLVED_BASE deliberately stays on SessionReviewView: MainWorkspace and its tests reference it, and moving a public constant is a separate change from moving package-private derivation. Behaviour is unchanged. Every early return the old code took is reachable in the new shape: no scope and no loaded diff both collapse to an absent Board, which the delegations answer exactly as before; the extra board.isEmpty() guard in renderVerdictBar cannot fire while an intent is current, since intents() is empty without a loaded diff. Eighteen assertions that never needed a rendered board move to a plain JUnit SectionStatesTest -- merge rules, counts, the three-way staleness, adrift groupings, the memo's re-issued-id case, the circled marks. What stays in ReviewHunkProgressTest is what genuinely needs a Stage: the progress label, the settled card class, the rail's labels, and the keyboard and Submit paths. diagSectionState still routes through ReviewDiagFxThread for the three tests that read derived state through the view. --- .../drydock/ui/review/ReviewIntentRail.java | 12 +- .../app/drydock/ui/review/SectionStates.java | 328 +++++++++++++++++ .../drydock/ui/review/SessionReviewView.java | 317 ++-------------- .../ui/review/ReviewHunkProgressTest.java | 261 +++++-------- .../drydock/ui/review/SectionStatesTest.java | 342 ++++++++++++++++++ 5 files changed, 800 insertions(+), 460 deletions(-) create mode 100644 app/src/main/java/app/drydock/ui/review/SectionStates.java create mode 100644 app/src/test/java/app/drydock/ui/review/SectionStatesTest.java 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 a6af4d25..0423b99a 100644 --- a/app/src/main/java/app/drydock/ui/review/ReviewIntentRail.java +++ b/app/src/main/java/app/drydock/ui/review/ReviewIntentRail.java @@ -66,8 +66,8 @@ final class ReviewIntentRail extends VBox { * 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 -> SessionReviewView.SectionState.unknown(); + private Function stateLookup = + intent -> SectionStates.SectionState.unknown(); private Consumer onSelected = intent -> { }; private Runnable onToggleCollapse = () -> { }; private String selectedId; @@ -103,9 +103,9 @@ void setOnToggleCollapse(Runnable handler) { this.onToggleCollapse = handler == null ? () -> { } : handler; } - void setSectionStateLookup(Function lookup) { + void setSectionStateLookup(Function lookup) { this.stateLookup = lookup == null - ? intent -> SessionReviewView.SectionState.unknown() + ? intent -> SectionStates.SectionState.unknown() : lookup; } @@ -277,10 +277,10 @@ private Button buildCard(ReviewIntent intent) { Label number = new Label(String.valueOf(intent.number())); number.getStyleClass().add("review-intent-number"); - SessionReviewView.SectionState state = stateLookup.apply(intent); + SectionStates.SectionState state = stateLookup.apply(intent); Optional decision = state.decision(); boolean settled = decision.isPresent() || intent.autoApprove(); - boolean moved = state.staleness() == SessionReviewView.Staleness.MOVED; + boolean moved = state.staleness() == SectionStates.Staleness.MOVED; if (settled) { card.getStyleClass().add("settled"); } 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..ada85170 --- /dev/null +++ b/app/src/main/java/app/drydock/ui/review/SectionStates.java @@ -0,0 +1,328 @@ +package app.drydock.ui.review; + +import app.drydock.git.UnifiedDiff; +import app.drydock.review.BaseMove; +import app.drydock.review.IntentHunks; +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.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; + +/** + * 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 blocks -- the two questions + * that need git ({@link SessionReviewView.Host#currentBase} and {@link + * SessionReviewView.Host#baseMove}) are answered from the host's own cache.

+ */ +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 + * @param settledHunks how many of its hunks carry a verdict + * @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 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 totalHunks, Staleness staleness, List settledElsewhere, + boolean hunksMissing) { + + 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, Staleness.UNKNOWN, List.of(), false); + } + + /** + * 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, Staleness.FRESH, List.of(), true); + } + } + + /** + * 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.

+ */ + record Board(ReviewScope scope, UnifiedDiff diff, List sections) { + Board { + Objects.requireNonNull(scope, "scope"); + Objects.requireNonNull(diff, "diff"); + sections = List.copyOf(sections); + } + } + + 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. */ + int settledHunkCount(Board board) { + int settled = 0; + for (String digest : distinctDigests(board)) { + if (host.verdict(board.scope(), digest).isPresent()) { + settled++; + } + } + return settled; + } + + /** + * 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()); + List files = filesOf(board, intent); + List> perHunk = new ArrayList<>(); + Set elsewhere = new LinkedHashSet<>(); + Staleness staleness = Staleness.FRESH; + int settled = 0; + for (String digest : digests) { + Optional verdict = host.verdict(board.scope(), digest); + perHunk.add(verdict); + if (verdict.isPresent()) { + settled++; + // 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; + } + collectSharingSections(board, digest, intent, elsewhere); + } + } + return new SectionState(VerdictMerge.derive(perHunk), settled, digests.size(), + staleness, List.copyOf(elsewhere), false); + } + + /** + * 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. + */ + private Staleness stalenessOf(Board board, ReviewVerdict verdict, String base, + List files) { + if (!verdict.staleAgainst(base)) { + return Staleness.FRESH; + } + 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 marks of the OTHER sections sharing {@code digest}, 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.

+ */ + 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())); + } + } + } + + /** + * 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(); + } + + /** 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 3f1c6fa8..b8010a3e 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -5,7 +5,6 @@ import app.drydock.git.UnifiedDiff; import app.drydock.mcp.McpActivityLog; import app.drydock.review.BaseMove; -import app.drydock.review.IntentHunks; import app.drydock.review.ReviewAnnotation; import app.drydock.review.ReviewIntent; import app.drydock.review.ReviewScope; @@ -13,7 +12,6 @@ import app.drydock.review.SessionReviewScopes; import app.drydock.review.Severity; import app.drydock.review.SubmitPlan; -import app.drydock.review.VerdictMerge; import javafx.application.Platform; import javafx.geometry.Pos; @@ -32,13 +30,9 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.Set; -import java.util.TreeSet; import java.util.function.Consumer; /** @@ -225,78 +219,12 @@ void setVerdict(ReviewScope scope, ReviewIntent intent, List hunkDigests */ public static final String UNRESOLVED_BASE = "unresolved"; - /** - * 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 - * @param settledHunks how many of its hunks carry a verdict - * @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 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 totalHunks, Staleness staleness, List settledElsewhere, - boolean hunksMissing) { - - 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, Staleness.UNKNOWN, List.of(), false); - } - - /** - * 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, Staleness.FRESH, List.of(), true); - } - } - 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; private final ReviewFindingsMargin margin; private final ReviewVerdictBar verdictBar; @@ -319,21 +247,6 @@ static SectionState notInDiff() { */ private final Map outcomeByScope = new HashMap<>(); - /** - * 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<>(); - /** The scopes this session offers, once {@link SessionReviewScopes} has measured them. */ private Optional scopes = Optional.empty(); @@ -414,6 +327,7 @@ static SectionState notInDiff() { 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()); @@ -920,216 +834,52 @@ private Optional loadedDiff() { } /** - * The content digests of the hunks {@code intent} covers, memoized for - * the diff they were taken from (see {@link #digestsByIntent}). - */ - private List digestsOf(ReviewIntent intent) { - UnifiedDiff diff = loadedDiff().orElse(null); - if (diff == null) { - return List.of(); - } - if (diff != digestedDiff) { - digestedDiff = diff; - digestsByIntent.clear(); - } - return digestsByIntent.computeIfAbsent(intent, - key -> IntentHunks.digestsOf(key, diff)); - } - - /** - * 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. + * 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 List filesOf(ReviewIntent intent) { - List named = intent.files(); - if (!named.isEmpty()) { - return named; - } - return loadedDiff().map(diff -> diff.files().stream() - .map(UnifiedDiff.FileDiff::path).toList()).orElse(List.of()); + private Optional board() { + return selectedScope().flatMap(scope -> loadedDiff() + .map(diff -> new SectionStates.Board(scope, diff, intents()))); } - /** The commit the selected scope's base ref resolves to; see {@link #UNRESOLVED_BASE}. */ - private String currentBase() { - return selectedScope().map(host::currentBase).orElse(UNRESOLVED_BASE); - } - - /** What moved between {@code recordedBase} and {@link #currentBase()}. */ - private BaseMove.Delta baseDelta(String recordedBase) { - return selectedScope() - .map(scope -> host.baseMove(scope, recordedBase)) - // No scope means nothing to compare; unresolvable rather than - // an empty delta, so an absent answer is never read as "clean". - .orElseGet(() -> new BaseMove.Delta(true, new TreeSet<>())); + /** 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()); } - /** - * 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 - * #sectionState} adds: it is what one section asks of ANOTHER, and asking - * through the full state would recurse between two sections sharing a - * hunk.

- */ + /** What {@code intent}'s hunks merge to; nothing without a diff to merge over. */ private Optional decisionOf(ReviewIntent intent) { - Optional scope = selectedScope(); - if (scope.isEmpty()) { - return Optional.empty(); - } - return VerdictMerge.derive(digestsOf(intent).stream() - .map(digest -> host.verdict(scope.get(), digest)) - .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.

- */ - private boolean hasResolvableHunks(ReviewIntent intent) { - return !digestsOf(intent).isEmpty(); - } - - /** - * The marks of the OTHER sections sharing {@code digest}, 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.

- * - *

{@code sections} is passed in rather than read from {@link - * #intents()}: that regroups the whole diff on every call, and this runs - * once per settled hunk of every card the rail draws.

- */ - private void collectSharingSections(String digest, ReviewIntent self, - List sections, Set into) { - for (ReviewIntent other : sections) { - if (other.id().equals(self.id()) || !other.countsTowardProgress()) { - continue; - } - if (digestsOf(other).contains(digest)) { - into.add(sectionMark(other.number())); - } - } + return board().flatMap(b -> sections.decisionOf(b, intent)); } - /** How a section is named in another section's card: its number, circled. */ - private 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; + /** 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); } - /** - * 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. - */ - private Staleness stalenessOf(ReviewVerdict verdict, String base, List files) { - if (!verdict.staleAgainst(base)) { - return Staleness.FRESH; - } - BaseMove.Delta delta = baseDelta(verdict.baseCommit()); - if (delta.unresolvable()) { - return Staleness.UNKNOWN; - } - return BaseMove.couldMatter(delta, files) ? Staleness.MOVED : Staleness.FRESH; - } - - /** One section's rendered state, derived from its hunks (spec §9.1). */ - private SectionState sectionState(ReviewIntent intent) { - Optional scope = selectedScope(); - List digests = digestsOf(intent); - if (scope.isEmpty() || 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() && loadedDiff().isPresent() - ? SectionState.notInDiff() - : SectionState.unknown(); - } - String base = currentBase(); - List sections = intents(); - List files = filesOf(intent); - List> perHunk = new ArrayList<>(); - Set elsewhere = new LinkedHashSet<>(); - Staleness staleness = Staleness.FRESH; - int settled = 0; - for (String digest : digests) { - Optional verdict = host.verdict(scope.get(), digest); - perHunk.add(verdict); - if (verdict.isPresent()) { - settled++; - // MOVED outranks UNKNOWN outranks FRESH: one hunk known to - // have moved is the strongest thing true of the section. - Staleness hunk = stalenessOf(verdict.get(), base, files); - if (hunk == Staleness.MOVED - || (hunk == Staleness.UNKNOWN && staleness == Staleness.FRESH)) { - staleness = hunk; - } - collectSharingSections(digest, intent, sections, elsewhere); - } - } - return new SectionState(VerdictMerge.derive(perHunk), settled, digests.size(), - staleness, List.copyOf(elsewhere), false); - } - - /** - * 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. - */ - private List countedSections(List all) { - return all.stream() - .filter(ReviewIntent::countsTowardProgress) - .filter(this::hasResolvableHunks) - .toList(); + /** The sections progress is measured over and Submit demands a verdict on. */ + private List countedSections() { + return board().map(sections::counted).orElse(List.of()); } private void renderVerdictBar(ReviewScope scope) { - List intents = intents(); Optional current = currentIntent(); - if (current.isEmpty()) { + Optional board = board(); + if (current.isEmpty() || board.isEmpty()) { verdictBar.update(null, Optional.empty(), false); verdictBar.showProgress(0, 0); return; } - // 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 = countedSections(intents); - // The UNION of the counted sections' hunks, counted once. 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). - Set distinct = new LinkedHashSet<>(); - for (ReviewIntent intent : counted) { - distinct.addAll(digestsOf(intent)); - } - int settled = 0; - for (String digest : distinct) { - if (host.verdict(scope, digest).isPresent()) { - settled++; - } - } boolean blocked = host.findings(scope).stream() .filter(this::belongsToCurrentIntent) .anyMatch(ReviewAnnotation::blocksApproval); verdictBar.update(current.get(), sectionState(current.get()).decision(), blocked); - verdictBar.showProgress(settled, distinct.size()); + // Progress is the UNION of the counted sections' hunks, counted once. + verdictBar.showProgress(sections.settledHunkCount(board.get()), + sections.distinctDigests(board.get()).size()); } /** @@ -1171,11 +921,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() && hasResolvableHunks(intent) - && decisionOf(intent).isEmpty()) { + if (countable.contains(intent) && decisionOf(intent).isEmpty()) { intentIndex = candidate; refreshReviewState(); revealCurrentIntent(); @@ -1349,7 +1102,7 @@ private void submitReview() { return; } } - List counted = countedSections(intents()); + List counted = countedSections(); List decisions = new ArrayList<>(); for (int i = 0; i < counted.size(); i++) { Optional decision = decisionOf(counted.get(i)); @@ -1807,12 +1560,12 @@ void diagShowDiff(ReviewScope forScope, UnifiedDiff diff) { * accessor -- it reads the store and the rail's own grouping, both of * which the FX thread mutates. */ - SectionState diagSectionState(int index) { + SectionStates.SectionState diagSectionState(int index) { return ReviewDiagFxThread.call(() -> { List current = intents(); return index >= 0 && index < current.size() ? sectionState(current.get(index)) - : SectionState.unknown(); + : SectionStates.SectionState.unknown(); }); } diff --git a/app/src/test/java/app/drydock/ui/review/ReviewHunkProgressTest.java b/app/src/test/java/app/drydock/ui/review/ReviewHunkProgressTest.java index 36041ae7..ed991ded 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewHunkProgressTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewHunkProgressTest.java @@ -35,10 +35,14 @@ 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, marked with where. + * 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 @@ -85,7 +89,7 @@ void tearDown() { host.store.close(); } - // ---- progress counts hunks, not section slots --------------------------- + // ---- the verdict bar counts distinct hunks ------------------------------ /** * Two sections that share {@code guards.h}: four section slots over three @@ -119,42 +123,6 @@ void everyHunkSettledReadsAsComplete() { assertEquals("3/3 hunks reviewed", progressText()); } - // ---- a section's decision is derived from its hunks --------------------- - - @Test - void anUnsettledHunkLeavesItsSectionUnsettled() { - showOverlappingSections(); - - approve(GUARDS_H); - - assertEquals(Optional.empty(), view.diagSectionState(0).decision(), - "guards.cpp is still unread, so the section cannot be approved"); - assertEquals(1, view.diagSectionState(0).settledHunks()); - assertEquals(2, view.diagSectionState(0).totalHunks()); - } - - @Test - void aSectionWithEveryHunkSettledIsApproved() { - showOverlappingSections(); - - approve(GUARDS_H); - approve(GUARDS_CPP); - - assertEquals(Optional.of(ReviewVerdict.Decision.APPROVED), - view.diagSectionState(0).decision()); - } - - /** Any changes request wins over the rest of the section (VerdictMerge). */ - @Test - void oneChangeRequestMakesTheWholeSectionChanges() { - showOverlappingSections(); - - record(GUARDS_CPP, ReviewVerdict.Decision.CHANGES); - - assertEquals(Optional.of(ReviewVerdict.Decision.CHANGES), - view.diagSectionState(0).decision()); - } - // ---- re-pinned: the rail's settled card --------------------------------- /** @@ -175,7 +143,7 @@ void aSettledSectionDimsItsCard() { /** * Settling section ① settles a hunk section ② also contains. Without - * saying where it was settled, ②'s state changes with no visible cause. + * saying where, ②'s count changes with no visible cause. */ @Test void aHunkSettledElsewhereSaysWhereItWasSettled() { @@ -184,29 +152,10 @@ void aHunkSettledElsewhereSaysWhereItWasSettled() { approve(GUARDS_H); approve(GUARDS_CPP); - assertEquals(List.of("①"), view.diagSectionState(1).settledElsewhere()); assertTrue(railText().contains("✓ reviewed in ①"), "the rail must name the section that settled it, got: " + railText()); } - /** - * A sibling that settled ONE shared hunk moves this card's count by - * exactly as much as a fully settled sibling 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() { - showOverlappingSections(); - - approve(GUARDS_H); - - assertEquals(List.of("②"), view.diagSectionState(0).settledElsewhere(), - "section ① shares its settled hunk with ②, which is not itself settled"); - assertEquals(List.of("①"), view.diagSectionState(1).settledElsewhere()); - assertTrue(railText().contains("✓ reviewed in ①"), railText()); - } - /** A settled card explains itself with its own verdict; the marker would be noise. */ @Test void aFullySettledCardDoesNotAlsoPointElsewhere() { @@ -219,57 +168,7 @@ void aFullySettledCardDoesNotAlsoPointElsewhere() { "settled section ① must not point at ②, got: " + railText()); } - // ---- 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 aSectionWhoseHunksLeftTheDiffIsNotCountedAndSaysSo() { - 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(view.diagSectionState(1).hunksMissing() - && intentLabel().startsWith("2 "), - "n must not land on the adrift section, got: " + intentLabel()); - } - - // ---- carry-forward (a): verdicts are keyed by a real digest ------------- + // ---- verdicts are keyed by a real digest -------------------------------- @Test void approvingASectionRecordsOneVerdictPerHunkKeyedByItsDigest() { @@ -299,47 +198,19 @@ void undoingASectionClearsEveryHunkItSettled() { "undo must clear the whole section it settled"); } - // ---- carry-forward (b): staleness is measured against a commit ---------- + // ---- the stale banner --------------------------------------------------- - /** - * A verdict given against an older base, where the move touched a file - * the section covers, reads as stale. - */ @Test - void aBaseMoveTouchingTheSectionMarksItStale() { + 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(SessionReviewView.Staleness.MOVED, view.diagSectionState(0).staleness(), - "the base moved under a file this section covers"); + assertEquals(SectionStates.Staleness.MOVED, view.diagSectionState(0).staleness()); assertTrue(railLabels(".review-intent-stale").contains("⚠ base moved — confirm")); } - /** A base move that provably could not matter must not spend the reader's attention. */ - @Test - void aBaseMoveElsewhereLeavesTheSectionFresh() { - host.baseDelta = new BaseMove.Delta(false, - new TreeSet<>(List.of("docs/README.md"))); - showOverlappingSections(); - recordAgainstBase(GUARDS_H, "0".repeat(40)); - recordAgainstBase(GUARDS_CPP, "0".repeat(40)); - - assertEquals(SessionReviewView.Staleness.FRESH, view.diagSectionState(0).staleness(), - "nothing this section covers moved"); - } - - /** A verdict recorded against the current base is never stale. */ - @Test - void aFreshVerdictIsNotStale() { - showOverlappingSections(); - - approve(GUARDS_H); - - assertEquals(SessionReviewView.Staleness.FRESH, view.diagSectionState(0).staleness()); - } - /** * 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 @@ -353,12 +224,61 @@ void anUnresolvableDeltaSaysNothingRatherThanWarning() { recordAgainstBase(GUARDS_H, "0".repeat(40)); recordAgainstBase(GUARDS_CPP, "0".repeat(40)); - assertEquals(SessionReviewView.Staleness.UNKNOWN, view.diagSectionState(0).staleness(), + 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 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 ------------------------------------------------------------ /** @@ -366,16 +286,11 @@ void anUnresolvableDeltaSaysNothingRatherThanWarning() { * again and profiler.cpp. Three hunks, four slots. */ private void showOverlappingSections() { - 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())); + mintScope(); host.intents.set(scope.id(), List.of( section("section-1", "Guards", GUARDS_H, GUARDS_CPP), section("section-2", "Profiler", GUARDS_H, PROFILER))); - interact(() -> view.showScopes(new SessionReviewScopes.Scopes(scope, Optional.empty()), - SessionReviewScopes.Choice.LOCAL)); - interact(() -> view.diagShowDiff(scope, host.diff)); - WaitForAsyncUtils.waitForFxEvents(); + show(); } /** @@ -383,14 +298,22 @@ private void showOverlappingSections() { * file does not have, which is what a stale positional id looks like. */ private void showSectionsWithOneAdrift() { - 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())); + 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)); @@ -407,11 +330,7 @@ private static ReviewIntent section(String id, String title, String... files) { } private void approve(String file) { - record(file, ReviewVerdict.Decision.APPROVED); - } - - private void record(String file, ReviewVerdict.Decision decision) { - put(file, decision, host.baseCommit); + put(file, ReviewVerdict.Decision.APPROVED, host.baseCommit); } private void recordAgainstBase(String file, String base) { @@ -434,9 +353,7 @@ private String digestOf(String file) { } private String progressText() { - List labels = new ArrayList<>(); - interact(() -> labels.addAll(lookup(".review-verdict-progress-label").queryAll())); - return labels.stream().map(node -> ((Label) node).getText()) + return labels(".review-verdict-progress-label").stream() .findFirst().orElse(""); } @@ -448,22 +365,22 @@ private long settledCardCount() { /** The texts of every label the rail drew under {@code selector}. */ private List railLabels(String selector) { - List labels = new ArrayList<>(); - interact(() -> labels.addAll(lookup(selector).queryAll())); - return labels.stream().map(node -> ((Label) node).getText()).toList(); + return labels(selector); } private String intentLabel() { - List labels = new ArrayList<>(); - interact(() -> labels.addAll(lookup(".review-verdict-intent").queryAll())); - return labels.stream().map(node -> ((Label) node).getText()).findFirst().orElse(""); + return labels(".review-verdict-intent").stream().findFirst().orElse(""); } private String railText() { - List labels = new ArrayList<>(); - interact(() -> labels.addAll(lookup(".review-intent-settled-elsewhere").queryAll())); - return labels.stream().map(node -> ((Label) node).getText()) - .reduce("", (a, b) -> a + " " + b); + 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) { 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..7c395d93 --- /dev/null +++ b/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java @@ -0,0 +1,342 @@ +package app.drydock.ui.review; + +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 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.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()); + } + + // ---- 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()); + } + + /** 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()); + } + + /** 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()); + } + + // ---- 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)); + } + + // ---- 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); + } + + private void record(String file, ReviewVerdict.Decision decision, String base) { + host.store.putVerdict(new ReviewVerdict(scope.id(), digestOf(file), decision, + Optional.empty(), Instant.EPOCH, base, host.headCommit)); + } + + private String digestOf(String file) { + return diff.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))))); + } +} From 70b8e14c836f296e6ab630e27e31bdf2972c4820 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 06:46:23 +0200 Subject: [PATCH 022/113] 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. --- .../java/app/drydock/ui/MainWorkspace.java | 11 + .../java/app/drydock/ui/ShortcutsOverlay.java | 8 +- .../drydock/ui/review/ReviewVerdictBar.java | 125 +++++++++- .../app/drydock/ui/review/SectionStates.java | 45 ++++ .../drydock/ui/review/SessionReviewView.java | 232 +++++++++++++++--- .../ui/OpenSessionTabReviewSubTabTest.java | 4 + .../ui/ShortcutsOverlayParityTest.java | 2 +- .../app/drydock/ui/review/FakeReviewHost.java | 9 + .../ui/review/ReviewHunkProgressTest.java | 59 +++++ .../ReviewIntentRailCardHeightTest.java | 61 +++++ .../ui/review/ReviewSettleActionsTest.java | 66 +++++ .../ui/review/ReviewVerdictBarFitTest.java | 79 ++++++ .../ReviewVerdictBarNavigationTest.java | 1 + .../drydock/ui/review/ReviewViewFixture.java | 143 +++++++++++ .../drydock/ui/review/SectionStatesTest.java | 58 +++++ 15 files changed, 856 insertions(+), 47 deletions(-) create mode 100644 app/src/test/java/app/drydock/ui/review/ReviewSettleActionsTest.java create mode 100644 app/src/test/java/app/drydock/ui/review/ReviewViewFixture.java diff --git a/app/src/main/java/app/drydock/ui/MainWorkspace.java b/app/src/main/java/app/drydock/ui/MainWorkspace.java index cc33c203..fe6cf757 100644 --- a/app/src/main/java/app/drydock/ui/MainWorkspace.java +++ b/app/src/main/java/app/drydock/ui/MainWorkspace.java @@ -2003,6 +2003,17 @@ && blockingFindingOpen(scope, intent)) { } } + @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(); diff --git a/app/src/main/java/app/drydock/ui/ShortcutsOverlay.java b/app/src/main/java/app/drydock/ui/ShortcutsOverlay.java index ac1fa53b..b3cc091b 100644 --- a/app/src/main/java/app/drydock/ui/ShortcutsOverlay.java +++ b/app/src/main/java/app/drydock/ui/ShortcutsOverlay.java @@ -51,9 +51,11 @@ private record Section(String title, String[][] shortcuts) { } {"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"}, + {"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"}, {"Submit the review", "⏎"}, {"Collapse the intents", "i"}, {"Collapse the findings margin", "m"}, 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 15734cc7..eced0db8 100644 --- a/app/src/main/java/app/drydock/ui/review/ReviewVerdictBar.java +++ b/app/src/main/java/app/drydock/ui/review/ReviewVerdictBar.java @@ -36,9 +36,16 @@ interface Host { /** "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 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,6 +59,14 @@ 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(); @@ -63,6 +78,16 @@ interface Host { private final Button undoButton = new Button("change"); private final Label settledLabel = new Label(); private final Label refusalLabel = new Label(); + /** + * Names the unit {@code a}/{@code r}/{@code u} act on right now (spec + * §9.6): a key whose target depends on focus has to say what it is + * about to do, or the reader is guessing. + */ + private final Label actingUnitLabel = 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(); @@ -94,6 +119,7 @@ interface Host { private boolean blocked; private int settledHunks; private int totalHunks; + private Optional stale = Optional.empty(); ReviewVerdictBar(Host host) { this.host = host; @@ -108,7 +134,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"); @@ -138,6 +165,24 @@ interface Host { 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); + + actingUnitLabel.getStyleClass().add("review-verdict-unit"); + settledLabel.getStyleClass().add("review-verdict-settled"); refusalLabel.getStyleClass().add("review-verdict-refusal"); refusalLabel.setVisible(false); @@ -214,6 +259,36 @@ void showProgress(int settled, int 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(); + } + + /** + * Names the unit {@code a}/{@code r}/{@code u} act on right now (spec + * §9.6): a key whose target depends on focus has to say what it is + * about to do. + */ + void showActingUnit(SessionReviewView.SettleUnit unit) { + actingUnitLabel.setText(switch (unit) { + case HUNK -> "acts on: hunk"; + case SECTION -> "acts on: section"; + case FILE -> "acts on: file"; + }); + actingUnitLabel.setTooltip(new Tooltip( + "a, r and u act on the unit named here -- click into the diff column for a " + + "single hunk, the rail for the whole section, or use ⇧A / ⇧R for the file")); + render(); + } + /** * 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 @@ -229,6 +304,11 @@ void showSubmitRefused(String reason) { submitButton.pseudoClassStateChanged(javafx.css.PseudoClass.getPseudoClass("refused"), true); } + /** 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; + } + private void clearSubmitRefused() { submitRefusalLabel.setVisible(false); submitRefusalLabel.setManaged(false); @@ -255,12 +335,22 @@ private void render() { ? "all settled — ⏎ submits" : (totalHunks - settledHunks) + " hunks left · n jumps to the next"); - if (decision.isPresent()) { + 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, + actingUnitLabel, 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-" + decision.get().wireName()); actionRow.getChildren().setAll(previousButton, nextButton, intentLabel, - settledLabel, undoButton, actionSpacer, navHint); + actingUnitLabel, settledLabel, undoButton, actionSpacer, navHint); } else { refusalLabel.setText("⚠ a blocking finding is still open"); refusalLabel.setVisible(blocked); @@ -268,8 +358,8 @@ private void render() { approveButton.pseudoClassStateChanged( javafx.css.PseudoClass.getPseudoClass("refused"), blocked); actionRow.getChildren().setAll(previousButton, nextButton, intentLabel, - approveButton, requestChangesButton, askAgentButton, refusalLabel, - actionSpacer, navHint); + actingUnitLabel, approveButton, requestChangesButton, askAgentButton, + refusalLabel, actionSpacer, navHint); } fitActionRow(actionRow.getWidth()); @@ -320,19 +410,32 @@ private void fitActionRow(double width) { + INTENT_LABEL_MIN; int slots = 0; for (javafx.scene.Node child : actionRow.getChildren()) { - if (!child.isManaged() && child != navHint) { + if (!child.isManaged() && child != navHint && child != actingUnitLabel) { continue; } slots++; - if (child == actionSpacer || child == navHint || child == intentLabel) { + if (child == actionSpacer || child == navHint || child == intentLabel + || child == actingUnitLabel) { continue; } needed += child.prefWidth(-1); } needed += actionRow.getSpacing() * Math.max(0, slots - 1); - boolean room = width - needed >= navHint.prefWidth(-1); - navHint.setVisible(room); - navHint.setManaged(room); + + // The acting-unit label is checked first, and against the actions + // ALONE: it says what a/r/u are about to do, which matters more than + // navHint's "n jumps to the next" progress note, so a hint that + // would otherwise fit does not get to crowd it out. Never partially + // shown -- like the hint, it is either fully there or not at all. + double spacing = actionRow.getSpacing(); + boolean roomForUnit = width - needed >= actingUnitLabel.prefWidth(-1) + spacing; + actingUnitLabel.setVisible(roomForUnit); + actingUnitLabel.setManaged(roomForUnit); + + double afterUnit = needed + (roomForUnit ? actingUnitLabel.prefWidth(-1) + spacing : 0); + boolean roomForHint = width - afterUnit >= navHint.prefWidth(-1); + navHint.setVisible(roomForHint); + navHint.setManaged(roomForHint); } /** 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 index ada85170..8b34ce11 100644 --- a/app/src/main/java/app/drydock/ui/review/SectionStates.java +++ b/app/src/main/java/app/drydock/ui/review/SectionStates.java @@ -2,6 +2,7 @@ import app.drydock.git.UnifiedDiff; import app.drydock.review.BaseMove; +import app.drydock.review.HunkDigest; import app.drydock.review.IntentHunks; import app.drydock.review.ReviewIntent; import app.drydock.review.ReviewScope; @@ -317,6 +318,50 @@ private static List filesOf(Board board, ReviewIntent intent) { return board.diff().files().stream().map(UnifiedDiff.FileDiff::path).toList(); } + /** + * The digest of the hunk the diff column shows when {@code intent} is + * selected -- its anchor (spec §9.6). Selecting a section narrows the + * column to that section's hunks and scrolls it to the first one (see + * {@code SessionReviewView#revealCurrentIntent}), so that is the one + * hunk-scoped {@code a}/{@code r}/{@code u} acts on when the diff + * column, rather than the rail, has focus. 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 file the diff column is anchored on when {@code intent} is + * selected -- what {@code ⇧A}/{@code ⇧R} settle every hunk of, rather + * than just the section's slice of it. The intent's own anchor file + * when it names one, else the first file it covers at all (see {@link + * #filesOf}). + */ + Optional fileOf(Board board, ReviewIntent intent) { + return intent.anchor().map(ReviewIntent.Anchor::file) + .or(() -> filesOf(board, intent).stream().findFirst()); + } + + /** + * 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(); + } + /** 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" 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 b8010a3e..b86c447f 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -21,6 +21,7 @@ import javafx.scene.control.TextInputControl; import javafx.scene.control.Tooltip; import javafx.scene.input.KeyEvent; +import javafx.scene.input.MouseEvent; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; @@ -117,6 +118,17 @@ public interface Host { void setVerdict(ReviewScope scope, ReviewIntent intent, List hunkDigests, Optional decision); + /** + * "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. @@ -219,6 +231,20 @@ void setVerdict(ReviewScope scope, ReviewIntent intent, List hunkDigests */ 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 three possible + * targets rather than three keys 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 + } + private final Host host; private final ReviewScopeSwitcher switcher = new ReviewScopeSwitcher(); private final ReviewDiffColumn diffColumn; @@ -266,15 +292,39 @@ void setVerdict(ReviewScope scope, ReviewIntent intent, List hunkDigests /** * 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 the diff column, rather than the rail, is where {@code a}/ + * {@code r}/{@code u} act (spec §9.6). Tracked from a plain mouse press + * on either -- NOT from {@code Node.isFocusWithin()}, which this view's + * own rail defeats: {@link ReviewIntentRail#rebuild} replaces every card + * {@code Button} on each render, and JavaFX moves focus off a card about + * to be discarded via {@code Direction.NEXT} traversal (see the + * project's JavaFX-traps memory) -- which can land inside the diff + * column and never leave, well after the reader's last click was on the + * rail. Defaults to {@code false} (the rail), matching what {@code a}/ + * {@code r}/{@code u} did before this task. + */ + private boolean diffColumnActedOn; + /** Set by {@code m}/{@code f}; remembered independently of the responsive collapse. */ private boolean marginCollapsedByUser; @@ -389,6 +439,13 @@ public SessionReviewView(Host host, DiffService diffService, McpActivityLog acti } }); + // See diffColumnActedOn's javadoc for why this is a plain mouse-press + // filter rather than Node.isFocusWithin(): the rail rebuilds its + // cards on every render, which can leave isFocusWithin() stuck true + // for the diff column long after the reader's last click was on the + // rail. + intentRail.addEventFilter(MouseEvent.MOUSE_PRESSED, e -> diffColumnActedOn = false); + diffColumn.addEventFilter(MouseEvent.MOUSE_PRESSED, e -> diffColumnActedOn = true); widthProperty().addListener((obs, old, width) -> applyResponsiveLayout(width.doubleValue())); addEventFilter(KeyEvent.KEY_PRESSED, this::onKeyPressed); setFocusTraversable(true); @@ -563,6 +620,7 @@ private void renderSelectedScope() { // scope switch could make u undo, and jump into, a same-named // intent in the WRONG scope. lastSettledIntentId = Optional.empty(); + lastSettledDigests = List.of(); // 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 @@ -865,21 +923,86 @@ 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): + * the hunk the diff column is anchored on when it has focus, the whole + * section otherwise -- the same default the keys have always had, so a + * reader who has never clicked into the diff column sees no change. + */ + SettleUnit settleUnit() { + return diffColumnActedOn ? SettleUnit.HUNK : SettleUnit.SECTION; + } + + /** The one hunk {@link SettleUnit#HUNK} acts on -- see {@link SectionStates#digestOfAnchorHunk}. */ + private Optional digestOfCurrentHunk() { + return currentIntent().flatMap(intent -> board() + .flatMap(b -> sections.digestOfAnchorHunk(b, intent))); + } + + /** Every hunk of the current file -- what {@code ⇧A}/{@code ⇧R} act on. */ + private List digestsOfCurrentFile() { + return currentIntent().flatMap(intent -> board() + .flatMap(b -> sections.fileOf(b, intent).map(file -> sections.digestsOfFile(b, file)))) + .orElse(List.of()); + } + + /** + * The digests {@code a}/{@code r}/{@code u} act on for {@code intent} + * right now: {@code wholeFile} is {@code ⇧A}/{@code ⇧R} and always wins; + * otherwise it follows {@link #settleUnit()}. + */ + private List digestsForAction(ReviewIntent intent, boolean wholeFile) { + if (wholeFile) { + return digestsOfCurrentFile(); + } + return settleUnit() == SettleUnit.HUNK + ? digestOfCurrentHunk().map(List::of).orElse(List.of()) + : digestsOf(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 scope}'s current one. Callers only ask this once + * {@link SectionStates.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. + */ + private String oldBaseOf(ReviewScope scope, ReviewIntent intent) { + String current = host.currentBase(scope); + for (String digest : digestsOf(intent)) { + Optional verdict = host.verdict(scope, digest); + if (verdict.isPresent() && verdict.get().staleAgainst(current)) { + return verdict.get().baseCommit(); + } + } + return current; + } + private void renderVerdictBar(ReviewScope scope) { Optional current = currentIntent(); Optional board = board(); 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 = host.findings(scope).stream() .filter(this::belongsToCurrentIntent) .anyMatch(ReviewAnnotation::blocksApproval); - verdictBar.update(current.get(), sectionState(current.get()).decision(), blocked); + 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( + oldBaseOf(scope, current.get()), host.currentBase(scope))) + : Optional.empty()); + verdictBar.showActingUnit(settleUnit()); } /** @@ -1013,13 +1136,13 @@ private final class VerdictHost implements ReviewVerdictBar.Host { @Override public void approve(ReviewIntent intent) { selectedScope().ifPresent(scope -> host.setVerdict(scope, intent, - digestsOf(intent), Optional.of(ReviewVerdict.Decision.APPROVED))); + digestsForAction(intent, false), Optional.of(ReviewVerdict.Decision.APPROVED))); } @Override public void requestChanges(ReviewIntent intent) { selectedScope().ifPresent(scope -> host.setVerdict(scope, intent, - digestsOf(intent), Optional.of(ReviewVerdict.Decision.CHANGES))); + digestsForAction(intent, false), Optional.of(ReviewVerdict.Decision.CHANGES))); } @Override @@ -1033,10 +1156,20 @@ public void askAgentToFix(ReviewIntent intent) { @Override public void undo(ReviewIntent intent) { + // Re-review, too (spec §9.2): a stale section's banner button and + // the plain undo button both just clear what is recorded. selectedScope().ifPresent(scope -> host.setVerdict(scope, intent, digestsOf(intent), Optional.empty())); } + @Override + public void confirmStillGood(ReviewIntent intent) { + selectedScope().ifPresent(scope -> { + host.confirmStillGood(scope, digestsOf(intent)); + refreshReviewState(); + }); + } + @Override public void nextUnsettled() { nextUnsettledIntent(); @@ -1114,6 +1247,17 @@ private void submitReview() { "an intent still needs a verdict (approve or request changes); jumped to it"); 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("approvals were given against an older base"); + return; + } decisions.add(decision.get()); } host.submit(scope.get(), buildDiffIndex(diffColumn.displayedDiff()), decisions); @@ -1260,31 +1404,48 @@ 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) { Optional scope = selectedScope(); Optional intent = currentIntent(); - if (scope.isPresent() && intent.isPresent()) { - host.setVerdict(scope.get(), intent.get(), digestsOf(intent.get()), - Optional.of(decision)); - if (decisionOf(intent.get()).filter(decision::equals).isPresent()) { - lastSettledIntentId = Optional.of(intent.get().id()); - nextUnsettledIntent(); - } + if (scope.isEmpty() || intent.isEmpty()) { + return; + } + List digests = digestsForAction(intent.get(), wholeFile); + if (digests.isEmpty()) { + return; + } + host.setVerdict(scope.get(), intent.get(), digests, Optional.of(decision)); + boolean applied = digests.stream().allMatch(digest -> host.verdict(scope.get(), digest) + .filter(v -> v.decision() == decision).isPresent()); + if (!applied) { + return; + } + lastSettledIntentId = Optional.of(intent.get().id()); + lastSettledDigests = digests; + if (decisionOf(intent.get()).filter(decision::equals).isPresent()) { + nextUnsettledIntent(); } } /** - * {@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 @@ -1297,7 +1458,7 @@ private void verdictAction(ReviewVerdict.Decision decision) { */ private void undoVerdict() { Optional scope = selectedScope(); - if (scope.isEmpty() || lastSettledIntentId.isEmpty()) { + if (scope.isEmpty() || lastSettledIntentId.isEmpty() || lastSettledDigests.isEmpty()) { return; } List current = intents(); @@ -1308,15 +1469,16 @@ 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), digestsOf(current.get(index)), - Optional.empty()); + host.setVerdict(scope.get(), current.get(index), digests, Optional.empty()); intentIndex = index; refreshReviewState(); revealCurrentIntent(); @@ -1424,8 +1586,14 @@ public boolean handleShortcut(KeyEvent event) { 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 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. diff --git a/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java b/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java index 0ba29dc5..15452596 100644 --- a/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java +++ b/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java @@ -501,6 +501,10 @@ public void setVerdict(ReviewScope scope, ReviewIntent intent, List hunk Optional decision) { } + @Override + public void confirmStillGood(ReviewScope scope, List hunkDigests) { + } + @Override public String currentBase(ReviewScope scope) { return SessionReviewView.UNRESOLVED_BASE; diff --git a/app/src/test/java/app/drydock/ui/ShortcutsOverlayParityTest.java b/app/src/test/java/app/drydock/ui/ShortcutsOverlayParityTest.java index 0bbcb881..6efa73ff 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"); assertEquals(bound, advertised, "the overlay's IN REVIEW rows and what SessionReviewView.handleShortcut " 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 b60d9bce..7b60ab12 100644 --- a/app/src/test/java/app/drydock/ui/review/FakeReviewHost.java +++ b/app/src/test/java/app/drydock/ui/review/FakeReviewHost.java @@ -140,6 +140,15 @@ public void setVerdict(ReviewScope scope, ReviewIntent intent, List hunk } } + @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; diff --git a/app/src/test/java/app/drydock/ui/review/ReviewHunkProgressTest.java b/app/src/test/java/app/drydock/ui/review/ReviewHunkProgressTest.java index ed991ded..b5120453 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewHunkProgressTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewHunkProgressTest.java @@ -11,6 +11,7 @@ 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; @@ -230,6 +231,64 @@ void anUnresolvableDeltaSaysNothingRatherThanWarning() { "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"); + assertTrue(labels(".review-verdict-submit-refusal").stream() + .anyMatch(text -> text.contains("older base")), + "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()); + } + // ---- a grouping that drifted off the diff ------------------------------- /** 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..ae3b1b69 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,7 @@ package app.drydock.ui.review; import app.drydock.review.ReviewIntent; +import app.drydock.review.ReviewVerdict; import javafx.scene.Scene; import javafx.scene.control.Button; @@ -95,6 +96,66 @@ 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, 3, SectionStates.Staleness.FRESH, + List.of("①", "②", "③", "④", "⑤"), false)); + showIntents(List.of(intent(1, "guards.h", ReviewIntent.Kind.CHANGE, "shared hunk"))); + + 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, + SectionStates.Staleness.MOVED, List.of(), false)); + 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) { 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..0b09f69e --- /dev/null +++ b/app/src/test/java/app/drydock/ui/review/ReviewSettleActionsTest.java @@ -0,0 +1,66 @@ +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 {@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() { + focusDiffColumn(); + press(KeyCode.A).release(KeyCode.A); + WaitForAsyncUtils.waitForFxEvents(); + + assertEquals(1, view.diagSectionState(0).settledHunks()); + } + + @Test + void shiftApproveSettlesEveryHunkOfTheCurrentFile() { + 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()); + } + + @Test + void theBarNamesTheUnitAnActionWillHit() { + focusRail(); + assertEquals(SessionReviewView.SettleUnit.SECTION, view.settleUnit()); + + focusDiffColumn(); + assertEquals(SessionReviewView.SettleUnit.HUNK, view.settleUnit()); + } +} 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 c1d82cf1..ac9f40c6 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewVerdictBarFitTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewVerdictBarFitTest.java @@ -3,6 +3,7 @@ import app.drydock.review.ReviewIntent; import app.drydock.review.ReviewVerdict; +import javafx.scene.Node; import javafx.scene.Scene; import javafx.scene.control.Button; import javafx.scene.control.Label; @@ -41,6 +42,7 @@ public void start(Stage stage) { @Override public void requestChanges(ReviewIntent intent) { } @Override public void askAgentToFix(ReviewIntent intent) { } @Override public void undo(ReviewIntent intent) { } + @Override public void confirmStillGood(ReviewIntent intent) { } @Override public void nextUnsettled() { } @Override public void submit() { } @Override public void previousIntent() { } @@ -119,6 +121,83 @@ void theHintIsBackAsSoonAsThereIsRoomForIt() { assertTrue(hintShowing(), "a wide bar shows the hint again"); } + /** + * The stale banner (spec §9.2) swaps in two more buttons, "Confirm still + * good" and "Re-review"; the Phase 1 gate named it -- alongside the + * rail's two Task 6 additions -- as new UI with no fit coverage. + */ + @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 acting-unit label (spec §9.6) is the other new element the gate + * named: a key whose target depends on focus has to say what it is + * about to do. At the code-column floor there is genuinely no room for + * it alongside the primary actions and the title -- confirmed by hand: + * widening {@code CODE_MIN_WIDTH} to make room was rejected in favour of + * the same rule the progress hint already follows. It drops rather than + * clips, and reappears as soon as there is room; it must never show a + * half-cut word. + */ + @Test + void theActingUnitLabelDropsRatherThanClipsAtTheFloor() { + show(intent(2, "drydock/review · 4 files"), Optional.empty()); + interact(() -> bar.showActingUnit(SessionReviewView.SettleUnit.SECTION)); + WaitForAsyncUtils.waitForFxEvents(); + interact(() -> bar.getScene().getRoot().layout()); + WaitForAsyncUtils.waitForFxEvents(); + + assertFalse(actingUnitLabelShowing(), + "at the floor there is no room for it; it must hide, never show it clipped"); + } + + /** Same label, back and fully legible as soon as the bar has room, as the hint already is. */ + @Test + void theActingUnitLabelIsBackAsSoonAsThereIsRoomForIt() { + show(intent(2, "drydock/review · 4 files"), Optional.empty()); + interact(() -> bar.showActingUnit(SessionReviewView.SettleUnit.HUNK)); + WaitForAsyncUtils.waitForFxEvents(); + assertFalse(actingUnitLabelShowing(), "at the floor the label has to go, same as the hint"); + + interact(() -> bar.getScene().getWindow().setWidth(1400)); + WaitForAsyncUtils.waitForFxEvents(); + interact(() -> bar.getScene().getRoot().layout()); + WaitForAsyncUtils.waitForFxEvents(); + + assertTrue(actingUnitLabelShowing(), "a wide bar shows the acting-unit label again"); + assertLabelNotClipped(".review-verdict-unit"); + } + + private boolean actingUnitLabelShowing() { + boolean[] showing = new boolean[1]; + interact(() -> showing[0] = lookup(".review-verdict-unit").queryAll().stream() + .anyMatch(Node::isManaged)); + return showing[0]; + } + + private void assertLabelNotClipped(String selector) { + double[] width = new double[1]; + double[] pref = new double[1]; + String[] text = new String[1]; + interact(() -> { + Label label = (Label) lookup(selector).query(); + width[0] = label.getWidth(); + pref[0] = label.prefWidth(-1); + text[0] = label.getText(); + }); + assertTrue(width[0] + 0.5 >= pref[0], "'" + text[0] + "' got " + Math.round(width[0]) + + "px of " + Math.round(pref[0]) + "px it wanted"); + } + // ---- helpers -------------------------------------------------------- private boolean hintShowing() { 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 c219c248..ca14551c 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewVerdictBarNavigationTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewVerdictBarNavigationTest.java @@ -33,6 +33,7 @@ public void start(Stage stage) { @Override public void requestChanges(ReviewIntent intent) { calls.add("changes"); } @Override public void askAgentToFix(ReviewIntent intent) { calls.add("ask"); } @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"); } 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..96fa838c --- /dev/null +++ b/app/src/test/java/app/drydock/ui/review/ReviewViewFixture.java @@ -0,0 +1,143 @@ +package app.drydock.ui.review; + +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.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; + +/** + * 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()); + stage.setScene(scene); + stage.show(); + } + + /** + * 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() { + 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(); + } + + @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}. */ + final void focusDiffColumn() { + clickOn(".review-diff-cell"); + WaitForAsyncUtils.waitForFxEvents(); + } + + /** How many hunks {@link #FILE_A} has -- what {@code ⇧A}/{@code ⇧R} settle. */ + final int hunkCountOfCurrentFile() { + return 2; + } + + private static UnifiedDiff.FileDiff file(String path, String... hunkTexts) { + List hunks = new ArrayList<>(); + for (String text : hunkTexts) { + hunks.add(new UnifiedDiff.Hunk("@@ -1 +1 @@", List.of( + new UnifiedDiff.Line(UnifiedDiff.Line.Kind.ADD, OptionalInt.empty(), + OptionalInt.of(1), text)))); + } + return new UnifiedDiff.FileDiff(path, "M", hunkTexts.length, 0, false, false, hunks); + } +} diff --git a/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java b/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java index 7c395d93..78d1724f 100644 --- a/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java +++ b/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java @@ -287,6 +287,64 @@ void sectionMarksAreCircledUpToTwentyThenPlain() { 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 fileOfIsTheAnchorHunksFile() { + SectionStates.Board board = overlapping(); + ReviewIntent section2 = board.sections().get(1); + + assertEquals(Optional.of(GUARDS_H), sections.fileOf(board, section2)); + } + + /** + * An intent naming no hunks at all covers the whole diff (see {@link + * ReviewIntent#containsHunk}); {@link SectionStates#fileOf} falls back + * to the first file of the diff rather than answering nothing. + */ + @Test + void fileOfFallsBackToTheDiffsFirstFileWhenTheSectionNamesNone() { + 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.fileOf(board, board.sections().get(0))); + } + + /** + * {@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()); + } + // ---- helpers ------------------------------------------------------------- /** Section ① covers both guards files; section ② covers guards.h again and profiler. */ From 4649864311d4bf3b44a7981fe232c4747027774a Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 07:26:42 +0200 Subject: [PATCH 023/113] Settle the real hunk in view, not a stuck focus flag or the anchor The acting-unit label never changed after a click into the diff column: digestColumnActedOn was set from a MOUSE_PRESSED filter on the whole rail and column, and nothing re-rendered the bar when it flipped, so a/r silently switched to HUNK while the bar still read "section". settleUnit() now reads the Scene's real focus owner and walks its parent chain instead, and a focusOwnerProperty listener keeps the bar's label live across any focus change, not just the ones this view's own key handling triggers. That real-focus read is also immune to the isFocusWithin() ref-count bug an earlier attempt hit -- ReviewIntentRail rebuilds every card on each render, and JavaFX moves focus off a card about to be discarded via Direction.NEXT traversal, which left isFocusWithin() stuck true long after the real owner moved elsewhere. A fresh getFocusOwner() read every time never accumulates that staleness. The trade-off, on the record: a scrollbar drag or the rail's own collapse toggle can also move real focus and flip the unit, which a hand-tracked flag limited to "the rail" and "the diff" would not have -- accepted because the flag's alternative failure (silently stuck) is worse than this one (visibly, correctly focus-driven). HUNK mode also stopped always settling the section's first hunk: with the diff column acting, a settled hunk one forever, with no way to reach hunks two and up. It now prefers the hunk under an open gutter selection (ReviewDiffColumn#currentLineSelection, resolved to a digest by SectionStates#digestOfLine), falls back to the section's first UNSETTLED hunk so repeated presses walk forward, and only then to the anchor. Same priority for the file behind SHIFT+A/SHIFT+R (SectionStates#currentFileOf). Found and reported rather than worked around: a gutter click's release also opens the comment composer and moves real keyboard focus into its text field, which the existing TextInputControl guard then makes a/r type into rather than trigger -- and closing the composer clears the selection too. There is no way, with the composer unchanged, to hold a gutter selection AND have a/r read as shortcuts immediately afterward from a real completed click; ReviewSettleActionsTest proves the underlying wiring with a bare mouse press (selection painted, composer not yet opened) and says so. A stale verdict also stopped counting toward "everything settled": SectionStates#settledHunkCount now excludes a hunk whose recorded base has moved in a way that could matter, so the progress line and the "all settled" nav hint agree with Submit's own refusal instead of contradicting it one keystroke later. digestsForAction and oldBaseOf moved into SectionStates alongside the rest of the settle-target derivation, out of the view, which had regrown past its post-Task-6 size. --- .../drydock/ui/review/ReviewDiffColumn.java | 24 +++ .../app/drydock/ui/review/SectionStates.java | 176 ++++++++++++++++-- .../drydock/ui/review/SessionReviewView.java | 123 ++++++------ .../ui/review/ReviewHunkProgressTest.java | 25 +++ .../ui/review/ReviewSettleActionsTest.java | 68 ++++++- .../drydock/ui/review/ReviewViewFixture.java | 53 +++++- .../drydock/ui/review/SectionStatesTest.java | 116 +++++++++++- 7 files changed, 491 insertions(+), 94 deletions(-) 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..953d7ac4 100644 --- a/app/src/main/java/app/drydock/ui/review/ReviewDiffColumn.java +++ b/app/src/main/java/app/drydock/ui/review/ReviewDiffColumn.java @@ -19,6 +19,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; @@ -350,6 +351,14 @@ 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 -> list.requestFocus()); list.setCellFactory(view -> new DiffCell()); // Long lines wrap; the column never scrolls sideways. See // viewportWidth for what this replaces. @@ -823,6 +832,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 diff --git a/app/src/main/java/app/drydock/ui/review/SectionStates.java b/app/src/main/java/app/drydock/ui/review/SectionStates.java index 8b34ce11..a719c254 100644 --- a/app/src/main/java/app/drydock/ui/review/SectionStates.java +++ b/app/src/main/java/app/drydock/ui/review/SectionStates.java @@ -202,17 +202,55 @@ List distinctDigests(Board board) { return List.copyOf(distinct); } - /** How many of {@link #distinctDigests} carry a verdict. */ + /** + * 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()) { + 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)) { + List files = filesOf(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. @@ -319,31 +357,103 @@ private static List filesOf(Board board, ReviewIntent intent) { } /** - * The digest of the hunk the diff column shows when {@code intent} is - * selected -- its anchor (spec §9.6). Selecting a section narrows the - * column to that section's hunks and scrolls it to the first one (see - * {@code SessionReviewView#revealCurrentIntent}), so that is the one - * hunk-scoped {@code a}/{@code r}/{@code u} acts on when the diff - * column, rather than the rail, has focus. Empty for a section with no - * resolvable hunk at all. + * 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 -- what {@code ⇧A}/{@code ⇧R} settle every hunk of, rather - * than just the section's slice of it. The intent's own anchor file - * when it names one, else the first file it covers at all (see {@link - * #filesOf}). + * 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}). */ - Optional fileOf(Board board, ReviewIntent intent) { + 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} @@ -362,6 +472,44 @@ List digestsOfFile(Board board, String file) { 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" 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 b86c447f..bf34ac97 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -21,7 +21,6 @@ import javafx.scene.control.TextInputControl; import javafx.scene.control.Tooltip; import javafx.scene.input.KeyEvent; -import javafx.scene.input.MouseEvent; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; @@ -311,20 +310,6 @@ enum SettleUnit { */ private List lastSettledDigests = List.of(); - /** - * Whether the diff column, rather than the rail, is where {@code a}/ - * {@code r}/{@code u} act (spec §9.6). Tracked from a plain mouse press - * on either -- NOT from {@code Node.isFocusWithin()}, which this view's - * own rail defeats: {@link ReviewIntentRail#rebuild} replaces every card - * {@code Button} on each render, and JavaFX moves focus off a card about - * to be discarded via {@code Direction.NEXT} traversal (see the - * project's JavaFX-traps memory) -- which can land inside the diff - * column and never leave, well after the reader's last click was on the - * rail. Defaults to {@code false} (the rail), matching what {@code a}/ - * {@code r}/{@code u} did before this task. - */ - private boolean diffColumnActedOn; - /** Set by {@code m}/{@code f}; remembered independently of the responsive collapse. */ private boolean marginCollapsedByUser; @@ -439,13 +424,20 @@ public SessionReviewView(Host host, DiffService diffService, McpActivityLog acti } }); - // See diffColumnActedOn's javadoc for why this is a plain mouse-press - // filter rather than Node.isFocusWithin(): the rail rebuilds its - // cards on every render, which can leave isFocusWithin() stuck true - // for the diff column long after the reader's last click was on the - // rail. - intentRail.addEventFilter(MouseEvent.MOUSE_PRESSED, e -> diffColumnActedOn = false); - diffColumn.addEventFilter(MouseEvent.MOUSE_PRESSED, e -> diffColumnActedOn = true); + // 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(). + sceneProperty().addListener((obs, oldScene, newScene) -> { + if (newScene != null) { + newScene.focusOwnerProperty().addListener( + (o, oldOwner, newOwner) -> verdictBar.showActingUnit(settleUnit())); + } + }); widthProperty().addListener((obs, old, width) -> applyResponsiveLayout(width.doubleValue())); addEventFilter(KeyEvent.KEY_PRESSED, this::onKeyPressed); setFocusTraversable(true); @@ -907,6 +899,17 @@ 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} + * right now -- see {@link SectionStates#digestsForAction}. None without + * a diff to derive them from. + */ + private List digestsForAction(ReviewIntent intent, boolean wholeFile) { + return board().map(b -> sections.digestsForAction(b, intent, settleUnit(), 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)); @@ -925,59 +928,39 @@ private List countedSections() { /** * What {@code a} / {@code r} / {@code u} act on right now (spec §9.6): - * the hunk the diff column is anchored on when it has focus, the whole - * section otherwise -- the same default the keys have always had, so a - * reader who has never clicked into the diff column sees no change. + * {@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() { - return diffColumnActedOn ? SettleUnit.HUNK : SettleUnit.SECTION; - } - - /** The one hunk {@link SettleUnit#HUNK} acts on -- see {@link SectionStates#digestOfAnchorHunk}. */ - private Optional digestOfCurrentHunk() { - return currentIntent().flatMap(intent -> board() - .flatMap(b -> sections.digestOfAnchorHunk(b, intent))); - } - - /** Every hunk of the current file -- what {@code ⇧A}/{@code ⇧R} act on. */ - private List digestsOfCurrentFile() { - return currentIntent().flatMap(intent -> board() - .flatMap(b -> sections.fileOf(b, intent).map(file -> sections.digestsOfFile(b, file)))) - .orElse(List.of()); - } - - /** - * The digests {@code a}/{@code r}/{@code u} act on for {@code intent} - * right now: {@code wholeFile} is {@code ⇧A}/{@code ⇧R} and always wins; - * otherwise it follows {@link #settleUnit()}. - */ - private List digestsForAction(ReviewIntent intent, boolean wholeFile) { - if (wholeFile) { - return digestsOfCurrentFile(); - } - return settleUnit() == SettleUnit.HUNK - ? digestOfCurrentHunk().map(List::of).orElse(List.of()) - : digestsOf(intent); + return isDescendantOf(getScene() == null ? null : getScene().getFocusOwner(), diffColumn) + ? SettleUnit.HUNK + : SettleUnit.SECTION; } - /** - * 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 scope}'s current one. Callers only ask this once - * {@link SectionStates.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. - */ - private String oldBaseOf(ReviewScope scope, ReviewIntent intent) { - String current = host.currentBase(scope); - for (String digest : digestsOf(intent)) { - Optional verdict = host.verdict(scope, digest); - if (verdict.isPresent() && verdict.get().staleAgainst(current)) { - return verdict.get().baseCommit(); + private static boolean isDescendantOf(Node node, Node ancestor) { + for (Node n = node; n != null; n = n.getParent()) { + if (n == ancestor) { + return true; } } - return current; + return false; } private void renderVerdictBar(ReviewScope scope) { @@ -1000,7 +983,7 @@ private void renderVerdictBar(ReviewScope scope) { sections.distinctDigests(board.get()).size()); verdictBar.showStale(state.staleness() == SectionStates.Staleness.MOVED ? Optional.of(new ReviewVerdictBar.StaleInfo( - oldBaseOf(scope, current.get()), host.currentBase(scope))) + sections.oldBaseOf(board.get(), current.get()), host.currentBase(scope))) : Optional.empty()); verdictBar.showActingUnit(settleUnit()); } diff --git a/app/src/test/java/app/drydock/ui/review/ReviewHunkProgressTest.java b/app/src/test/java/app/drydock/ui/review/ReviewHunkProgressTest.java index b5120453..248b7325 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewHunkProgressTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewHunkProgressTest.java @@ -289,6 +289,31 @@ void reReviewClearsTheStaleVerdicts() { 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(""); + } + // ---- a grouping that drifted off the diff ------------------------------- /** diff --git a/app/src/test/java/app/drydock/ui/review/ReviewSettleActionsTest.java b/app/src/test/java/app/drydock/ui/review/ReviewSettleActionsTest.java index 0b09f69e..2c0f337e 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewSettleActionsTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewSettleActionsTest.java @@ -1,6 +1,8 @@ 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; @@ -55,12 +57,74 @@ void settlingASectionShowsItsSharedHunksSettledInTheOtherSection() { + 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() { focusRail(); - assertEquals(SessionReviewView.SettleUnit.SECTION, view.settleUnit()); + assertEquals("Approve (section)", approveButtonText()); focusDiffColumn(); - assertEquals(SessionReviewView.SettleUnit.HUNK, view.settleUnit()); + WaitForAsyncUtils.waitForFxEvents(); + assertEquals("Approve (hunk)", approveButtonText()); + } + + 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/ReviewViewFixture.java b/app/src/test/java/app/drydock/ui/review/ReviewViewFixture.java index 96fa838c..15e2cf68 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewViewFixture.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewViewFixture.java @@ -2,12 +2,15 @@ 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.stage.Stage; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -131,12 +134,58 @@ 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 (String text : hunkTexts) { + 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(1), text)))); + 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/SectionStatesTest.java b/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java index 78d1724f..43c4d9e6 100644 --- a/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java +++ b/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java @@ -306,25 +306,101 @@ void digestOfAnchorHunkIsEmptyForAnUnresolvableSection() { } @Test - void fileOfIsTheAnchorHunksFile() { + void currentFileOfIsTheAnchorHunksFileWhenNothingIsSelected() { SectionStates.Board board = overlapping(); ReviewIntent section2 = board.sections().get(1); - assertEquals(Optional.of(GUARDS_H), sections.fileOf(board, section2)); + 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}); {@link SectionStates#fileOf} falls back - * to the first file of the diff rather than answering nothing. + * 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 fileOfFallsBackToTheDiffsFirstFileWhenTheSectionNamesNone() { + 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.fileOf(board, board.sections().get(0))); + 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"); } /** @@ -345,6 +421,34 @@ void digestsOfFileIsEmptyForAFileNotInTheDiff() { 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())); + } + // ---- helpers ------------------------------------------------------------- /** Section ① covers both guards files; section ② covers guards.h again and profiler. */ From 8d4d305f74c4060eb474539ef9b4a3b4c149992f Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 07:27:05 +0200 Subject: [PATCH 024/113] Name the acting unit on the Approve button, not a label the floor hides "Approve intent" stayed on the button regardless of what a/r actually hit, and the acting-unit label this task first added to say so was, by design, one of the things dropped for room at the code column's floor -- so at 560px the only unit statement left on screen was the wrong one. The unit is now stated on the Approve/Request-changes buttons themselves ("Approve (hunk)", "Request changes (section)"), which are never dropped for width, so removing the separate label costs nothing and removes a whole fit surface rather than widening anything to keep it. The stale banner's own fit gap (Phase 1 gate) gets a real assertion too: assertNothingTruncated only ever looked at .button, so staleLabel -- a wrapText label with no minWidth -- could reflow silently underneath it. Measured, not asserted around: at the 560px floor the banner wraps to exactly two 17px lines rather than one, which is reported here rather than designed away, since nothing is actually clipped and the row is allowed to grow a line when a section goes stale. --- .../drydock/ui/review/ReviewVerdictBar.java | 90 ++++++------- .../ui/review/ReviewVerdictBarFitTest.java | 123 ++++++++++-------- 2 files changed, 116 insertions(+), 97 deletions(-) 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 eced0db8..82fd8b44 100644 --- a/app/src/main/java/app/drydock/ui/review/ReviewVerdictBar.java +++ b/app/src/main/java/app/drydock/ui/review/ReviewVerdictBar.java @@ -72,18 +72,15 @@ record StaleInfo(String oldBase, String newBase) { 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(); private final Label refusalLabel = new Label(); - /** - * Names the unit {@code a}/{@code r}/{@code u} act on right now (spec - * §9.6): a key whose target depends on focus has to say what it is - * about to do, or the reader is guessing. - */ - private final Label actingUnitLabel = 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"); @@ -120,6 +117,15 @@ record StaleInfo(String oldBase, String newBase) { 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; ReviewVerdictBar(Host host) { this.host = host; @@ -150,11 +156,9 @@ record StaleInfo(String oldBase, String newBase) { 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)); requestChangesButton.getStyleClass().add("review-verdict-action"); - requestChangesButton.setTooltip(new Tooltip("Request changes on this intent (r)")); requestChangesButton.setOnAction(e -> withIntent(host::requestChanges)); askAgentButton.getStyleClass().add("review-verdict-action"); @@ -181,8 +185,6 @@ record StaleInfo(String oldBase, String newBase) { staleLabel.getStyleClass().add("review-verdict-stale"); staleLabel.setWrapText(true); - actingUnitLabel.getStyleClass().add("review-verdict-unit"); - settledLabel.getStyleClass().add("review-verdict-settled"); refusalLabel.getStyleClass().add("review-verdict-refusal"); refusalLabel.setVisible(false); @@ -273,22 +275,25 @@ void showStale(Optional info) { } /** - * Names the unit {@code a}/{@code r}/{@code u} act on right now (spec - * §9.6): a key whose target depends on focus has to say what it is - * about to do. + * 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) { - actingUnitLabel.setText(switch (unit) { - case HUNK -> "acts on: hunk"; - case SECTION -> "acts on: section"; - case FILE -> "acts on: file"; - }); - actingUnitLabel.setTooltip(new Tooltip( - "a, r and u act on the unit named here -- click into the diff column for a " - + "single hunk, the rail for the whole section, or use ⇧A / ⇧R for the file")); + this.actingUnit = unit; render(); } + /** The word the unit reads as on a button: "Approve (hunk)", "Request changes (section)". */ + private static String unitWord(SessionReviewView.SettleUnit unit) { + return switch (unit) { + case HUNK -> "hunk"; + case SECTION -> "section"; + case FILE -> "file"; + }; + } + /** * 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 @@ -343,22 +348,30 @@ private void render() { staleLabel.setText("⚠ approved against base " + shortSha(stale.get().oldBase()) + " · base is now " + shortSha(stale.get().newBase())); actionRow.getChildren().setAll(previousButton, nextButton, intentLabel, - actingUnitLabel, staleLabel, confirmStillGoodButton, reReviewButton, - actionSpacer, navHint); + 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-" + decision.get().wireName()); actionRow.getChildren().setAll(previousButton, nextButton, intentLabel, - actingUnitLabel, settledLabel, undoButton, actionSpacer, navHint); + settledLabel, undoButton, actionSpacer, navHint); } else { + // 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 + ")"); + approveButton.setTooltip(new Tooltip("Approve this " + unit + " (a)")); + requestChangesButton.setText("Request changes (" + unit + ")"); + requestChangesButton.setTooltip(new Tooltip("Request changes on this " + unit + " (r)")); refusalLabel.setText("⚠ a blocking finding is still open"); refusalLabel.setVisible(blocked); refusalLabel.setManaged(blocked); approveButton.pseudoClassStateChanged( javafx.css.PseudoClass.getPseudoClass("refused"), blocked); actionRow.getChildren().setAll(previousButton, nextButton, intentLabel, - actingUnitLabel, approveButton, requestChangesButton, askAgentButton, + approveButton, requestChangesButton, askAgentButton, refusalLabel, actionSpacer, navHint); } fitActionRow(actionRow.getWidth()); @@ -410,32 +423,19 @@ private void fitActionRow(double width) { + INTENT_LABEL_MIN; int slots = 0; for (javafx.scene.Node child : actionRow.getChildren()) { - if (!child.isManaged() && child != navHint && child != actingUnitLabel) { + if (!child.isManaged() && child != navHint) { continue; } slots++; - if (child == actionSpacer || child == navHint || child == intentLabel - || child == actingUnitLabel) { + if (child == actionSpacer || child == navHint || child == intentLabel) { continue; } needed += child.prefWidth(-1); } needed += actionRow.getSpacing() * Math.max(0, slots - 1); - - // The acting-unit label is checked first, and against the actions - // ALONE: it says what a/r/u are about to do, which matters more than - // navHint's "n jumps to the next" progress note, so a hint that - // would otherwise fit does not get to crowd it out. Never partially - // shown -- like the hint, it is either fully there or not at all. - double spacing = actionRow.getSpacing(); - boolean roomForUnit = width - needed >= actingUnitLabel.prefWidth(-1) + spacing; - actingUnitLabel.setVisible(roomForUnit); - actingUnitLabel.setManaged(roomForUnit); - - double afterUnit = needed + (roomForUnit ? actingUnitLabel.prefWidth(-1) + spacing : 0); - boolean roomForHint = width - afterUnit >= navHint.prefWidth(-1); - navHint.setVisible(roomForHint); - navHint.setManaged(roomForHint); + boolean room = width - needed >= navHint.prefWidth(-1); + navHint.setVisible(room); + navHint.setManaged(room); } /** Test-only: whether approval is currently being refused. */ 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 ac9f40c6..43a1079a 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewVerdictBarFitTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewVerdictBarFitTest.java @@ -3,7 +3,6 @@ import app.drydock.review.ReviewIntent; import app.drydock.review.ReviewVerdict; -import javafx.scene.Node; import javafx.scene.Scene; import javafx.scene.control.Button; import javafx.scene.control.Label; @@ -122,9 +121,21 @@ void theHintIsBackAsSoonAsThereIsRoomForIt() { } /** - * The stale banner (spec §9.2) swaps in two more buttons, "Confirm still - * good" and "Re-review"; the Phase 1 gate named it -- alongside the - * rail's two Task 6 additions -- as new UI with no fit coverage. + * 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: {@code assertNothingTruncated} only ever looked at {@code + * .button}, so {@code staleLabel} -- a {@code wrapText} label with no + * {@code minWidth} -- could reflow silently and the assertion would + * never see it. + * + *

Measured (see {@link #assertStaleLabelWrapsCleanlyRatherThanClipping}): + * 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, 34px total. Reported here + * rather than designed around: nothing was truncated (wrap, not + * ellipsis, so no character is lost), but the floor is real and the row + * genuinely gets taller when a section is stale at that width.

*/ @Test void theStaleBannerFitsAtTheCodeColumnFloor() { @@ -136,66 +147,74 @@ void theStaleBannerFitsAtTheCodeColumnFloor() { WaitForAsyncUtils.waitForFxEvents(); assertNothingTruncated(); + assertStaleLabelWrapsCleanlyRatherThanClipping(); } /** - * The acting-unit label (spec §9.6) is the other new element the gate - * named: a key whose target depends on focus has to say what it is - * about to do. At the code-column floor there is genuinely no room for - * it alongside the primary actions and the title -- confirmed by hand: - * widening {@code CODE_MIN_WIDTH} to make room was rejected in favour of - * the same rule the progress hint already follows. It drops rather than - * clips, and reappears as soon as there is room; it must never show a - * half-cut word. + * Not "fits on one line" -- it measurably does not, at the floor (see + * the test's javadoc). What this guards is the OTHER failure mode: + * wrapping past two lines, or collapsing to the near-zero-width, + * one-character-per-line pathology this codebase has shipped before + * (see {@code ReviewIntentRailCardHeightTest}'s history). Two lines of + * 17px is a legible banner; a dozen lines of single characters is not, + * and this is what would catch the difference. */ - @Test - void theActingUnitLabelDropsRatherThanClipsAtTheFloor() { - show(intent(2, "drydock/review · 4 files"), Optional.empty()); - interact(() -> bar.showActingUnit(SessionReviewView.SettleUnit.SECTION)); - WaitForAsyncUtils.waitForFxEvents(); - interact(() -> bar.getScene().getRoot().layout()); - WaitForAsyncUtils.waitForFxEvents(); - - assertFalse(actingUnitLabelShowing(), - "at the floor there is no room for it; it must hide, never show it clipped"); + private void assertStaleLabelWrapsCleanlyRatherThanClipping() { + double[] oneLineHeight = new double[1]; + double[] actualHeight = new double[1]; + interact(() -> { + Label label = (Label) lookup(".review-verdict-stale").query(); + oneLineHeight[0] = label.prefHeight(-1); + actualHeight[0] = label.getHeight(); + }); + assertTrue(actualHeight[0] <= oneLineHeight[0] * 2 + 1, + "the stale banner wrapped to roughly " + Math.round(actualHeight[0] / oneLineHeight[0]) + + " lines at the " + (int) RailLayout.CODE_MIN_WIDTH + "px floor (one line is " + + Math.round(oneLineHeight[0]) + "px, rendered height is " + + Math.round(actualHeight[0]) + "px)"); } - /** Same label, back and fully legible as soon as the bar has room, as the hint already is. */ + /** + * 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 theActingUnitLabelIsBackAsSoonAsThereIsRoomForIt() { - show(intent(2, "drydock/review · 4 files"), Optional.empty()); - interact(() -> bar.showActingUnit(SessionReviewView.SettleUnit.HUNK)); - WaitForAsyncUtils.waitForFxEvents(); - assertFalse(actingUnitLabelShowing(), "at the floor the label has to go, same as the hint"); - - interact(() -> bar.getScene().getWindow().setWidth(1400)); - WaitForAsyncUtils.waitForFxEvents(); - interact(() -> bar.getScene().getRoot().layout()); - WaitForAsyncUtils.waitForFxEvents(); - - assertTrue(actingUnitLabelShowing(), "a wide bar shows the acting-unit label again"); - assertLabelNotClipped(".review-verdict-unit"); + 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(); + } } - private boolean actingUnitLabelShowing() { - boolean[] showing = new boolean[1]; - interact(() -> showing[0] = lookup(".review-verdict-unit").queryAll().stream() - .anyMatch(Node::isManaged)); - return showing[0]; + private static String unitWord(SessionReviewView.SettleUnit unit) { + return switch (unit) { + case HUNK -> "hunk"; + case SECTION -> "section"; + case FILE -> "file"; + }; } - private void assertLabelNotClipped(String selector) { - double[] width = new double[1]; - double[] pref = new double[1]; + private String approveButtonText() { String[] text = new String[1]; - interact(() -> { - Label label = (Label) lookup(selector).query(); - width[0] = label.getWidth(); - pref[0] = label.prefWidth(-1); - text[0] = label.getText(); - }); - assertTrue(width[0] + 0.5 >= pref[0], "'" + text[0] + "' got " + Math.round(width[0]) - + "px of " + Math.round(pref[0]) + "px it wanted"); + 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 -------------------------------------------------------- From de7e947c4695718b417428a62e1d522d4ad37055 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 08:08:16 +0200 Subject: [PATCH 025/113] Read the unit pressed, not what focus becomes, and free the listener on close Ruling reversed: HUNK mode never actually settles the hunk under the gutter's cursor. Tracing a real click confirmed it -- a completed gutter click opens the comment composer and moves keyboard focus into its text field, which the existing TextInputControl guard then makes a/r type into rather than trigger, and closing that composer clears the gutter selection right along with it. Every real reader path leaves HUNK mode acting on the section's first unread hunk, the option the previous ruling rejected in favour of a cursor that turns out not to be reachable this way. The button and its tooltip now say "next unread hunk" rather than "hunk," and so does the shortcuts overlay; SectionStates.digestOfCurrentHunk keeps trying the selection first regardless, since it costs nothing and becomes live for free if the composer coupling is ever loosened. A second bug surfaced once the button named its unit: pressing a focusable Button requests focus on press, so clicking "Approve (next unread hunk)" moved Scene focus off the diff column and onto the button itself before release -- which flipped settleUnit() to SECTION mid-press and settled the whole section on release, silently disagreeing with what the reader just read on the label. ReviewVerdictBar.Host.approve/requestChanges now take the acting unit as a parameter, captured by a MOUSE_PRESSED filter at the moment of the real press (falling back to the live unit for a keyboard activation or a test's Button.fire(), neither of which presses at all). Caught by driving the button through a real press-then-release rather than fire(), which is what let this ship the first time. The focus-owner listener SessionReviewView added to watch for this also turned out to leak: the Scene it attaches to is built once for the whole application, so a listener never removed keeps every session's review board -- diff column included -- reachable for the process's life, and re-renders every one of their verdict bars on every focus change anywhere in the app. Held as a field now so close() (which already detaches the MCP panel and stops the rail's collapse timeline) can remove the exact same instance it was added with. --- .../java/app/drydock/ui/ShortcutsOverlay.java | 6 +- .../drydock/ui/review/ReviewVerdictBar.java | 83 ++++++++++-- .../drydock/ui/review/SessionReviewView.java | 71 ++++++++-- .../ui/review/ReviewSettleActionsTest.java | 28 +++- .../ReviewVerdictBarNavigationTest.java | 8 +- .../ui/review/SessionReviewViewCloseTest.java | 121 ++++++++++++++++++ 6 files changed, 292 insertions(+), 25 deletions(-) create mode 100644 app/src/test/java/app/drydock/ui/review/SessionReviewViewCloseTest.java diff --git a/app/src/main/java/app/drydock/ui/ShortcutsOverlay.java b/app/src/main/java/app/drydock/ui/ShortcutsOverlay.java index b3cc091b..b1d87ee6 100644 --- a/app/src/main/java/app/drydock/ui/ShortcutsOverlay.java +++ b/app/src/main/java/app/drydock/ui/ShortcutsOverlay.java @@ -51,9 +51,9 @@ private record Section(String title, String[][] shortcuts) { } {"Show or hide unchanged lines", "c"}, {"Previous / next intent", "[ / ]"}, {"Next unsettled intent", "n"}, - {"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 (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", "⏎"}, 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 82fd8b44..6ae8c935 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,9 +30,19 @@ 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); + /** + * {@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); + void requestChanges(ReviewIntent intent, SessionReviewView.SettleUnit unit); /** "Ask the agent to fix it" -- hands the intent's findings to the bound session. */ void askAgentToFix(ReviewIntent intent); @@ -126,6 +137,18 @@ record StaleInfo(String oldBase, String newBase) { * 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; @@ -156,10 +179,19 @@ record StaleInfo(String oldBase, String newBase) { nextButton.setOnAction(e -> host.nextIntent()); approveButton.getStyleClass().addAll("review-verdict-action", "primary"); - 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.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")); @@ -230,6 +262,19 @@ private void withIntent(java.util.function.Consumer action) { } } + /** + * 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. * @@ -285,10 +330,21 @@ void showActingUnit(SessionReviewView.SettleUnit unit) { render(); } - /** The word the unit reads as on a button: "Approve (hunk)", "Request changes (section)". */ + /** + * 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 -> "hunk"; + case HUNK -> "next unread hunk"; case SECTION -> "section"; case FILE -> "file"; }; @@ -362,9 +418,20 @@ private void render() { // never dropped for width, unlike a separate label would be. String unit = unitWord(actingUnit); approveButton.setText("Approve (" + unit + ")"); - approveButton.setTooltip(new Tooltip("Approve this " + unit + " (a)")); requestChangesButton.setText("Request changes (" + unit + ")"); - requestChangesButton.setTooltip(new Tooltip("Request changes on this " + unit + " (r)")); + // 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("⚠ a blocking finding is still open"); refusalLabel.setVisible(blocked); refusalLabel.setManaged(blocked); 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 bf34ac97..ebe8599b 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -14,6 +14,7 @@ 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; @@ -253,6 +254,20 @@ enum SettleUnit { 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; @@ -366,6 +381,8 @@ public SessionReviewView(Host host, DiffService diffService, McpActivityLog acti 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 @@ -432,10 +449,20 @@ public SessionReviewView(Host host, DiffService diffService, McpActivityLog acti // 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( - (o, oldOwner, newOwner) -> verdictBar.showActingUnit(settleUnit())); + newScene.focusOwnerProperty().addListener(focusOwnerListener); } }); widthProperty().addListener((obs, old, width) -> applyResponsiveLayout(width.doubleValue())); @@ -901,11 +928,20 @@ private List digestsOf(ReviewIntent intent) { /** * The digests {@code a}/{@code r}/{@code u} act on for {@code intent} - * right now -- see {@link SectionStates#digestsForAction}. None without - * a diff to derive them from. + * 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, boolean wholeFile) { - return board().map(b -> sections.digestsForAction(b, intent, settleUnit(), wholeFile, + private List digestsForAction(ReviewIntent intent, SettleUnit unit, boolean wholeFile) { + return board().map(b -> sections.digestsForAction(b, intent, unit, wholeFile, diffColumn.currentLineSelection())) .orElse(List.of()); } @@ -1117,15 +1153,15 @@ 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) { + public void approve(ReviewIntent intent, SettleUnit unit) { selectedScope().ifPresent(scope -> host.setVerdict(scope, intent, - digestsForAction(intent, false), Optional.of(ReviewVerdict.Decision.APPROVED))); + digestsForAction(intent, unit, false), Optional.of(ReviewVerdict.Decision.APPROVED))); } @Override - public void requestChanges(ReviewIntent intent) { + public void requestChanges(ReviewIntent intent, SettleUnit unit) { selectedScope().ifPresent(scope -> host.setVerdict(scope, intent, - digestsForAction(intent, false), Optional.of(ReviewVerdict.Decision.CHANGES))); + digestsForAction(intent, unit, false), Optional.of(ReviewVerdict.Decision.CHANGES))); } @Override @@ -1408,7 +1444,7 @@ private void verdictAction(ReviewVerdict.Decision decision, boolean wholeFile) { if (scope.isEmpty() || intent.isEmpty()) { return; } - List digests = digestsForAction(intent.get(), wholeFile); + List digests = digestsForAction(intent.get(), settleUnit(), wholeFile); if (digests.isEmpty()) { return; } @@ -1645,12 +1681,25 @@ 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() { mcpPanel.ifPresent(ReviewMcpActivityPanel::detach); intentRail.stopWidthAnimation(); + if (getScene() != null) { + getScene().focusOwnerProperty().removeListener(focusOwnerListener); + } } // ---- diagnostics -------------------------------------------------------- diff --git a/app/src/test/java/app/drydock/ui/review/ReviewSettleActionsTest.java b/app/src/test/java/app/drydock/ui/review/ReviewSettleActionsTest.java index 2c0f337e..5f59e4a5 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewSettleActionsTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewSettleActionsTest.java @@ -114,7 +114,33 @@ void theBarNamesTheUnitAnActionWillHit() { focusDiffColumn(); WaitForAsyncUtils.waitForFxEvents(); - assertEquals("Approve (hunk)", approveButtonText()); + 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() { + 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() { 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 ca14551c..07295f11 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewVerdictBarNavigationTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewVerdictBarNavigationTest.java @@ -29,8 +29,12 @@ 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 approve(ReviewIntent intent, SessionReviewView.SettleUnit unit) { + calls.add("approve"); + } + @Override public void requestChanges(ReviewIntent intent, SessionReviewView.SettleUnit unit) { + calls.add("changes"); + } @Override public void askAgentToFix(ReviewIntent intent) { calls.add("ask"); } @Override public void undo(ReviewIntent intent) { calls.add("undo"); } @Override public void confirmStillGood(ReviewIntent intent) { calls.add("confirm"); } 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..37613ab7 --- /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.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()); + stage.setScene(scene); + stage.show(); + 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))))); + } +} From f1420f25dbdeb74a3ab85425633ea39783192cd3 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 08:08:36 +0200 Subject: [PATCH 026/113] A stale hunk stops counting one layer up too, not just in the bar settledHunkCount already excluded a stale verdict from the verdict bar's global progress line; SectionStates.stateOf did not exclude the same verdict from a section's OWN count, so a rail card could read fully settled ("2/2 hunks") while the bar's line, over the identical hunks, read one short of it. Same rule now applies at both layers: a hunk whose base has moved in a way that could matter does not count toward either count. The section's DECISION is untouched by this -- it still merges the stale verdict, since staleness puts the base in question, never the decision itself. --- .../app/drydock/ui/review/SectionStates.java | 23 ++++++++++++++++--- .../ui/review/ReviewHunkProgressTest.java | 20 ++++++++++++++++ .../drydock/ui/review/SectionStatesTest.java | 22 ++++++++++++++++++ 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/app/drydock/ui/review/SectionStates.java b/app/src/main/java/app/drydock/ui/review/SectionStates.java index a719c254..5c11d9b3 100644 --- a/app/src/main/java/app/drydock/ui/review/SectionStates.java +++ b/app/src/main/java/app/drydock/ui/review/SectionStates.java @@ -65,8 +65,15 @@ enum Staleness { /** * One section's rendered state, derived from its hunks (spec §9.1). * - * @param decision what its hunks merge to, empty while any is unread - * @param settledHunks how many of its hunks carry a verdict + * @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 totalHunks how many hunks it covers at all * @param staleness whether a base move since a verdict could have changed * what was approved @@ -286,7 +293,6 @@ SectionState stateOf(Board board, ReviewIntent intent) { Optional verdict = host.verdict(board.scope(), digest); perHunk.add(verdict); if (verdict.isPresent()) { - settled++; // 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); @@ -294,6 +300,17 @@ SectionState stateOf(Board board, ReviewIntent intent) { || (hunk == Staleness.UNKNOWN && staleness == Staleness.FRESH)) { staleness = hunk; } + // 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. + if (hunk != Staleness.MOVED) { + settled++; + } collectSharingSections(board, digest, intent, elsewhere); } } diff --git a/app/src/test/java/app/drydock/ui/review/ReviewHunkProgressTest.java b/app/src/test/java/app/drydock/ui/review/ReviewHunkProgressTest.java index 248b7325..1b830e1d 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewHunkProgressTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewHunkProgressTest.java @@ -314,6 +314,26 @@ private String navHintText() { .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 ------------------------------- /** diff --git a/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java b/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java index 43c4d9e6..0b391e9a 100644 --- a/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java +++ b/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java @@ -176,6 +176,28 @@ void aBaseMoveTouchingTheSectionIsMoved() { 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"); + } + /** A move that provably could not matter must not spend the reader's attention. */ @Test void aBaseMoveElsewhereIsFresh() { From 58cd8c1d78a7425cfad1804690b64c097a2a4ece Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 08:08:49 +0200 Subject: [PATCH 027/113] Fit-check the bar's own height and the stale label, not just its buttons Both were named in the original ruling and neither was actually done: assertNothingTruncated only ever looked at .button, so the stale banner's wrapText label could reflow silently underneath it, and nothing measured the bar's own height at the 560px floor at all. Both are folded into the one helper now, so every existing caller gets them for free. Measured height must be read via prefHeight, not getHeight: the bar is the test Scene's root, and a Scene resizes its root to fill its own fixed dimensions regardless of content, so getHeight() always reported the Scene's own 200px and the assertion would have passed without measuring anything -- the same trap the width check beside it already guards against. --- .../ui/review/ReviewVerdictBarFitTest.java | 99 +++++++++++-------- 1 file changed, 58 insertions(+), 41 deletions(-) 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 43a1079a..6c23eaf2 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewVerdictBarFitTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewVerdictBarFitTest.java @@ -37,8 +37,8 @@ class ReviewVerdictBarFitTest extends ApplicationTest { @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 approve(ReviewIntent intent, SessionReviewView.SettleUnit unit) { } + @Override public void requestChanges(ReviewIntent intent, SessionReviewView.SettleUnit unit) { } @Override public void askAgentToFix(ReviewIntent intent) { } @Override public void undo(ReviewIntent intent) { } @Override public void confirmStillGood(ReviewIntent intent) { } @@ -124,18 +124,19 @@ void theHintIsBackAsSoonAsThereIsRoomForIt() { * 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: {@code assertNothingTruncated} only ever looked at {@code - * .button}, so {@code staleLabel} -- a {@code wrapText} label with no - * {@code minWidth} -- could reflow silently and the assertion would - * never see it. + * 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 (see {@link #assertStaleLabelWrapsCleanlyRatherThanClipping}): - * 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, 34px total. Reported here - * rather than designed around: nothing was truncated (wrap, not - * ellipsis, so no character is lost), but the floor is real and the row - * genuinely gets taller when a section is stale at that width.

+ *

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() { @@ -147,31 +148,6 @@ void theStaleBannerFitsAtTheCodeColumnFloor() { WaitForAsyncUtils.waitForFxEvents(); assertNothingTruncated(); - assertStaleLabelWrapsCleanlyRatherThanClipping(); - } - - /** - * Not "fits on one line" -- it measurably does not, at the floor (see - * the test's javadoc). What this guards is the OTHER failure mode: - * wrapping past two lines, or collapsing to the near-zero-width, - * one-character-per-line pathology this codebase has shipped before - * (see {@code ReviewIntentRailCardHeightTest}'s history). Two lines of - * 17px is a legible banner; a dozen lines of single characters is not, - * and this is what would catch the difference. - */ - private void assertStaleLabelWrapsCleanlyRatherThanClipping() { - double[] oneLineHeight = new double[1]; - double[] actualHeight = new double[1]; - interact(() -> { - Label label = (Label) lookup(".review-verdict-stale").query(); - oneLineHeight[0] = label.prefHeight(-1); - actualHeight[0] = label.getHeight(); - }); - assertTrue(actualHeight[0] <= oneLineHeight[0] * 2 + 1, - "the stale banner wrapped to roughly " + Math.round(actualHeight[0] / oneLineHeight[0]) - + " lines at the " + (int) RailLayout.CODE_MIN_WIDTH + "px floor (one line is " - + Math.round(oneLineHeight[0]) + "px, rendered height is " - + Math.round(actualHeight[0]) + "px)"); } /** @@ -200,7 +176,7 @@ void theApproveButtonNamesTheUnitAndFitsForEveryUnitAtTheFloor() { private static String unitWord(SessionReviewView.SettleUnit unit) { return switch (unit) { - case HUNK -> "hunk"; + case HUNK -> "next unread hunk"; case SECTION -> "section"; case FILE -> "file"; }; @@ -244,12 +220,36 @@ private void show(ReviewIntent intent, Optional decision * 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()); + 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(RailLayout.CODE_MIN_WIDTH); + }); assertTrue(width[0] <= RailLayout.CODE_MIN_WIDTH + 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) RailLayout.CODE_MIN_WIDTH + "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() @@ -262,8 +262,25 @@ private void assertNothingTruncated() { + Math.round(button.getWidth()) + " of " + Math.round(wanted)); } })); + // 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)"); + } + })); assertTrue(squeezed.isEmpty(), "at " + (int) RailLayout.CODE_MIN_WIDTH - + "px these controls were truncated: " + squeezed); + + "px these controls were truncated or mis-wrapped: " + squeezed); } private static ReviewIntent intent(int number, String title) { From b573f459bc2bf2e75f0b9cef4ca26de236bda1b6 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 08:31:08 +0200 Subject: [PATCH 028/113] 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. --- app/build.gradle.kts | 18 ++++ .../app/drydock/review/GrammarRegistry.java | 102 ++++++++++++++++++ .../drydock/review/GrammarRegistryTest.java | 42 ++++++++ 3 files changed, 162 insertions(+) create mode 100644 app/src/main/java/app/drydock/review/GrammarRegistry.java create mode 100644 app/src/test/java/app/drydock/review/GrammarRegistryTest.java 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/review/GrammarRegistry.java b/app/src/main/java/app/drydock/review/GrammarRegistry.java new file mode 100644 index 00000000..3cb2f576 --- /dev/null +++ b/app/src/main/java/app/drydock/review/GrammarRegistry.java @@ -0,0 +1,102 @@ +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(); + } + } +} 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..5d3343c0 --- /dev/null +++ b/app/src/test/java/app/drydock/review/GrammarRegistryTest.java @@ -0,0 +1,42 @@ +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()); + } +} From 888cff587377081669873adbb75263f7ed719f5a Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 08:39:37 +0200 Subject: [PATCH 029/113] A broken grammar class only falls back for its own extensions Class.forName succeeding is not enough to prove a grammar loads: a ReflectiveOperationException other than ClassNotFoundException means one grammar's shape changed (constructor visibility, missing no-arg constructor), not that the native library is gone. Folding it into the same catch as UnsatisfiedLinkError latched every language to lexical for one class's problem -- exactly the silent, process-wide failure this registry exists to prevent. Now a per-class reflective-shape failure logs once, naming the class, and only that class's extensions fall back; the native latch is reserved for UnsatisfiedLinkError and RuntimeException, the two shapes an actual failed native load can take. A parameterized test exercises every shipped extension end-to-end, and a companion test asserts nativeAvailable() stays true afterward -- proving a per-class failure cannot take the other languages down with it. --- .../app/drydock/review/GrammarRegistry.java | 25 ++++++++++--- .../drydock/review/GrammarRegistryTest.java | 35 +++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/app/drydock/review/GrammarRegistry.java b/app/src/main/java/app/drydock/review/GrammarRegistry.java index 3cb2f576..08b56f27 100644 --- a/app/src/main/java/app/drydock/review/GrammarRegistry.java +++ b/app/src/main/java/app/drydock/review/GrammarRegistry.java @@ -3,9 +3,11 @@ 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; @@ -48,6 +50,8 @@ public final class GrammarRegistry { } 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() { @@ -87,10 +91,23 @@ private static Optional load(String className) { // 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. + } 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 " diff --git a/app/src/test/java/app/drydock/review/GrammarRegistryTest.java b/app/src/test/java/app/drydock/review/GrammarRegistryTest.java index 5d3343c0..c15f6cc9 100644 --- a/app/src/test/java/app/drydock/review/GrammarRegistryTest.java +++ b/app/src/test/java/app/drydock/review/GrammarRegistryTest.java @@ -1,6 +1,8 @@ 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; @@ -39,4 +41,37 @@ void extensionMatchingIsCaseInsensitive() { 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()); + } } From 7a98e88bbbe11127eb6f36baf32935f2a6047968 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 08:54:27 +0200 Subject: [PATCH 030/113] 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. --- .../java/app/drydock/review/SymbolScan.java | 206 ++++++++++++++++++ .../app/drydock/review/SymbolScanTest.java | 83 +++++++ 2 files changed, 289 insertions(+) create mode 100644 app/src/main/java/app/drydock/review/SymbolScan.java create mode 100644 app/src/test/java/app/drydock/review/SymbolScanTest.java 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..93356c53 --- /dev/null +++ b/app/src/main/java/app/drydock/review/SymbolScan.java @@ -0,0 +1,206 @@ +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.List; +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.

+ * + *

Blocking: parsing a line -- 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. */ + public record Symbol(String name, String path, 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. + */ + private static final List NAME_NODES = List.of( + "identifier", "type_identifier", "field_identifier", "simple_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"); + + 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 -- enough for + * "is this token introducing a name", the only question asked here, and + * it avoids reconstructing whole files from a diff. + * + *

A fresh {@link TSParser} (and the {@link TSTree} it returns) per + * line 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 List parsed(TSLanguage language, String path, String text, + boolean changed) { + try { + TSParser parser = new TSParser(); + parser.setLanguage(language); + TSTree tree = parser.parseString(null, text); + byte[] utf8 = text.getBytes(StandardCharsets.UTF_8); + List symbols = new ArrayList<>(); + walk(tree.getRootNode(), utf8, path, changed, symbols); + return symbols; + } 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. + return lexical(path, text, changed); + } + } + + /** + * 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, byte[] utf8, String path, boolean changed, + 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, utf8, child, path, true, changed); + } else { + walk(child, utf8, path, changed, out); + } + } + return; + } + if (isNameNode(node)) { + addSymbol(out, utf8, node, path, false, changed); + return; + } + for (int i = 0; i < node.getChildCount(); i++) { + walk(node.getChild(i), utf8, path, changed, out); + } + } + + private static boolean isNameNode(TSNode node) { + return NAME_NODES.contains(node.getType()); + } + + /** + * {@code node}'s text, sliced from the line's own UTF-8 bytes rather + * than {@code String.substring} on the original line. {@link + * TSNode#getStartByte()}/{@link TSNode#getEndByte()} are UTF-8 BYTE + * offsets (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) -- + * slicing the {@code String} by char index would misalign, or throw, + * for any line with a multi-byte character before the token. + */ + private static void addSymbol(List out, byte[] utf8, TSNode node, String path, + boolean declaration, boolean changed) { + String name = new String(utf8, node.getStartByte(), node.getEndByte() - node.getStartByte(), + StandardCharsets.UTF_8); + if (SymbolWords.isSymbol(name)) { + out.add(new Symbol(name, path, declaration, changed)); + } + } + + 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; + } +} 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..c08abda9 --- /dev/null +++ b/app/src/test/java/app/drydock/review/SymbolScanTest.java @@ -0,0 +1,83 @@ +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)); + } +} From 95b31283a9e1090ed26629c9bbff93ec8ad5d7e4 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 09:05:08 +0200 Subject: [PATCH 031/113] Narrow the tree-sitter fallback to the calls that can actually fail --- .../java/app/drydock/review/SymbolScan.java | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/app/drydock/review/SymbolScan.java b/app/src/main/java/app/drydock/review/SymbolScan.java index 93356c53..b267e3e7 100644 --- a/app/src/main/java/app/drydock/review/SymbolScan.java +++ b/app/src/main/java/app/drydock/review/SymbolScan.java @@ -114,21 +114,28 @@ public static List of(UnifiedDiff.FileDiff file) { */ private static List parsed(TSLanguage language, String path, String text, boolean changed) { + TSTree tree; try { TSParser parser = new TSParser(); parser.setLanguage(language); - TSTree tree = parser.parseString(null, text); - byte[] utf8 = text.getBytes(StandardCharsets.UTF_8); - List symbols = new ArrayList<>(); - walk(tree.getRootNode(), utf8, path, changed, symbols); - return symbols; + tree = parser.parseString(null, 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. + // + // 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. return lexical(path, text, changed); } + byte[] utf8 = text.getBytes(StandardCharsets.UTF_8); + List symbols = new ArrayList<>(); + walk(tree.getRootNode(), utf8, path, changed, symbols); + return symbols; } /** From 60881ede9fcbdbb472a982d0e33b6ae53d44898b Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 09:10:20 +0200 Subject: [PATCH 032/113] 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. The use itself must also sit on a changed line, not just fall inside the diff's context window. A context line is pulled into a hunk only because it sits near some unrelated edit in the same file; treating it as a real reference would mint an edge from a line nobody touched, purely by proximity to noise. Declarations were already restricted to changed lines for the same reason -- this keeps both ends of an edge answerable to an actual change, not incidental surrounding text. 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. --- .../java/app/drydock/review/ChangeGraph.java | 145 ++++++++++++++++++ .../app/drydock/review/ChangeGraphTest.java | 80 ++++++++++ 2 files changed, 225 insertions(+) create mode 100644 app/src/main/java/app/drydock/review/ChangeGraph.java create mode 100644 app/src/test/java/app/drydock/review/ChangeGraphTest.java 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..927440a9 --- /dev/null +++ b/app/src/main/java/app/drydock/review/ChangeGraph.java @@ -0,0 +1,145 @@ +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.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.

+ * + *

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 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 -- 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<>(); + 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()) { + // The use itself must be part of this change, not a context + // line pulled into the hunk window by an unrelated nearby + // edit -- otherwise an edge could be minted from a line + // nobody touched, coincidentally sitting near real changes + // in the same file. + if (!symbol.onChangedLine()) { + continue; + } + 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); + } + + /** 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)); + } + + /** 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())); + } + + private static SortedSet unmodifiable(SortedSet set) { + return Collections.unmodifiableSortedSet(set == null ? new TreeSet<>() : set); + } +} 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..85536ce3 --- /dev/null +++ b/app/src/test/java/app/drydock/review/ChangeGraphTest.java @@ -0,0 +1,80 @@ +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")); + } + + /** 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())); + } +} From d1a026a71650f557ab77e944d24a2d79cde89691 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 09:17:28 +0200 Subject: [PATCH 033/113] A use mints an edge wherever it sits in the diff window, not only on a changed line Reversing the changed-line restriction on uses that this class shipped with. The node set is already restricted to changed files, so the loose rule cannot drag in unrelated code -- it only connects files already under review together. "A declaration's behaviour changed without touching most of its call sites" is the single most common shape of the coupling this graph exists to surface, and 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. The declaration side keeps its changed-line requirement -- only a change can introduce a name worth resolving to. The original changed-line-for-uses reasoning was coherent on its own terms (both ends of an edge answerable to an actual change) and is kept in the class's history/report rather than discarded, in case a noisier graph downstream reopens the question. Added a fixture with a genuine CONTEXT line carrying the use, since every prior test's file() helper only emitted ADD lines and so could not discriminate between the two rules -- aContextLineUseStillMintsAnEdge fails under the changed-line-only rule and passes under this one, verified by toggling the guard back in locally before reverting it out again. Also closed two interface methods that had no coverage: declarationsIn(String) and changedDeclarations(), both asserted from aDeclarationIsFoundByName. --- .../java/app/drydock/review/ChangeGraph.java | 18 +++++++----- .../app/drydock/review/ChangeGraphTest.java | 29 +++++++++++++++++++ 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/app/drydock/review/ChangeGraph.java b/app/src/main/java/app/drydock/review/ChangeGraph.java index 927440a9..393bc80a 100644 --- a/app/src/main/java/app/drydock/review/ChangeGraph.java +++ b/app/src/main/java/app/drydock/review/ChangeGraph.java @@ -86,14 +86,16 @@ public static ChangeGraph of(UnifiedDiff diff) { Map> in = new TreeMap<>(); for (Map.Entry> entry : scans.entrySet()) { for (SymbolScan.Symbol symbol : entry.getValue()) { - // The use itself must be part of this change, not a context - // line pulled into the hunk window by an unrelated nearby - // edit -- otherwise an edge could be minted from a line - // nobody touched, coincidentally sitting near real changes - // in the same file. - if (!symbol.onChangedLine()) { - continue; - } + // 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. diff --git a/app/src/test/java/app/drydock/review/ChangeGraphTest.java b/app/src/test/java/app/drydock/review/ChangeGraphTest.java index 85536ce3..63e85931 100644 --- a/app/src/test/java/app/drydock/review/ChangeGraphTest.java +++ b/app/src/test/java/app/drydock/review/ChangeGraphTest.java @@ -66,6 +66,35 @@ void aDeclarationIsFoundByName() { 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")); } /** Determinism: iteration order is a property this graph must keep (spec §9.5). */ From 9b08c4f3848b479df337f961d75d61a067ea9c7b Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 09:25:31 +0200 Subject: [PATCH 034/113] 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. Verified the iterative Tarjan by hand against a chain, a two- and a three-node cycle, a cycle with a node hanging off it, two disjoint components, a self-loop and a diamond (the case that catches a wrong low-link merge) -- the brief's sketch was correct as written; only the inline java.util.Iterator/Set/LinkedHashSet references and two overlong signatures needed cleanup to match the file conventions elsewhere in this package. Also added the "dependency outside nodes" case explicitly: such an edge is ignored rather than rejected, since it cannot affect where any member of nodes lands and every given node still appears exactly once. --- .../main/java/app/drydock/review/Graphs.java | 165 ++++++++++++++++++ .../java/app/drydock/review/GraphsTest.java | 137 +++++++++++++++ 2 files changed, 302 insertions(+) create mode 100644 app/src/main/java/app/drydock/review/Graphs.java create mode 100644 app/src/test/java/app/drydock/review/GraphsTest.java 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..fe1ecf88 --- /dev/null +++ b/app/src/main/java/app/drydock/review/Graphs.java @@ -0,0 +1,165 @@ +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 ignored rather than rejected -- it cannot + * affect which unit any member of {@code nodes} lands in, so dropping it + * keeps the output contract (every node in {@code nodes} appears exactly + * once) rather than failing a caller for a fact about a node it never + * asked to place.

+ */ +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(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)) { + 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; + } +} 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..9106bfe4 --- /dev/null +++ b/app/src/test/java/app/drydock/review/GraphsTest.java @@ -0,0 +1,137 @@ +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())); + } + + // --- Hand-computed cases added to verify the iterative Tarjan (see + // task-11-report.md for the by-hand derivation of each expectation). --- + + @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 or w -> z: two independent chains, unrelated to each other. + 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 aDependencyOutsideTheNodeSetIsIgnoredRatherThanCorruptingTheResult() { + // "b" depends on "ghost", which never appears in nodes. Every given + // node still appears exactly once; the phantom edge is dropped. + List> result = order(Map.of("a", set(), "b", set("a", "ghost"))); + + assertEquals(List.of(List.of("a"), List.of("b")), result); + } +} From 687a2b3f9e76e8f10b407441b69188275aede5d1 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 09:35:42 +0200 Subject: [PATCH 035/113] Graphs rejects a dependency on a node outside the given set A dependsOn edge pointing at a node not in nodes was being silently dropped. That made "no such dependency" and "a dependency the caller forgot to include" produce identical output -- absent and broken must not look the same, a principle enforced elsewhere in this plan and no less true here just because today's only caller, ChangeGraph, happens not to produce the case. stronglyConnected now throws IllegalArgumentException naming the offending node and its target as soon as Tarjan's traversal reaches it, before Kahn's condensation ever runs; the condensation loop drops its now-redundant null checks since that invariant is already guaranteed by the time it executes. Also: inlined the by-hand derivation for each hand-computed test into its own comment, since the test file is the thing a future reader of this algorithm will actually open -- the session's report lives under a git-ignored directory and was a dangling reference from a committed test. And removed a sort of the SCC list in stronglyConnected that had no effect on output order: topologicalOrder only ever consults tieBreak through the Kahn ready-set comparator, never through the order components are returned in. --- .../main/java/app/drydock/review/Graphs.java | 25 +++++++++------ .../java/app/drydock/review/GraphsTest.java | 32 +++++++++++++------ 2 files changed, 38 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/app/drydock/review/Graphs.java b/app/src/main/java/app/drydock/review/Graphs.java index fe1ecf88..f7ff1010 100644 --- a/app/src/main/java/app/drydock/review/Graphs.java +++ b/app/src/main/java/app/drydock/review/Graphs.java @@ -26,11 +26,12 @@ * *

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 ignored rather than rejected -- it cannot - * affect which unit any member of {@code nodes} lands in, so dropping it - * keeps the output contract (every node in {@code nodes} appears exactly - * once) rather than failing a caller for a fact about a node it never - * asked to place.

+ * 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 { @@ -60,11 +61,14 @@ public static List> topologicalOrder( 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)) { - Integer from = componentOf.get(prerequisite); - Integer to = componentOf.get(node); - if (from == null || to == null || from.equals(to)) { + int from = componentOf.get(prerequisite); + int to = componentOf.get(node); + if (from == to) { continue; } prerequisites.get(to).add(from); @@ -126,7 +130,9 @@ private static List> stronglyConnected( if (children.hasNext()) { T child = children.next(); if (!nodes.contains(child)) { - continue; + throw new IllegalArgumentException( + "dependsOn(" + node + ") named " + child + + ", which is not in nodes"); } if (!index.containsKey(child)) { index.put(child, counter[0]); @@ -159,7 +165,6 @@ private static List> stronglyConnected( } } } - components.sort(Comparator.comparing(c -> c.get(0), tieBreak)); return components; } } diff --git a/app/src/test/java/app/drydock/review/GraphsTest.java b/app/src/test/java/app/drydock/review/GraphsTest.java index 9106bfe4..7c965d76 100644 --- a/app/src/test/java/app/drydock/review/GraphsTest.java +++ b/app/src/test/java/app/drydock/review/GraphsTest.java @@ -9,6 +9,8 @@ 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, @@ -64,8 +66,10 @@ void anEmptyGraphOrdersToNothing() { assertEquals(List.of(), order(Map.of())); } - // --- Hand-computed cases added to verify the iterative Tarjan (see - // task-11-report.md for the by-hand derivation of each expectation). --- + // --- 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() { @@ -94,7 +98,13 @@ void aCycleWithANodeHangingOffItKeepsTheHangerSeparate() { @Test void twoDisjointComponentsBothAppearOrderedByTheTieBreak() { - // x -> y or w -> z: two independent chains, unrelated to each other. + // 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"))); @@ -127,11 +137,15 @@ void aDiamondOrdersTheSharedBaseFirstWithoutMergingLowLinksWrongly() { } @Test - void aDependencyOutsideTheNodeSetIsIgnoredRatherThanCorruptingTheResult() { - // "b" depends on "ghost", which never appears in nodes. Every given - // node still appears exactly once; the phantom edge is dropped. - List> result = order(Map.of("a", set(), "b", set("a", "ghost"))); - - assertEquals(List.of(List.of("a"), List.of("b")), result); + 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()); } } From 21d7cc1809812d1e4fd3685e8629a08d0ee50bc4 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 09:54:08 +0200 Subject: [PATCH 036/113] Sections follow the code's structure, not its folders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. A section is now a unit of the file-level reference graph, ordered foundation-first, carrying the files its own members depend on, and titled by the changed symbol most referenced. Three edge kinds go in. References (from ChangeGraph) and includes are directed. 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 declaring no changed symbol of its own that exactly one changed file pulls in -- the counters.h case. Symmetric pairs therefore land in one unit, which is also why cycleWith is recomputed from the directed edges alone: guards.h and guards.cpp are one thing, not a dependency cycle, and saying otherwise is a lie a reviewer acts on. Sections overlap. A shared header appears in every section that needs it; 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 each caller gets its own section further down. A unit with no symbol to name it whose files another section already carries gets no card, because a card reading "src · 1 file" beside the sections already showing that file is the folder failure returning through the side door. 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. With no edges to consult, today's directory clustering survives untouched. An include is recognised by the shape of the line, not by a substring search over hunk text: only a line shaped like #include, import, use, mod or require counts, and the name it carries must match the whole of the other file's name. A substring test fires on comments and string literals, which would drag every file mentioning a header into that header's group. ChangeGraph gains filesReferencingSymbol. Fan-in per file is one number shared by every name a file declares, so ranking declarations by it titles the section with whichever name sorts first -- a statement about the alphabet, not about the code. The per-symbol count is already computed while the edges are minted; it was only being discarded. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/app/drydock/review/ChangeGraph.java | 22 +- .../java/app/drydock/review/Sections.java | 485 ++++++++++++++++++ .../app/drydock/review/ChangeGraphTest.java | 24 + .../java/app/drydock/review/SectionsTest.java | 284 ++++++++++ 4 files changed, 813 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/app/drydock/review/Sections.java create mode 100644 app/src/test/java/app/drydock/review/SectionsTest.java diff --git a/app/src/main/java/app/drydock/review/ChangeGraph.java b/app/src/main/java/app/drydock/review/ChangeGraph.java index 393bc80a..d75ea526 100644 --- a/app/src/main/java/app/drydock/review/ChangeGraph.java +++ b/app/src/main/java/app/drydock/review/ChangeGraph.java @@ -36,17 +36,20 @@ public final class ChangeGraph { private final Map fileByUniqueDeclaration; private final Map> referencesOut; private final Map> referencesIn; + private final Map> referencesInBySymbol; private ChangeGraph(SortedSet files, Map> declarationsByFile, Map fileByUniqueDeclaration, Map> referencesOut, - Map> referencesIn) { + Map> referencesIn, + Map> referencesInBySymbol) { this.files = files; this.declarationsByFile = declarationsByFile; this.fileByUniqueDeclaration = fileByUniqueDeclaration; this.referencesOut = referencesOut; this.referencesIn = referencesIn; + this.referencesInBySymbol = referencesInBySymbol; } /** @@ -84,6 +87,7 @@ public static ChangeGraph of(UnifiedDiff diff) { Map> out = new TreeMap<>(); Map> in = new TreeMap<>(); + Map> inBySymbol = 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 @@ -104,11 +108,13 @@ public static ChangeGraph of(UnifiedDiff diff) { } 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()); } } SortedSet files = new TreeSet<>(scans.keySet()); - return new ChangeGraph(files, declarationsByFile, unique, out, in); + return new ChangeGraph(files, declarationsByFile, unique, out, in, inBySymbol); } /** Every changed file, in this scope. */ @@ -131,6 +137,18 @@ 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)); 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..3c7480c5 --- /dev/null +++ b/app/src/main/java/app/drydock/review/Sections.java @@ -0,0 +1,485 @@ +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.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).

+ * + *

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
sections = 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); + Optional hub = hubOf(unit, graph); + sections.add(new Section( + title(ordered, hub), + ordered, + hunkIdsOf(byPath, ordered), + hub, + cyclesIn(unit, depends))); + } + return readable(sections); + } + + /** + * 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
sections) { + List
visible = new ArrayList<>(); + for (int index = 0; index < sections.size(); index++) { + Section section = sections.get(index); + if (section.hubSymbol().isEmpty() && coveredByAnother(sections, index)) { + continue; + } + visible.add(section); + } + return List.copyOf(visible); + } + + private static boolean coveredByAnother(List
sections, int index) { + List files = sections.get(index).files(); + for (int other = 0; other < sections.size(); other++) { + if (other == index || !sections.get(other).files().containsAll(files)) { + continue; + } + if (sections.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 ------------------------------------------------------------- + + /** + * The section's most-referenced changed symbol: what the section is + * about. Counted per symbol, not per declaring file -- a file's fan-in + * is the same number for every name it declares, so ranking by it would + * title the section with whichever name sorted first. + * + *

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.

+ * + *

A symbol nothing references wins only when it is the unit's single + * declaration -- then there is nothing to be wrong about. Several, all + * unreferenced, and there is no hub: the honest answer is no symbol at + * all, and {@link #title} falls back to the directory.

+ */ + private static Optional hubOf(List unit, ChangeGraph graph) { + SortedSet declarations = new TreeSet<>(); + for (String file : unit) { + declarations.addAll(graph.declarationsIn(file)); + } + String best = null; + int bestFanIn = -1; + // Ascending order plus a strict >: ties keep the alphabetically + // first name, so the title cannot depend on iteration order. + for (String symbol : declarations) { + int fanIn = graph.filesReferencingSymbol(symbol).size(); + if (fanIn > bestFanIn) { + best = symbol; + bestFanIn = fanIn; + } + } + return bestFanIn > 0 || declarations.size() == 1 + ? Optional.ofNullable(best) + : Optional.empty(); + } + + 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 is still the most + // specific true thing that can be said. + .orElseGet(() -> { + String directory = FallbackIntents.directoryOf(files.get(0)); + return (directory.isEmpty() ? "repository root" : directory) + " · " + 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/test/java/app/drydock/review/ChangeGraphTest.java b/app/src/test/java/app/drydock/review/ChangeGraphTest.java index 63e85931..ce5ceafa 100644 --- a/app/src/test/java/app/drydock/review/ChangeGraphTest.java +++ b/app/src/test/java/app/drydock/review/ChangeGraphTest.java @@ -97,6 +97,30 @@ void aContextLineUseStillMintsAnEdge() { 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"))); + } + /** Determinism: iteration order is a property this graph must keep (spec §9.5). */ @Test void everyExposedCollectionIsSorted() { 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..db31b8ca --- /dev/null +++ b/app/src/test/java/app/drydock/review/SectionsTest.java @@ -0,0 +1,284 @@ +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"); + } + + /** 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)))); + } +} From cd39bb50f1fe533d032dc2cebd6cefa762bec20f Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 10:20:16 +0200 Subject: [PATCH 037/113] Sections name what a card is about, and no two read the same MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run against this branch's own 54-file diff the rail failed four ways, all in the titling. Fourteen of twenty-five cards were named after a folder, three of them read identically, and the folder was the wrong one: the directory came off files.get(0), which is the first file in READING order and therefore a pulled-in foundation rather than a member, so a card could name a package holding none of the files it was about. A card is now named after its unit's most substantial own file when no symbol will do, never after a directory. The grouping is not directory-derived, so a directory title misdescribes it; a member file cannot. Units are disjoint, so no two cards can choose the same file, and a file NAME that repeats is re-spelled as the full path -- FallbackIntents guarantees two cards can never read the same, and a grouping is only useful if its entries can be told apart, so this guarantees it too rather than hoping. Fan-in alone picks the wrong symbol. On the same diff it titled cards hunk, isEmpty, has and files -- a loop variable and three one-line accessors, whose names simply recur in many files -- while BaseMove, HunkDigest and 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. A type-shaped name now outranks any member name and fan-in only breaks ties within a shape, which turns "hunk · 29 files" into "BaseMove · 29 files". Type-shaped means an initial capital, a naming convention rather than a fact from the parse tree; a codebase spelling types in lower case lands on the member name it would have picked anyway. A unit whose declarations are referenced by nothing at all can still be named: one declaration is unambiguous, a name matching the unit's own file name is that file's subject, and a lone type among functions is what the functions are for. That last rung is what titles guards.h plus guards.cpp JmpCtxScope rather than src, which is the case this class was commissioned to fix and which fan-in cannot reach, both declarations having no referrers in a two-file change. Measured after: twenty-five cards, twenty-five distinct titles, none naming a directory, no hunk outside a section, and identical output from the same diff in reversed file order. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/app/drydock/review/Sections.java | 254 ++++++++++++++---- .../java/app/drydock/review/SectionsTest.java | 77 ++++++ 2 files changed, 280 insertions(+), 51 deletions(-) diff --git a/app/src/main/java/app/drydock/review/Sections.java b/app/src/main/java/app/drydock/review/Sections.java index 3c7480c5..368418ab 100644 --- a/app/src/main/java/app/drydock/review/Sections.java +++ b/app/src/main/java/app/drydock/review/Sections.java @@ -12,6 +12,7 @@ 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; @@ -49,6 +50,15 @@ * 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 @@ -147,7 +157,7 @@ public static List

of(UnifiedDiff diff, ChangeGraph graph) { byPath.put(file.path(), file); } - List
sections = new ArrayList<>(); + List drafts = new ArrayList<>(); for (List unit : units) { SortedSet members = new TreeSet<>(unit); for (String file : unit) { @@ -155,15 +165,26 @@ public static List
of(UnifiedDiff diff, ChangeGraph graph) { } List ordered = new ArrayList<>(members); ordered.sort(readingOrder); - Optional hub = hubOf(unit, graph); - sections.add(new Section( - title(ordered, hub), - ordered, - hunkIdsOf(byPath, ordered), - hub, - cyclesIn(unit, depends))); - } - return readable(sections); + 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) { } /** @@ -176,25 +197,25 @@ public static List
of(UnifiedDiff diff, ChangeGraph graph) { * 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
sections) { - List
visible = new ArrayList<>(); - for (int index = 0; index < sections.size(); index++) { - Section section = sections.get(index); - if (section.hubSymbol().isEmpty() && coveredByAnother(sections, index)) { + 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(section); + visible.add(draft); } return List.copyOf(visible); } - private static boolean coveredByAnother(List
sections, int index) { - List files = sections.get(index).files(); - for (int other = 0; other < sections.size(); other++) { - if (other == index || !sections.get(other).files().containsAll(files)) { + 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 (sections.get(other).files().size() > files.size() || other < index) { + if (drafts.get(other).files().size() > files.size() || other < index) { return true; } } @@ -306,50 +327,181 @@ private static boolean sameComponentByName(String left, String right, // ---- naming ------------------------------------------------------------- /** - * The section's most-referenced changed symbol: what the section is - * about. Counted per symbol, not per declaring file -- a file's fan-in - * is the same number for every name it declares, so ranking by it would - * title the section with whichever name sorted first. + * 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.

- * - *

A symbol nothing references wins only when it is the unit's single - * declaration -- then there is nothing to be wrong about. Several, all - * unreferenced, and there is no hub: the honest answer is no symbol at - * all, and {@link #title} falls back to the directory.

*/ - private static Optional hubOf(List unit, ChangeGraph graph) { + private static Optional hubOf(List unit, List sectionFiles, + ChangeGraph graph) { SortedSet declarations = new TreeSet<>(); for (String file : unit) { declarations.addAll(graph.declarationsIn(file)); } - String best = null; - int bestFanIn = -1; - // Ascending order plus a strict >: ties keep the alphabetically - // first name, so the title cannot depend on iteration order. + if (declarations.isEmpty()) { + return Optional.empty(); + } + SortedSet section = new TreeSet<>(sectionFiles); + List referenced = new ArrayList<>(); for (String symbol : declarations) { - int fanIn = graph.filesReferencingSymbol(symbol).size(); - if (fanIn > bestFanIn) { - best = symbol; - bestFanIn = fanIn; + 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)); } } - return bestFanIn > 0 || declarations.size() == 1 - ? Optional.ofNullable(best) - : Optional.empty(); + 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 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 is still the most - // specific true thing that can be said. - .orElseGet(() -> { - String directory = FallbackIntents.directoryOf(files.get(0)); - return (directory.isEmpty() ? "repository root" : directory) + " · " + count; - }); + 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 ------------------------------------------------------------- diff --git a/app/src/test/java/app/drydock/review/SectionsTest.java b/app/src/test/java/app/drydock/review/SectionsTest.java index db31b8ca..6a24e257 100644 --- a/app/src/test/java/app/drydock/review/SectionsTest.java +++ b/app/src/test/java/app/drydock/review/SectionsTest.java @@ -212,6 +212,83 @@ void aHublessSectionAlreadyCarriedElsewhereGetsNoCardOfItsOwn() { "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() { From 6737e8cf7cd0f8be70a802bf4eedb445a1c1ccdc Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 10:45:50 +0200 Subject: [PATCH 038/113] A multi-line declaration keeps its name in the change graph SymbolScan parsed one diff line at a time, on the spec's reasoning that a diff line is not a compilation unit. For a C or C++ type whose body spans lines -- which is every real header -- that discards the name the section should be titled after. Measured on the realistic flagship pair: src/guards.h "class JmpCtxScope { };" -> declarations [JmpCtxScope] src/guards.h "class JmpCtxScope {" / "};" -> declarations [arm] The unit tests passed only because their fixtures wrote the class on one line. Java's grammar recovers a lone "class X {" and C++'s does not, so the language the whole feature was commissioned for was the one it lost. A hunk is now the parsing unit. A hunk interleaves ADD, DEL and CONTEXT lines, and joining all three is not valid source in either state -- a deleted "if" and the added one replacing it, 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. Context lines appear in both views, so only the new-state view reports them and the old-state view reports only its DEL lines; every source line is still scanned exactly once, and a deleted line is read in the syntax it was deleted from rather than in isolation. A hunk with no deletions -- the common case -- parses once. Node offsets from tree-sitter are UTF-8 BYTE offsets, so the fragment carries a per-line byte table and a binary search maps a node back to the line it starts on; that is what keeps onChangedLine per symbol. Counting characters would drift one per non-ASCII byte and mis-attribute or mis-slice. Everything else about the contract holds: no grammar still means uses only, an untokenisable fragment still falls back to the lexical scan (for the lines that view owns), context lines are still marked onChangedLine=false, and SymbolScan.of's signature is unchanged. Verified on this branch's own diff, git diff -U12 main...HEAD, 54 files and 103 hunks, through DiffService.parseUnified / ChangeGraph.of / Sections.of in a scratch harness: parseString calls over grammar-backed files fall from 9419 to 141, and the harness end to end from ~3.6s to ~2.2s. Uniquely-declared changed symbols go 335 -> 353; the nine that disappear were false ones read out of comments and string literals (copyOf, getStartByte, Kotlin, work) or names now correctly seen as declared in two files, and the twenty-seven gained are real multi-line declarations (Draft, Section, SectionState, topologicalOrder, hubOf, and the enum constants FILE/HUNK/SECTION/FRESH/MOVED/UNKNOWN). Output is byte-identical across three separate processes. Not covered: a hunk whose fragment is badly truncated can still lose a declaration to an ERROR node -- uses survive, since walk descends into ERROR. Tests: SymbolScanTest 9/9, ChangeGraphTest 7/7, SectionsTest 21/21. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/app/drydock/review/SymbolScan.java | 206 ++++++++++++++---- .../app/drydock/review/SymbolScanTest.java | 125 +++++++++++ 2 files changed, 284 insertions(+), 47 deletions(-) diff --git a/app/src/main/java/app/drydock/review/SymbolScan.java b/app/src/main/java/app/drydock/review/SymbolScan.java index b267e3e7..757f9aea 100644 --- a/app/src/main/java/app/drydock/review/SymbolScan.java +++ b/app/src/main/java/app/drydock/review/SymbolScan.java @@ -8,6 +8,7 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Optional; import java.util.Set; @@ -23,10 +24,20 @@ * call without guessing, and a wrong declaration would mint wrong edges * everywhere the name appears.

* - *

Blocking: parsing a line -- 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.

+ *

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.

+ * + *

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 { @@ -81,17 +92,22 @@ public record Symbol(String name, String path, boolean declaration, boolean onCh private SymbolScan() { } - /** {@code file}'s symbols, in source order. */ + /** {@code file}'s symbols, in reading order within each hunk. */ 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)); + 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(), hunk, UnifiedDiff.Line.Kind.ADD, true, + symbols); + scanView(grammar.get(), file.path(), hunk, UnifiedDiff.Line.Kind.DEL, false, + symbols); + } else { + for (UnifiedDiff.Line line : hunk.lines()) { + lexical(symbols, file.path(), line.text(), isChanged(line)); } } } @@ -99,43 +115,140 @@ public static List of(UnifiedDiff.FileDiff file) { } /** - * Line-at-a-time parsing. A diff line is not a compilation unit, so the - * tree is usually an ERROR node with recognisable children -- enough for - * "is this token introducing a name", the only question asked here, and - * it avoids reconstructing whole files from a diff. + * 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 - * line is deliberate, not a leak: the binding exposes no public {@code + * 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 List parsed(TSLanguage language, String path, String text, - boolean changed) { + private static void scanView(TSLanguage language, String path, 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, text); + 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. + // 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. - return lexical(path, text, changed); + for (int index = 0; index < lines.size(); index++) { + if (fragment.reports(index)) { + lexical(out, path, lines.get(index).text(), fragment.changed(index)); + } + } + return; } - byte[] utf8 = text.getBytes(StandardCharsets.UTF_8); - List symbols = new ArrayList<>(); - walk(tree.getRootNode(), utf8, path, changed, symbols); - return symbols; + walk(tree.getRootNode(), fragment, path, 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; } /** @@ -150,8 +263,7 @@ private static List parsed(TSLanguage language, String path, String text * 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, byte[] utf8, String path, boolean changed, - List out) { + private static void walk(TSNode node, Fragment fragment, String path, List out) { if (DECLARATION_NODES.contains(node.getType())) { int count = node.getChildCount(); for (int i = 0; i < count; i++) { @@ -160,19 +272,19 @@ private static void walk(TSNode node, byte[] utf8, String path, boolean changed, boolean isDeclaredName = isNameNode(child) && (field == null || NAME_FIELDS.contains(field)); if (isDeclaredName) { - addSymbol(out, utf8, child, path, true, changed); + addSymbol(out, fragment, child, path, true); } else { - walk(child, utf8, path, changed, out); + walk(child, fragment, path, out); } } return; } if (isNameNode(node)) { - addSymbol(out, utf8, node, path, false, changed); + addSymbol(out, fragment, node, path, false); return; } for (int i = 0; i < node.getChildCount(); i++) { - walk(node.getChild(i), utf8, path, changed, out); + walk(node.getChild(i), fragment, path, out); } } @@ -181,33 +293,33 @@ private static boolean isNameNode(TSNode node) { } /** - * {@code node}'s text, sliced from the line's own UTF-8 bytes rather - * than {@code String.substring} on the original line. {@link - * TSNode#getStartByte()}/{@link TSNode#getEndByte()} are UTF-8 BYTE - * offsets (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) -- - * slicing the {@code String} by char index would misalign, or throw, - * for any line with a multi-byte character before the token. + * {@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, byte[] utf8, TSNode node, String path, - boolean declaration, boolean changed) { - String name = new String(utf8, node.getStartByte(), node.getEndByte() - node.getStartByte(), + private static void addSymbol(List out, Fragment fragment, TSNode node, String path, + 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, declaration, changed)); + out.add(new Symbol(name, path, declaration, fragment.changed(index))); } } - private static List lexical(String path, String text, boolean changed) { - List symbols = new ArrayList<>(); + private static void lexical(List out, String path, String text, boolean changed) { 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)); + out.add(new Symbol(name, path, false, changed)); } } - return symbols; } } diff --git a/app/src/test/java/app/drydock/review/SymbolScanTest.java b/app/src/test/java/app/drydock/review/SymbolScanTest.java index c08abda9..a5879686 100644 --- a/app/src/test/java/app/drydock/review/SymbolScanTest.java +++ b/app/src/test/java/app/drydock/review/SymbolScanTest.java @@ -6,6 +6,7 @@ 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; @@ -80,4 +81,128 @@ void aSymbolOnAContextLineIsNotOnAChangedLine() { 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 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 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); + } } From ef37062b8a4a7cb9b5783ed9d867dd106e9e4e61 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 10:46:24 +0200 Subject: [PATCH 039/113] A qualified name references the type that qualifies it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An out-of-line C++ definition, "void JmpCtxScope::arm() { }" in guards.cpp, minted no reference to JmpCtxScope: tree-sitter spells the qualifier of a qualified_identifier as a namespace_identifier node, which was not in NAME_NODES, so it was walked past in silence. The consequence is not a missing token but a missing edge -- a .cpp did not link to its own header by symbol, which is the single relationship the reference target's rail is built out of. namespace_identifier is added to NAME_NODES, so the qualifier registers as a use. The other shipped languages need no entry: dumping the real trees shows Java's Foo.bar() (object: identifier), Rust's Foo::bar (path: identifier) and Go's pkg.Sym (operand: identifier) all spell the qualifier with a plain identifier, which was already listed. Deliberately NOT changed: "arm" stays a use in the .cpp rather than becoming a second declaration of a name guards.h already declares -- making it a declaration would make the name ambiguous and ChangeGraph would drop it, losing the edge this commit exists to add. Verified on the flagship pair (multi-line class JmpCtxScope in guards.h, #include plus two qualified definitions in guards.cpp): guards.cpp now yields JmpCtxScope as a use, and the section reads "JmpCtxScope · 2 files" where before this commit it read "arm · 2 files". Tests: SymbolScanTest 10/10 (one added), ChangeGraphTest 7/7, SectionsTest 21/21. Co-Authored-By: Claude Opus 5 (1M context) --- .../main/java/app/drydock/review/SymbolScan.java | 13 ++++++++++++- .../java/app/drydock/review/SymbolScanTest.java | 15 +++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/drydock/review/SymbolScan.java b/app/src/main/java/app/drydock/review/SymbolScan.java index 757f9aea..b3f38847 100644 --- a/app/src/main/java/app/drydock/review/SymbolScan.java +++ b/app/src/main/java/app/drydock/review/SymbolScan.java @@ -73,9 +73,20 @@ public record Symbol(String name, String path, boolean declaration, boolean onCh * 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"); + "identifier", "type_identifier", "field_identifier", "simple_identifier", + "namespace_identifier"); /** * Child field names that mark an identifier as the thing being diff --git a/app/src/test/java/app/drydock/review/SymbolScanTest.java b/app/src/test/java/app/drydock/review/SymbolScanTest.java index a5879686..462663eb 100644 --- a/app/src/test/java/app/drydock/review/SymbolScanTest.java +++ b/app/src/test/java/app/drydock/review/SymbolScanTest.java @@ -118,6 +118,21 @@ void aMultiLineJavaTypeKeepsItsNameWhenTheBraceIsOnItsOwnLine() { 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)); + } + /** * A hunk holds ADD, DEL and CONTEXT lines at once. Both states are * scanned: the deleted line's symbols are still reported, and still From f2dd3d54a60145ee4dbbaea72e6841bfdeb57089 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 10:46:58 +0200 Subject: [PATCH 040/113] Files that are not code contribute no symbols at all The rule "a file with no grammar contributes uses only" was written for an unsupported language, not for prose, and prose is what it hit. Measured on this branch's own 54-file diff, 51 of 337 reference edges -- 15% -- originated in files that are not code: the plan's own markdown design document quotes Java in fenced blocks and minted an edge to each of seventeen changed classes, and app.css reached Sections.java through a style class name. Those edges are not wrong about the text; they are wrong about the code, and they glue together sections with no structural relation. The 30-file card at the top of the rail was one of them. SymbolScan now returns nothing at all for a file whose extension is on an explicit denylist -- markdown, plain text, rst/adoc/org, css/scss/less, json/yaml/toml/ini/lock, html/xml/svg, images, properties, csv, patch, log. A denylist rather than a cleverer test because anything inferring "is this code" is a guess whose failures are invisible, and the cost of a denylist is at least stated: it lets through every extension nobody listed and every file with no extension at all. That last is deliberate -- Makefile and gradlew are code and should pass; LICENSE and CODEOWNERS are not and still contribute uses, but they are prose without identifiers, so they mint few edges. The case that actually mattered, a document full of code blocks, has an extension. None of the denylisted extensions has a grammar today, so nothing that was parsed stops being parsed; the check runs first regardless, so packaging a grammar for one of them later cannot re-open the hole by accident. Verified on the same 54-file diff through DiffService.parseUnified / ChangeGraph.of / Sections.of: reference edges 337 -> 178, of which edges originating in non-code files 51 -> 0; largest SCC 29 -> 21; largest section 30 files/60 hunks -> 23 files/52 hunks; duplication 3.65x -> 2.78x; every hunk still covered, 54/54 files still in the rail. Sections rise 25 -> 33 and cards titled by a declared symbol 13/25 -> 20/33: the rail is finer-grained because the false edges were merging unrelated files, and that is the intended direction. Not fixed here: Sections.includeEdges still shape-matches import lines in a markdown file, so a document quoting "import app.drydock.review.X" keeps a small include edge. That is a separate rule in a separate class. Tests: SymbolScanTest 11/11 (one added), ChangeGraphTest 7/7, SectionsTest 21/21. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/app/drydock/review/SymbolScan.java | 56 ++++++++++++++++++- .../app/drydock/review/SymbolScanTest.java | 17 ++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/drydock/review/SymbolScan.java b/app/src/main/java/app/drydock/review/SymbolScan.java index b3f38847..4c5e0c6b 100644 --- a/app/src/main/java/app/drydock/review/SymbolScan.java +++ b/app/src/main/java/app/drydock/review/SymbolScan.java @@ -10,6 +10,7 @@ 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; @@ -22,7 +23,9 @@ * 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.

+ * 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 @@ -100,11 +103,45 @@ public record Symbol(String name, String path, boolean declaration, boolean onCh */ 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 (UnifiedDiff.Hunk hunk : file.hunks()) { @@ -125,6 +162,23 @@ public static List of(UnifiedDiff.FileDiff file) { 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}. * diff --git a/app/src/test/java/app/drydock/review/SymbolScanTest.java b/app/src/test/java/app/drydock/review/SymbolScanTest.java index 462663eb..6da8b9a2 100644 --- a/app/src/test/java/app/drydock/review/SymbolScanTest.java +++ b/app/src/test/java/app/drydock/review/SymbolScanTest.java @@ -133,6 +133,23 @@ void aQualifiedDefinitionReferencesItsQualifier() { 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 From 66d47a91f90fba0a8b790bf8ed3268222acf8c4f Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 11:11:48 +0200 Subject: [PATCH 041/113] A name that appears only in documentation is not a reference Reviewing the per-commit edge decomposition showed the headline improvement in this task was credited to the wrong cause. On the branch's own diff the reference edges fall 355 -> 234 -> 234 -> 183 across the three commits, so 121 of the 172 removed edges -- 70% -- come from per-hunk parsing, not from the non-code denylist, which is worth 51. Classifying every lost (symbol, referencing file, declaring file) triple by where that symbol occurs in the referencing file shows 169 of 208 are comment-only and 19 more are mixed: 90% is documentation. The mechanism is narrower than "comments". A whole // line, and a one-line block comment, already parsed as comment nodes and never leaked. What leaked was an INTERIOR line of a block comment, which is most of the documentation here: read alone, " * ranks {@code BaseMove} above {@link HunkDigest}" is an asterisk and some identifiers, so it lexed as bare names and minted structural edges. That is why {@link SectionStates.Staleness#UNKNOWN} in ReviewVerdictBar.java coupled it to four changed symbols. The behaviour is correct and stays -- prose that NAMES a thing is not code that DEPENDS on it, and nobody ever decided a {@link} should couple two files in the review rail -- but it was a policy arrived at as a side effect, stated nowhere and guarded by nothing, so a walk change or a grammar bump would silently restore all 121 edges. It is now a paragraph in SymbolScan's class javadoc beside "a hunk, not a line", and a test. The first version of that test used a one-line Javadoc fixture and was worthless: compiled against the pre-change class it passed, because that case never leaked. Verified by running both classes over the same input rather than assuming; the shipped fixture is a multi-line block comment, where base reports all five documentation names (BaseMove, HunkDigest, SectionStates, Staleness, UNKNOWN) and this reports none while install and helper still resolve. Behaviour is unchanged by this commit: the harness output over the 54-file diff is byte-identical to 2d99211's. Tests: SymbolScanTest 12/12 (one added), ChangeGraphTest 7/7, SectionsTest 21/21, forced with --rerun-tasks. Full suite not run. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/app/drydock/review/SymbolScan.java | 21 ++++++++++ .../app/drydock/review/SymbolScanTest.java | 42 +++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/app/src/main/java/app/drydock/review/SymbolScan.java b/app/src/main/java/app/drydock/review/SymbolScan.java index 4c5e0c6b..989ad02d 100644 --- a/app/src/main/java/app/drydock/review/SymbolScan.java +++ b/app/src/main/java/app/drydock/review/SymbolScan.java @@ -38,6 +38,27 @@ * 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.

diff --git a/app/src/test/java/app/drydock/review/SymbolScanTest.java b/app/src/test/java/app/drydock/review/SymbolScanTest.java index 6da8b9a2..9317b868 100644 --- a/app/src/test/java/app/drydock/review/SymbolScanTest.java +++ b/app/src/test/java/app/drydock/review/SymbolScanTest.java @@ -198,6 +198,48 @@ void aContextLineIsReportedOnceEvenWhenBothStatesAreParsed() { .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 From e5117e75298da7f00b66dbab25befa641dee65cd Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 11:41:12 +0200 Subject: [PATCH 042/113] 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. When the graph turns out to have nothing structural to add, IntentGrouping returns the fallback's own intents rather than restating them under a fresh computed: identity -- otherwise a finding recorded against the fallback's id the moment a diff lands is silently orphaned once the graph finishes a few milliseconds later, defeating blockingFindingOpen's id match for no reason a reviewer caused. Three existing tests hit exactly this race before the fix. Host.intents grows an Optional parameter so SessionReviewView can hand through what it has without forcing MainWorkspace's ReviewHost or the test fakes to know about threading; each implementer forwards it or ignores it as before. --- .../app/drydock/review/IntentGrouping.java | 80 +++++++++++++++++- .../java/app/drydock/ui/MainWorkspace.java | 6 +- .../drydock/ui/review/SessionReviewView.java | 83 ++++++++++++++++++- .../review/SectionDeterminismTest.java | 67 +++++++++++++++ .../ui/OpenSessionTabReviewSubTabTest.java | 4 +- .../app/drydock/ui/review/FakeReviewHost.java | 6 +- .../ui/review/ReviewCommentComposerTest.java | 2 +- 7 files changed, 237 insertions(+), 11 deletions(-) create mode 100644 app/src/test/java/app/drydock/review/SectionDeterminismTest.java diff --git a/app/src/main/java/app/drydock/review/IntentGrouping.java b/app/src/main/java/app/drydock/review/IntentGrouping.java index 8cebe3c7..ac06e8ec 100644 --- a/app/src/main/java/app/drydock/review/IntentGrouping.java +++ b/app/src/main/java/app/drydock/review/IntentGrouping.java @@ -61,13 +61,91 @@ 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.

+ * + *

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()); + if (sameAsFallback(sections, fallback)) { + return fallback; + } + List computed = new ArrayList<>(); + int number = 1; + for (Sections.Section section : sections) { + 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); + } + + /** + * 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; + } + + /** + * 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()); } /** diff --git a/app/src/main/java/app/drydock/ui/MainWorkspace.java b/app/src/main/java/app/drydock/ui/MainWorkspace.java index fe6cf757..9461e105 100644 --- a/app/src/main/java/app/drydock/ui/MainWorkspace.java +++ b/app/src/main/java/app/drydock/ui/MainWorkspace.java @@ -48,6 +48,7 @@ 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; @@ -1970,8 +1971,9 @@ public List findings(ReviewScope scope) { } @Override - public List intents(ReviewScope scope, UnifiedDiff diff) { - return intentGrouping.intentsFor(scope.id(), diff); + public List intents(ReviewScope scope, UnifiedDiff diff, + Optional graph) { + return intentGrouping.intentsFor(scope.id(), diff, graph); } @Override 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 ebe8599b..eebc06fe 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -5,6 +5,8 @@ import app.drydock.git.UnifiedDiff; import app.drydock.mcp.McpActivityLog; 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; import app.drydock.review.ReviewScope; @@ -33,7 +35,10 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; import java.util.function.Consumer; /** @@ -82,16 +87,22 @@ 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); + List intents(ReviewScope scope, UnifiedDiff diff, Optional graph); /** * The verdict recorded on one hunk, if any -- keyed by the hunk's @@ -287,6 +298,34 @@ enum SettleUnit { */ 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 scopes this session offers, once {@link SessionReviewScopes} has measured them. */ private Optional scopes = Optional.empty(); @@ -432,6 +471,11 @@ public SessionReviewView(Host host, DiffService diffService, McpActivityLog acti // from an empty diff and never recovers. diffColumn.setOnDiffResolved((scopeId, outcome) -> { outcomeByScope.put(scopeId, outcome); + if (outcome instanceof DiffOutcome.Loaded loaded) { + 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. @@ -862,11 +906,42 @@ private List intents() { return List.of(); } if (selectedOutcome().orElse(null) instanceof DiffOutcome.Loaded loaded) { - return host.intents(scope.get(), loaded.diff()); + return host.intents(scope.get(), loaded.diff(), + Optional.ofNullable(graphByScope.get(scope.get().id()))); } return List.of(); } + /** + * 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. + */ + private void requestGraph(String scopeId, UnifiedDiff diff) { + int generation = graphGenerationByScope.merge(scopeId, 1, Integer::sum); + graphByScope.remove(scopeId); + CompletableFuture.supplyAsync(() -> ChangeGraph.of(diff), SECTION_GRAPH_EXECUTOR) + .whenComplete((graph, failure) -> Platform.runLater(() -> { + if (failure != null + || !Objects.equals(graphGenerationByScope.get(scopeId), generation)) { + // Either the parse failed -- in which case the + // (kind, directory) fallback is the honest answer, + // not a broken rail -- or a newer diff for this + // scope started a second build before this one + // finished, and publishing a graph for a diff no + // longer on screen would be worse than the fallback + // it displaced. + return; + } + graphByScope.put(scopeId, graph); + if (selectedScope().map(scope -> scope.id().equals(scopeId)).orElse(false)) { + refreshReviewState(); + } + })); + } + /** * Which empty the rail is showing. A scope with a checkout whose diff has * not arrived is loading; one without a checkout never will; a loaded 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/ui/OpenSessionTabReviewSubTabTest.java b/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java index 15452596..49cf98ed 100644 --- a/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java +++ b/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java @@ -5,6 +5,7 @@ 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; @@ -487,7 +488,8 @@ 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(); } 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 7b60ab12..fe3b1a9d 100644 --- a/app/src/test/java/app/drydock/ui/review/FakeReviewHost.java +++ b/app/src/test/java/app/drydock/ui/review/FakeReviewHost.java @@ -4,6 +4,7 @@ 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; @@ -113,8 +114,9 @@ public List findings(ReviewScope scope) { } @Override - public List intents(ReviewScope scope, UnifiedDiff diff) { - return intents.intentsFor(scope.id(), diff); + public List intents(ReviewScope scope, UnifiedDiff diff, + Optional graph) { + return intents.intentsFor(scope.id(), diff, graph); } @Override 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..6b91d8ab 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewCommentComposerTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewCommentComposerTest.java @@ -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()); } From 15a02e2eb07567e6bd375bd9577820f6a180452d Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 12:35:41 +0200 Subject: [PATCH 043/113] A closed view stops a background section graph from touching it close() now sets a flag requestGraph's completion checks before doing anything -- including before posting to Platform.runLater -- so a graph still parsing when its view closes never revives state or FX work for a view nothing is looking at. Test suites create a fresh SessionReviewView per test method and rarely call close(), so this alone does not fully bound the background work; it is lifecycle symmetry (AGENTS.md), not a full fix. The actual flake this surfaced: theExplorerJumpStillWorksOnARestoredScope failed under the full required test scope (not in isolation) because the virtualized ListView's cell layout runs on the pulse AFTER a scope switch, not synchronously within it, and a full suite now has other tests' own background section-graph builds competing for the CPU that pulse needs. Confirmed by A/B against the pre-Task-13 commit: 2/2 clean there, 2/3 failing here before this fix, 6/6 clean after adding the WaitForAsyncUtils.waitForFxEvents() this suite's other diff-driven lookups already use. --- .../drydock/ui/review/SessionReviewView.java | 54 +++++++++++++------ .../ui/review/SessionReviewViewTest.java | 8 +++ 2 files changed, 47 insertions(+), 15 deletions(-) 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 eebc06fe..22d5299e 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -326,6 +326,18 @@ enum SettleUnit { */ private final Map graphGenerationByScope = new HashMap<>(); + /** + * 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; + /** The scopes this session offers, once {@link SessionReviewScopes} has measured them. */ private Optional scopes = Optional.empty(); @@ -923,23 +935,34 @@ private void requestGraph(String scopeId, UnifiedDiff diff) { int generation = graphGenerationByScope.merge(scopeId, 1, Integer::sum); graphByScope.remove(scopeId); CompletableFuture.supplyAsync(() -> ChangeGraph.of(diff), SECTION_GRAPH_EXECUTOR) - .whenComplete((graph, failure) -> Platform.runLater(() -> { - if (failure != null - || !Objects.equals(graphGenerationByScope.get(scopeId), generation)) { - // Either the parse failed -- in which case the - // (kind, directory) fallback is the honest answer, - // not a broken rail -- or a newer diff for this - // scope started a second build before this one - // finished, and publishing a graph for a diff no - // longer on screen would be worse than the fallback - // it displaced. + .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; } - graphByScope.put(scopeId, graph); - if (selectedScope().map(scope -> scope.id().equals(scopeId)).orElse(false)) { - refreshReviewState(); - } - })); + Platform.runLater(() -> { + if (closed || failure != null + || !Objects.equals(graphGenerationByScope.get(scopeId), generation)) { + // Either the parse failed -- in which case the + // (kind, directory) fallback is the honest + // answer, not a broken rail -- or a newer diff + // for this scope started a second build before + // this one finished, and publishing a graph for + // a diff no longer on screen would be worse + // than the fallback it displaced. + return; + } + graphByScope.put(scopeId, graph); + if (selectedScope().map(scope -> scope.id().equals(scopeId)).orElse(false)) { + refreshReviewState(); + } + }); + }); } /** @@ -1770,6 +1793,7 @@ public void onShown() { * OpenSessionTab.disposeNativeResources}.

*/ public void close() { + closed = true; mcpPanel.ifPresent(ReviewMcpActivityPanel::detach); intentRail.stopWidthAnimation(); if (getScene() != null) { 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..042f21ca 100644 --- a/app/src/test/java/app/drydock/ui/review/SessionReviewViewTest.java +++ b/app/src/test/java/app/drydock/ui/review/SessionReviewViewTest.java @@ -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 From 90c083a889ac639cd360f63644896faa53656e01 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 12:57:55 +0200 Subject: [PATCH 044/113] A computed section's id is derived from its hunks, not its position 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. The positional computed:N id this used to mint would then silently re-point any verdict or finding recorded against N at a DIFFERENT section covering different hunks after such a shift -- worse than orphaning it, because nothing about the result looks wrong to notice. Hashed instead over the section's own sorted hunk ids: the identity is the SET of hunks a section covers, which survives a re-diff that reorders sections without changing their membership, and only changes when the section itself actually does. --- .../app/drydock/review/IntentGrouping.java | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/drydock/review/IntentGrouping.java b/app/src/main/java/app/drydock/review/IntentGrouping.java index ac06e8ec..26c8a3c2 100644 --- a/app/src/main/java/app/drydock/review/IntentGrouping.java +++ b/app/src/main/java/app/drydock/review/IntentGrouping.java @@ -2,7 +2,12 @@ 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.HexFormat; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -106,7 +111,7 @@ public List intentsFor(String scopeId, UnifiedDiff diff, List computed = new ArrayList<>(); int number = 1; for (Sections.Section section : sections) { - computed.add(new ReviewIntent("computed:" + number, number, + computed.add(new ReviewIntent(computedId(section), number, section.title(), ReviewIntent.Kind.CHANGE, ReviewIntent.Risk.NONE, rationale(section), section.hunkIds(), Optional.empty(), false)); number++; @@ -114,6 +119,37 @@ public List intentsFor(String scopeId, UnifiedDiff diff, return List.copyOf(computed); } + /** + * 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.

+ */ + private static String computedId(Sections.Section section) { + List sorted = new ArrayList<>(section.hunkIds()); + Collections.sort(sorted); + return "computed:" + sha256Hex(String.join("\n", sorted)).substring(0, 16); + } + + 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 From 94cacdb707785e015fbe0e2e4b206527b2d9fc72 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 12:58:53 +0200 Subject: [PATCH 045/113] Computed cards carry over the fallback's kind and risk Every computed intent was minted Kind.CHANGE, Risk.NONE regardless of what it actually covers, so the heat bar flattened and every tag read "change" the instant the graph landed -- discarding the fallback's churn-derived HIGH/MED/LOW and its tests/config/generated inference the moment Sections regrouped the same hunks differently. A section's kind is now the most significant kind among the fallback intents whose hunks it covers (the same production-over-tests-and-config priority FallbackIntents already orders the rail by), and its risk is the worst of theirs -- a section is only as safe to wave through as its riskiest part. --- .../app/drydock/review/IntentGrouping.java | 68 ++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/drydock/review/IntentGrouping.java b/app/src/main/java/app/drydock/review/IntentGrouping.java index 26c8a3c2..5e5c4fe4 100644 --- a/app/src/main/java/app/drydock/review/IntentGrouping.java +++ b/app/src/main/java/app/drydock/review/IntentGrouping.java @@ -108,17 +108,83 @@ public List intentsFor(String scopeId, UnifiedDiff diff, if (sameAsFallback(sections, fallback)) { return fallback; } + 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 : sections) { computed.add(new ReviewIntent(computedId(section), number, - section.title(), ReviewIntent.Kind.CHANGE, ReviewIntent.Risk.NONE, + section.title(), kindOf(section, fallbackByHunk), riskOf(section, fallbackByHunk), rationale(section), section.hunkIds(), Optional.empty(), false)); 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. From 8bf6515bcc4ec894cce5f031bebd1bfa1d5f33b7 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 12:59:42 +0200 Subject: [PATCH 046/113] A cycle rationale names at most three files, and stops pointing outward Sections.cyclesIn returns members of the unit ITSELF -- files inside this same section that reference each other -- so "in a dependency cycle with" read as though they belonged elsewhere, when the section IS the cycle. Reworded to say so. With no cap, a large cycle spelled out inline (this branch's own ChangeGraph section names 24 mutually-referencing files) wrapped across dozens of lines and made a single card taller than the rail's own viewport. Capped at three names plus "and N more". --- .../app/drydock/review/IntentGrouping.java | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/app/drydock/review/IntentGrouping.java b/app/src/main/java/app/drydock/review/IntentGrouping.java index 5e5c4fe4..fb9c7295 100644 --- a/app/src/main/java/app/drydock/review/IntentGrouping.java +++ b/app/src/main/java/app/drydock/review/IntentGrouping.java @@ -238,6 +238,9 @@ private static boolean sameAsFallback(List sections, List sections, 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"; } /** From 269a9b31c1694782ab73aa4a2a60024928cf89b7 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 13:00:23 +0200 Subject: [PATCH 047/113] A blocking finding survives its intent's id changing under it blockingFindingOpen matched a finding's named intentId by strict equality only, with no fallback -- so a finding filed while a scope showed the (kind, directory) fallback (or under a reviewer's earlier grouping) stopped blocking anything the instant the id scheme underneath it changed, silently defeating spec #4.6's refusal. This is the keyboard path's only guard: verdictAction calls setVerdict with no "blocked" pre-check of its own, so the write-time match here was the sole enforcement point. Now falls back to file overlap when the named id does not match literally, mirroring SessionReviewView#belongsToCurrentIntent -- the same reasoning that already renders the verdict bar's own "blocked" state correctly, just reached from the write path a click or keypress takes directly to it. --- .../main/java/app/drydock/ui/MainWorkspace.java | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/drydock/ui/MainWorkspace.java b/app/src/main/java/app/drydock/ui/MainWorkspace.java index 9461e105..0a376fa9 100644 --- a/app/src/main/java/app/drydock/ui/MainWorkspace.java +++ b/app/src/main/java/app/drydock/ui/MainWorkspace.java @@ -2432,10 +2432,25 @@ private void refreshReviewBoards() { } } + /** + * Whether a still-open blocking finding names {@code intent} -- or, when + * its named id no longer matches literally, whether it still touches one + * of {@code intent}'s files. The file fallback matters because a + * grouping's ids are not stable across every regrouping (a reviewer's + * own re-run, or the computed-sections graph landing after the fallback + * shown while it built): matching by id alone would let a finding filed + * against an id that no longer resolves silently stop blocking anything, + * defeating spec §4.6's refusal for a reason no reviewer caused. Mirrors + * {@link SessionReviewView#belongsToCurrentIntent}, which the verdict + * bar itself already renders "blocked" from -- this is the second, + * write-time guard the keyboard path reaches without going through that + * render at all. + */ 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)) + .map(id -> id.equals(intent.id()) || intent.touches(finding.file())) + .orElse(true)) .anyMatch(ReviewAnnotation::blocksApproval); } From e686977d84759048d1704062cfc6e63e73017539 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 13:07:51 +0200 Subject: [PATCH 048/113] Computed intents are worked out once per refresh, not once per finding Sections.of runs on the FX thread every time #intents() is called, and refreshReviewState() calls it independently through findingsForMargin (plus once per finding inside it, via belongsToCurrentIntent), currentIntent(), renderVerdictBar and the rail's own setIntents -- >=4 full runs per refresh, scaling with finding count. Measured on this branch's own 57-file diff with 5 findings seeded: ~5.9s per refreshReviewState() call, entirely on the FX thread, which Sections.of's own contract says it must never run on. Cached now, keyed by (scope id, diff, graph) and invalidated at the top of every refreshReviewState() -- that entry point's own contract is "re-reads ... on every store change", so a cache surviving past one refresh would go stale exactly when a reviewer's grouping changed without diff or graph changing. Within the one pass a refresh starts, nothing yields back to the FX event queue, so scope/diff/graph cannot change again, and reusing the cached list for the rest of it is exactly as fresh as recomputing. Re-measured after the fix, same 57-file diff, same 5 findings: ~396ms average per refreshReviewState() (a first call at 634ms, then 300-350ms) -- roughly 15x. The remaining cost is one Sections.of run plus rebuilding the rail's own card nodes, neither of which this task's scope covers moving off the FX thread. --- .../drydock/ui/review/SessionReviewView.java | 53 +++++++++++++++++-- 1 file changed, 49 insertions(+), 4 deletions(-) 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 22d5299e..277b7397 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -338,6 +338,36 @@ enum SettleUnit { */ private volatile boolean closed; + /** + * {@link #intents()}'s last computed result, so one {@link + * #refreshReviewState()} pass computes {@code Sections.of} at most ONCE + * rather than once per internal caller. {@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 a single refresh -- + * measured at over a second of real work on this branch's own diff, ALL + * of it on the FX thread, which {@link Sections#of}'s own contract + * forbids. + * + *

Bumped at the top of every {@link #refreshReviewState()} -- the + * entry point's own contract is "re-reads ... on every store change", so + * a cache that survived past one refresh would go stale exactly when a + * reviewer's grouping changed underneath it. Within the one pass that + * bump started, {@code scope}, {@code diff} and {@code graph} cannot + * change again (nothing here yields back to the FX event queue mid-pass), + * so reusing the cached list for the rest of it is exactly as fresh as + * recomputing would have been.

+ */ + private IntentsCacheEntry intentsCache; + + /** One completed {@link #intents()} lookup, keyed by what it was computed from. */ + private record IntentsCacheEntry(long generation, String scopeId, UnifiedDiff diff, + ChangeGraph graph, List intents) { + } + + /** Bumped at the top of every {@link #refreshReviewState()}; see {@link #intentsCache}. */ + private long refreshGeneration; + /** The scopes this session offers, once {@link SessionReviewScopes} has measured them. */ private Optional scopes = Optional.empty(); @@ -815,6 +845,12 @@ public void refreshCounts() { * from a cached value silently discards the other writer's work. */ public void refreshReviewState() { + // Invalidates #intentsCache: this call is the "re-read from the + // store" contract above, so every intents() lookup this pass makes + // must recompute at least once, even if scope/diff/graph are + // unchanged from the last pass -- a reviewer's grouping can have + // changed without any of those changing. + refreshGeneration++; Optional scope = selectedScope(); updateRunReviewButton(); updateCountsLabel(); @@ -917,11 +953,20 @@ private List intents() { if (scope.isEmpty()) { return List.of(); } - if (selectedOutcome().orElse(null) instanceof DiffOutcome.Loaded loaded) { - return host.intents(scope.get(), loaded.diff(), - Optional.ofNullable(graphByScope.get(scope.get().id()))); + 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); + IntentsCacheEntry cached = intentsCache; + if (cached != null && cached.generation() == refreshGeneration + && cached.scopeId().equals(scopeId) && cached.diff() == diff && cached.graph() == graph) { + return cached.intents(); } - return List.of(); + List computed = host.intents(scope.get(), diff, Optional.ofNullable(graph)); + intentsCache = new IntentsCacheEntry(refreshGeneration, scopeId, diff, graph, computed); + return computed; } /** From c717717aab6e66769b79e10955146781d7787401 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 13:09:04 +0200 Subject: [PATCH 049/113] A diff already graphed is not re-parsed on every scope redisplay requestGraph dropped the cached graph and started a fresh build on every Loaded outcome, even for a scope flip back to a cached diff -- outcomeByScope exists specifically so that case does not re-run git, and requestGraph was undoing half of it by re-running ChangeGraph.of anyway. Now a no-op when the diff handed in is the SAME instance already graphed (or currently being graphed) for that scope: a scope switch back to a cached Loaded outcome, and an untracked-files toggle, both republish the identical diff object through onDiffResolved, and this is what recognises that rather than treating it as new work. Also narrows the window a superseded build could still publish a stale graph into, since there are fewer redundant builds in flight to begin with. --- .../drydock/ui/review/SessionReviewView.java | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) 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 277b7397..7a547138 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -326,6 +326,18 @@ enum SettleUnit { */ 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<>(); + /** * Set by {@link #close()}. A graph build already running when a view * closes is left to finish -- there is no cancelling a virtual thread @@ -975,8 +987,20 @@ private List intents() { * 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); CompletableFuture.supplyAsync(() -> ChangeGraph.of(diff), SECTION_GRAPH_EXECUTOR) From df6093aa40da71bce8e482a216dbfea787e2a061 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 13:17:27 +0200 Subject: [PATCH 050/113] The rail can say its grouping is provisional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A boolean the header's hint now folds in ("N/M · i · refining grouping…") when set -- the seam SessionReviewView needs to say the (kind, directory) fallback on screen may still be replaced by the computed grouping once its background graph finishes, rather than swapping the cards with no warning at all. --- .../drydock/ui/review/ReviewIntentRail.java | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) 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 0423b99a..f1953dc1 100644 --- a/app/src/main/java/app/drydock/ui/review/ReviewIntentRail.java +++ b/app/src/main/java/app/drydock/ui/review/ReviewIntentRail.java @@ -74,6 +74,16 @@ final class ReviewIntentRail extends VBox { private boolean collapsed; private boolean narrow; + /** + * True while the grouping shown is provisional: a computed + * {@link app.drydock.review.ChangeGraph} is still building, and what is + * on screen is the (kind, directory) fallback the real grouping may + * still replace. Shown in the header's hint 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; @@ -143,6 +153,15 @@ void setIntents(List newIntents, String selectedIntentId, Empty re rebuild(); } + /** See {@link #groupingPending}. */ + void setGroupingPending(boolean pending) { + if (groupingPending == pending) { + return; + } + groupingPending = pending; + rebuild(); + } + boolean collapsed() { return collapsed; } @@ -244,7 +263,8 @@ private void rebuild() { .filter(ReviewIntent::countsTowardProgress) .filter(intent -> stateLookup.apply(intent).decision().isPresent()) .count(); - header.setHint(settled + "/" + counted + " · i"); + header.setHint(settled + "/" + counted + " · i" + + (groupingPending ? " · refining grouping…" : "")); buttonsByIntentId.clear(); List nodes = new ArrayList<>(); From 5291b3bd121a73274bb88f97214b0a731ca205bf Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 13:17:47 +0200 Subject: [PATCH 051/113] The reviewer's cursor follows the intent across a grouping swap intentIndex was a bare position, left untouched when the intents list changed underneath the same scope -- the fallback swapping for the computed grouping once its background graph finished, or a reviewer's own regroup. verdictAction reads currentIntent() fresh at keypress time, so a swap landing between a reviewer reading a card and pressing a/r would record the verdict against whatever intent now happens to occupy that same position, not the one actually read. Verdicts are hunk-digest-keyed, so nothing corrupts permanently, but the wrong hunks get marked settled. refreshReviewState now computes intents() once at the top (reusing the per-refresh cache), and re-anchors intentIndex when the result differs from the last pass for the SAME scope: to the same id first (nothing about the selection actually changed), otherwise to whichever new intent overlaps the previously-selected one in the most hunks. A scope switch is excluded by comparing scope ids, since renderSelectedScope already resets the cursor to 0 for that case and there is nothing to re-anchor against. Also tracks which scopes have a graph build in flight and tells the rail, via #setGroupingPending -- the swap this re-anchors around is no longer silent. --- .../drydock/ui/review/SessionReviewView.java | 94 ++++++++++++++++++- 1 file changed, 91 insertions(+), 3 deletions(-) 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 7a547138..1bdd7223 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -33,10 +33,12 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; 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; @@ -338,6 +340,14 @@ enum SettleUnit { */ 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 @@ -397,6 +407,17 @@ private record IntentsCacheEntry(long generation, String scopeId, UnifiedDiff di /** 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; + /** * The id of the intent {@code a}/{@code r} last recorded a verdict on, * so {@code u} can snap the cursor back to it -- see {@link @@ -874,19 +895,77 @@ public void refreshReviewState() { // 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.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; + margin.invalidate(null); margin.setFindings(findingsForMargin(scope.get())); diffColumn.refreshPins(); - intentRail.setIntents(intents(), currentIntent().map(ReviewIntent::id).orElse(null), + intentRail.setIntents(currentIntents, currentIntent().map(ReviewIntent::id).orElse(null), emptyReason()); + intentRail.setGroupingPending(graphBuilding.contains(scopeId)); 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 @@ -1003,6 +1082,7 @@ private void requestGraph(String scopeId, UnifiedDiff diff) { graphedDiffByScope.put(scopeId, diff); int generation = graphGenerationByScope.merge(scopeId, 1, Integer::sum); graphByScope.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 @@ -1015,8 +1095,16 @@ private void requestGraph(String scopeId, UnifiedDiff diff) { return; } Platform.runLater(() -> { - if (closed || failure != null - || !Objects.equals(graphGenerationByScope.get(scopeId), generation)) { + boolean current = Objects.equals(graphGenerationByScope.get(scopeId), generation); + if (current) { + // Only the CURRENT generation clears "building": + // a stale callback's own build really is done, + // but a newer one superseded it before this one + // arrived and is presumably still in flight, so + // the rail should still say so. + graphBuilding.remove(scopeId); + } + if (closed || failure != null || !current) { // Either the parse failed -- in which case the // (kind, directory) fallback is the honest // answer, not a broken rail -- or a newer diff From d0b25b4e86bf483a9268abda594ebcb82aa288d0 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 13:25:21 +0200 Subject: [PATCH 052/113] Pin the computed-id, degenerate-fallback and rail-swap behaviour with tests grep -rn "computed:" app/src found the id scheme nowhere in a test: nothing asserted that a computed section actually mints one, that Sections.of degenerating to the fallback keeps its own identity instead, that kind and risk carry over, or that the rail itself flips from the (kind, directory) fallback to the computed grouping once the graph lands. IntentGroupingTest covers the first three directly against IntentGrouping, using a four-file diff (m.h/m.cpp convention-merge, z.cpp and a.cpp their own units) whose split is already relied on elsewhere in this file's own determinism test. SectionRailSwapTest drives the same diff through a real SessionReviewView end to end and asserts the rail settles on three distinct computed: ids rather than staying on the fallback's one. Adds SessionReviewView#diagIntentIds, a small diagnostic accessor in the same style as the file's existing diag* methods, since nothing exposed the rendered ids for a test to read. --- .../drydock/ui/review/SessionReviewView.java | 5 + .../drydock/review/IntentGroupingTest.java | 119 +++++++++++++++ .../ui/review/SectionRailSwapTest.java | 138 ++++++++++++++++++ 3 files changed, 262 insertions(+) create mode 100644 app/src/test/java/app/drydock/review/IntentGroupingTest.java create mode 100644 app/src/test/java/app/drydock/ui/review/SectionRailSwapTest.java 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 1bdd7223..93237a06 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -2025,6 +2025,11 @@ SectionStates.SectionState diagSectionState(int index) { }); } + /** Diagnostic-only: the current rail's intent ids, in rendered order. */ + List diagIntentIds() { + return ReviewDiagFxThread.call(() -> intents().stream().map(ReviewIntent::id).toList()); + } + /** * 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/test/java/app/drydock/review/IntentGroupingTest.java b/app/src/test/java/app/drydock/review/IntentGroupingTest.java new file mode 100644 index 00000000..052480c0 --- /dev/null +++ b/app/src/test/java/app/drydock/review/IntentGroupingTest.java @@ -0,0 +1,119 @@ +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; + +/** + * {@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"); + } + + // ---- 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"); + } + + // ---- 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/ui/review/SectionRailSwapTest.java b/app/src/test/java/app/drydock/ui/review/SectionRailSwapTest.java new file mode 100644 index 00000000..206771a8 --- /dev/null +++ b/app/src/test/java/app/drydock/ui/review/SectionRailSwapTest.java @@ -0,0 +1,138 @@ +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.SessionReviewScopes; +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.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()); + stage.setScene(scene); + stage.show(); + } + + @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"); + } + + 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); + } +} From f5e863cbd7fbc88c6bf62403787f7da04eaa1682 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 13:35:09 +0200 Subject: [PATCH 053/113] A javadoc reference uses an import, not an inline qualified name ChangeGraph was spelled out in full in setGroupingPending's own javadoc; imported instead, per this codebase's own no-inline-fully-qualified-names convention. --- .../java/app/drydock/ui/review/ReviewIntentRail.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 f1953dc1..7d033bcd 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,6 @@ package app.drydock.ui.review; +import app.drydock.review.ChangeGraph; import app.drydock.review.ReviewIntent; import app.drydock.review.ReviewVerdict; import app.drydock.ui.PanelHeader; @@ -76,11 +77,10 @@ final class ReviewIntentRail extends VBox { /** * True while the grouping shown is provisional: a computed - * {@link app.drydock.review.ChangeGraph} is still building, and what is - * on screen is the (kind, directory) fallback the real grouping may - * still replace. Shown in the header's hint rather than silently, so a - * reviewer mid-read is not surprised by cards changing under them with - * no warning at all. + * {@link ChangeGraph} is still building, and what is on screen is the + * (kind, directory) fallback the real grouping may still replace. Shown + * in the header's hint rather than silently, so a reviewer mid-read is + * not surprised by cards changing under them with no warning at all. */ private boolean groupingPending; From 4515ba27d4e0e6237a50c9f316c98ddb1291b5ea Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 14:01:51 +0200 Subject: [PATCH 054/113] The verdict bar and the write path agree on what blocks approval blockingFindingOpen's file-overlap fallback and belongsToCurrentIntent (what the bar itself renders "blocked" from) disagreed for exactly one input: a finding naming an intent B that STILL EXISTS, on a file intent A also touches. The bar computed not blocked; blockingFindingOpen computed blocked regardless -- so on the overlapping computed sections this whole design produces, approving A did nothing, forever, while the bar insisted nothing was blocking, with no way for a keypress to tell why. Rather than add a second implementation of the same rule and hope the two stay in sync, Host.setVerdict now takes the "blocked" decision as a parameter, computed by SessionReviewView's own belongsToIntent (the same method the bar's blocked already comes from, generalised to an explicit intent rather than always the current one) -- there is exactly one place that answers "does this finding block this intent" now, and the host is told, not asked to guess with less information than the view has. Pinned by BlockingFindingAcrossOverlappingIntentsTest: approving an intent the finding does not name is no longer refused by an overlapping file, and approving the intent it actually names still is. --- .../java/app/drydock/ui/MainWorkspace.java | 37 +--- .../drydock/ui/review/SessionReviewView.java | 93 +++++++--- .../ui/OpenSessionTabReviewSubTabTest.java | 2 +- ...ngFindingAcrossOverlappingIntentsTest.java | 161 ++++++++++++++++++ .../app/drydock/ui/review/FakeReviewHost.java | 10 +- 5 files changed, 240 insertions(+), 63 deletions(-) create mode 100644 app/src/test/java/app/drydock/ui/review/BlockingFindingAcrossOverlappingIntentsTest.java diff --git a/app/src/main/java/app/drydock/ui/MainWorkspace.java b/app/src/main/java/app/drydock/ui/MainWorkspace.java index 0a376fa9..f2b92632 100644 --- a/app/src/main/java/app/drydock/ui/MainWorkspace.java +++ b/app/src/main/java/app/drydock/ui/MainWorkspace.java @@ -1983,7 +1983,7 @@ public Optional verdict(ReviewScope scope, String hunkDigest) { @Override public void setVerdict(ReviewScope scope, ReviewIntent intent, List hunkDigests, - Optional decision) { + Optional decision, boolean blocked) { if (decision.isEmpty()) { for (String digest : hunkDigests) { annotationStore.clearVerdict(scope.id(), digest); @@ -1991,11 +1991,14 @@ public void setVerdict(ReviewScope scope, ReviewIntent intent, List hunk 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; } ReviewBaseline baseline = baselineOf(scope); @@ -2432,28 +2435,6 @@ private void refreshReviewBoards() { } } - /** - * Whether a still-open blocking finding names {@code intent} -- or, when - * its named id no longer matches literally, whether it still touches one - * of {@code intent}'s files. The file fallback matters because a - * grouping's ids are not stable across every regrouping (a reviewer's - * own re-run, or the computed-sections graph landing after the fallback - * shown while it built): matching by id alone would let a finding filed - * against an id that no longer resolves silently stop blocking anything, - * defeating spec §4.6's refusal for a reason no reviewer caused. Mirrors - * {@link SessionReviewView#belongsToCurrentIntent}, which the verdict - * bar itself already renders "blocked" from -- this is the second, - * write-time guard the keyboard path reaches without going through that - * render at all. - */ - private boolean blockingFindingOpen(ReviewScope scope, ReviewIntent intent) { - return annotationStore.forScope(scope.id()).stream() - .filter(finding -> finding.intentId() - .map(id -> id.equals(intent.id()) || intent.touches(finding.file())) - .orElse(true)) - .anyMatch(ReviewAnnotation::blocksApproval); - } - /** * Sends {@code prompt} to the scope's bound session's live terminal. * False when there is no session or its tab is not open -- the caller 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 93237a06..107b71bf 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -124,12 +124,20 @@ public interface Host { * 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 intent} still comes along because the host - * refuses an approval over an open blocking finding, and that refusal - * is stated in terms of the intent (spec §4.6).

+ * 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); + Optional decision, boolean blocked); /** * "Confirm still good" (spec §9.2): rewrites each of {@code @@ -998,31 +1006,58 @@ 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.

*/ - 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); } /** @@ -1264,9 +1299,7 @@ private void renderVerdictBar(ReviewScope scope) { verdictBar.showActingUnit(settleUnit()); return; } - boolean blocked = host.findings(scope).stream() - .filter(this::belongsToCurrentIntent) - .anyMatch(ReviewAnnotation::blocksApproval); + 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. @@ -1410,13 +1443,15 @@ private final class VerdictHost implements ReviewVerdictBar.Host { @Override public void approve(ReviewIntent intent, SettleUnit unit) { selectedScope().ifPresent(scope -> host.setVerdict(scope, intent, - digestsForAction(intent, unit, false), Optional.of(ReviewVerdict.Decision.APPROVED))); + digestsForAction(intent, unit, false), Optional.of(ReviewVerdict.Decision.APPROVED), + blockingFindingOpen(scope, intent))); } @Override public void requestChanges(ReviewIntent intent, SettleUnit unit) { selectedScope().ifPresent(scope -> host.setVerdict(scope, intent, - digestsForAction(intent, unit, false), Optional.of(ReviewVerdict.Decision.CHANGES))); + digestsForAction(intent, unit, false), Optional.of(ReviewVerdict.Decision.CHANGES), + blockingFindingOpen(scope, intent))); } @Override @@ -1431,9 +1466,12 @@ public void askAgentToFix(ReviewIntent intent) { @Override public void undo(ReviewIntent intent) { // Re-review, too (spec §9.2): a stale section's banner button and - // the plain undo button both just clear what is recorded. + // 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. selectedScope().ifPresent(scope -> - host.setVerdict(scope, intent, digestsOf(intent), Optional.empty())); + host.setVerdict(scope, intent, digestsOf(intent), Optional.empty(), false)); } @Override @@ -1703,7 +1741,8 @@ private void verdictAction(ReviewVerdict.Decision decision, boolean wholeFile) { if (digests.isEmpty()) { return; } - host.setVerdict(scope.get(), intent.get(), digests, Optional.of(decision)); + 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) { @@ -1752,7 +1791,9 @@ private void undoVerdict() { // sane to undo or jump to. return; } - host.setVerdict(scope.get(), current.get(index), digests, 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(); diff --git a/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java b/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java index 49cf98ed..f5708fcd 100644 --- a/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java +++ b/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java @@ -500,7 +500,7 @@ public Optional verdict(ReviewScope scope, String hunkDigest) { @Override public void setVerdict(ReviewScope scope, ReviewIntent intent, List hunkDigests, - Optional decision) { + Optional decision, boolean blocked) { } @Override 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..d25f51f7 --- /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.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()); + stage.setScene(scene); + stage.show(); + } + + @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/FakeReviewHost.java b/app/src/test/java/app/drydock/ui/review/FakeReviewHost.java index fe3b1a9d..a07b474c 100644 --- a/app/src/test/java/app/drydock/ui/review/FakeReviewHost.java +++ b/app/src/test/java/app/drydock/ui/review/FakeReviewHost.java @@ -126,14 +126,14 @@ public Optional verdict(ReviewScope scope, String hunkDigest) { @Override public void setVerdict(ReviewScope scope, ReviewIntent intent, List hunkDigests, - Optional decision) { + Optional decision, boolean blocked) { if (decision.isEmpty()) { 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; } for (String digest : hunkDigests) { @@ -161,12 +161,6 @@ public BaseMove.Delta baseMove(ReviewScope scope, String recordedBase) { return baseDelta; } - 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 setResolved(ReviewScope scope, ReviewAnnotation finding, boolean resolved) { store.mutate(finding.key(), current -> current.withStatus( From 0803a3ad9d9c7f916d49ca586559450b6ebe6761 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 14:02:02 +0200 Subject: [PATCH 055/113] The provisional-grouping notice gets its own row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Appended to the header's hint, "· refining grouping…" pushed a ~32-char string into the ~154px the header's padding, chevron and title leave out of the rail's 232px (196px narrow) width -- ~190px of text at 10px, both labels defaulting to ELLIPSIS overrun, so the notice truncated itself and could eat the settled/counted counter beside it. This project has shipped exactly that kind of truncation once before, caught only by a screenshot. pendingBanner is now its own wrapped Label between the header and the card list, sized to its content rather than sharing a line with anything else. Captured via the scene-snapshot route at both 232px and 196px (screenshots not committed, reproducible from ReviewIntentRail directly): the notice reads cleanly on its own line at both widths, with nothing truncated or crowded out. --- .../drydock/ui/review/ReviewIntentRail.java | 32 ++++++++++++++++--- app/src/main/resources/app/drydock/ui/app.css | 8 +++++ 2 files changed, 35 insertions(+), 5 deletions(-) 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 7d033bcd..1b46e6cb 100644 --- a/app/src/main/java/app/drydock/ui/review/ReviewIntentRail.java +++ b/app/src/main/java/app/drydock/ui/review/ReviewIntentRail.java @@ -59,6 +59,19 @@ 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 List intents = List.of(); @@ -79,8 +92,9 @@ final class ReviewIntentRail extends VBox { * 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 - * in the header's hint rather than silently, so a reviewer mid-read is - * not surprised by cards changing under them with no warning at all. + * 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; @@ -102,7 +116,12 @@ 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) { @@ -263,8 +282,11 @@ private void rebuild() { .filter(ReviewIntent::countsTowardProgress) .filter(intent -> stateLookup.apply(intent).decision().isPresent()) .count(); - header.setHint(settled + "/" + counted + " · i" - + (groupingPending ? " · refining grouping…" : "")); + header.setHint(settled + "/" + counted + " · i"); + + boolean showBanner = groupingPending && !collapsed; + pendingBanner.setManaged(showBanner); + pendingBanner.setVisible(showBanner); buttonsByIntentId.clear(); List nodes = new ArrayList<>(); diff --git a/app/src/main/resources/app/drydock/ui/app.css b/app/src/main/resources/app/drydock/ui/app.css index f8701f59..1763946d 100644 --- a/app/src/main/resources/app/drydock/ui/app.css +++ b/app/src/main/resources/app/drydock/ui/app.css @@ -3159,6 +3159,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; From 3436326935e62068bfdc4800179a5f0891581ca7 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 14:07:59 +0200 Subject: [PATCH 056/113] Pin content-derived ids against an actual reorder, not just reproducibility theSameSectionMintsTheSameIdAcrossASeparateRebuild and computedSectionsMintDistinctContentDerivedIds both pass unchanged against the old positional "computed:" + number scheme too -- the same diff yields the same section order every time, so neither test ever exercised the defect the commit fixed. Added the discriminating case: the same sections reached through a DIFFERENT topological order. An unrelated extra file that sorts before 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. Verified this actually discriminates by temporarily reverting computedId to the positional scheme: the new test fails there (a.cpp's id is no longer found after the reorder) and passes against the real fix. --- .../drydock/review/IntentGroupingTest.java | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/app/src/test/java/app/drydock/review/IntentGroupingTest.java b/app/src/test/java/app/drydock/review/IntentGroupingTest.java index 052480c0..459ab1bc 100644 --- a/app/src/test/java/app/drydock/review/IntentGroupingTest.java +++ b/app/src/test/java/app/drydock/review/IntentGroupingTest.java @@ -9,6 +9,7 @@ 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; /** @@ -69,6 +70,46 @@ void theSameSectionMintsTheSameIdAcrossASeparateRebuild() { "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"); + } + // ---- nothing structural: the fallback's own ids survive ----------------- @Test From 5294aa1dfbdd90b6945a1ab134ca73a91544fb15 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 14:12:19 +0200 Subject: [PATCH 057/113] A computed id hashes the section's files too, not just its hunks UnifiedDiff carries no hunks at all for a binary file or a pure rename, so a section built from one alone hashed an EMPTY hunk list -- and any two such sections, sharing that same empty list, minted the identical id. Positional ids could never collide this way; ReviewIntentRail's own buttonsByIntentId (keyed by id) would keep only one of the two, silently dropping the other from the rail's lookup. Files and hunks are now hashed separately and the two digests concatenated, rather than joined into one string with a separator -- one less thing to get exactly right. Confirmed with two hunkless sections that previously would have minted the same id: they now mint two different ones. --- .../app/drydock/review/IntentGrouping.java | 22 +++++++++-- .../drydock/review/IntentGroupingTest.java | 38 +++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/app/drydock/review/IntentGrouping.java b/app/src/main/java/app/drydock/review/IntentGrouping.java index fb9c7295..a2387003 100644 --- a/app/src/main/java/app/drydock/review/IntentGrouping.java +++ b/app/src/main/java/app/drydock/review/IntentGrouping.java @@ -198,11 +198,27 @@ private static ReviewIntent.Risk riskOf(Sections.Section section, Map + * + *

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 sorted = new ArrayList<>(section.hunkIds()); - Collections.sort(sorted); - return "computed:" + sha256Hex(String.join("\n", sorted)).substring(0, 16); + 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 the two digests are + // concatenated -- rather than joined into one string with a + // separator, which is one more thing to get exactly right. Each + // digest is already unambiguous within its own sorted, newline- + // joined list, so nothing is lost by keeping them apart. + String files = sha256Hex(String.join("\n", sortedFiles)); + String hunks = sha256Hex(String.join("\n", sortedHunks)); + return "computed:" + (files + hunks).substring(0, 16); } private static String sha256Hex(String material) { diff --git a/app/src/test/java/app/drydock/review/IntentGroupingTest.java b/app/src/test/java/app/drydock/review/IntentGroupingTest.java index 459ab1bc..237b5060 100644 --- a/app/src/test/java/app/drydock/review/IntentGroupingTest.java +++ b/app/src/test/java/app/drydock/review/IntentGroupingTest.java @@ -110,6 +110,44 @@ void aSurvivingSectionKeepsItsIdAfterAnUnrelatedFileShiftsTheOrder() { "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 From 44daed4c4f96dd043cf5a5161c57c0db72183185 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 14:16:56 +0200 Subject: [PATCH 058/113] A failed graph build is retried, logged, and stops claiming to refine Two related regressions from the re-graph guard: graphedDiffByScope records a scope's diff BEFORE the build runs, and nothing cleared it on failure -- so a failed parse made every later republish of that same diff instance a permanent no-op, stuck on the fallback forever, since requestGraph's own "already graphed" check saw no reason to try again. And the callback cleared graphBuilding on any settled (non-stale) result but only called refreshReviewState() on success, so a failure left the rail's "refining grouping..." banner showing until some unrelated store change happened to repaint it. Both share one cause: the callback returned before reaching the refresh for every path except success. Restructured so "settled" (this generation, not stale) is decided once, and success/failure only change what gets recorded -- graphedDiffByScope is cleared and the failure logged only on failure, but the rail refresh happens either way. Not covered by a test: ChangeGraph.of is deliberately hard to make fail synthetically (SymbolScan falls back to a lexical scan rather than throwing for almost everything), and forcing a real failure without a test seam felt like more surgery on production code than this fix warrants. --- .../drydock/ui/review/SessionReviewView.java | 51 ++++++++++++------- 1 file changed, 33 insertions(+), 18 deletions(-) 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 107b71bf..ee1dbf7b 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -30,6 +30,8 @@ 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.util.ArrayList; import java.util.HashMap; @@ -62,6 +64,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 { @@ -1131,26 +1135,37 @@ private void requestGraph(String scopeId, UnifiedDiff diff) { } Platform.runLater(() -> { boolean current = Objects.equals(graphGenerationByScope.get(scopeId), generation); - if (current) { - // Only the CURRENT generation clears "building": - // a stale callback's own build really is done, - // but a newer one superseded it before this one - // arrived and is presumably still in flight, so - // the rail should still say so. - graphBuilding.remove(scopeId); - } - if (closed || failure != null || !current) { - // Either the parse failed -- in which case the - // (kind, directory) fallback is the honest - // answer, not a broken rail -- or a newer diff - // for this scope started a second build before - // this one finished, and publishing a graph for - // a diff no longer on screen would be worse - // than the fallback it displaced. + 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; } - graphByScope.put(scopeId, graph); - if (selectedScope().map(scope -> scope.id().equals(scopeId)).orElse(false)) { + // 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); + } 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(); } }); From 99d2b5e8952bed13ea3cfc92474cf495cb2f9cd8 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 14:57:51 +0200 Subject: [PATCH 059/113] The intents cache survives across refreshes, not just within one Every navigation keypress ([, ], n, a, r, u) ends in refreshReviewState(), and until now that meant a fresh Sections.of every time -- the previous per-refresh cache only deduplicated calls WITHIN one pass, not across them, so the rail cost one full Sections.of run (plus rebuilding its own card nodes) on every single keypress. Measured on this branch's own 57-file diff: ~350-400ms per refresh, roughly 3fps while stepping the rail. IntentGrouping already had notifyChanged/addChangeListener with zero subscribers -- exactly the signal needed to know when the one thing that can change intentsFor's answer without diff or graph changing (a reviewer's own set/clear) actually did. It now tracks a per-scope version, bumped on every change, and the cache key grows that fourth field in place of the blunt "recompute once per refresh" generation counter: unchanged (scope, diff, graph, version) is now exactly as fresh a claim across ANY number of refreshes as it was within one. Pinned by a test asserting REFERENCE equality of two calls' result -- an equals() check would pass even for a full recomputation that happened to produce the same content, which is exactly the gap the old per-refresh design left. Verifying this exposed a real, separate defect: ReviewSettleActionsTest started flaking under the full suite (reproduced via A/B: 2/3 failures with this change, 0/2 without). Root cause -- pre-existing, not caused by the cache -- a scope with an ALREADY-SUPPLIED reviewer grouping still requested a background ChangeGraph it would never use, and that build's own completion could fire an unpredictable extra refresh racing the test's own focus-driven one. Since a reviewer's grouping always wins over the computed sections regardless, building the graph for one is pure waste -- real parsing work and a needless async refresh -- so onDiffResolved now skips requestGraph entirely when Host.hasReviewerGrouping says one already exists. Confirmed clean across 4 consecutive full-scope reruns afterward. --- .../app/drydock/review/IntentGrouping.java | 22 ++++ .../java/app/drydock/ui/MainWorkspace.java | 10 ++ .../drydock/ui/review/SessionReviewView.java | 120 +++++++++++++----- .../ui/OpenSessionTabReviewSubTabTest.java | 10 ++ .../app/drydock/ui/review/FakeReviewHost.java | 10 ++ .../ui/review/SectionRailSwapTest.java | 77 +++++++++++ 6 files changed, 216 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/app/drydock/review/IntentGrouping.java b/app/src/main/java/app/drydock/review/IntentGrouping.java index a2387003..3318f562 100644 --- a/app/src/main/java/app/drydock/review/IntentGrouping.java +++ b/app/src/main/java/app/drydock/review/IntentGrouping.java @@ -33,6 +33,27 @@ public final class IntentGrouping { 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 @@ -310,6 +331,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/ui/MainWorkspace.java b/app/src/main/java/app/drydock/ui/MainWorkspace.java index f2b92632..77e96eb6 100644 --- a/app/src/main/java/app/drydock/ui/MainWorkspace.java +++ b/app/src/main/java/app/drydock/ui/MainWorkspace.java @@ -1976,6 +1976,16 @@ public List intents(ReviewScope scope, UnifiedDiff diff, 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, String hunkDigest) { return annotationStore.verdict(scope.id(), hunkDigest); 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 ee1dbf7b..38c93c23 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -110,6 +110,29 @@ public interface Host { */ 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 @@ -373,35 +396,35 @@ enum SettleUnit { private volatile boolean closed; /** - * {@link #intents()}'s last computed result, so one {@link - * #refreshReviewState()} pass computes {@code Sections.of} at most ONCE - * rather than once per internal caller. {@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 a single refresh -- - * measured at over a second of real work on this branch's own diff, ALL - * of it on the FX thread, which {@link Sections#of}'s own contract - * forbids. + * {@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. * - *

Bumped at the top of every {@link #refreshReviewState()} -- the - * entry point's own contract is "re-reads ... on every store change", so - * a cache that survived past one refresh would go stale exactly when a - * reviewer's grouping changed underneath it. Within the one pass that - * bump started, {@code scope}, {@code diff} and {@code graph} cannot - * change again (nothing here yields back to the FX event queue mid-pass), - * so reusing the cached list for the rest of it is exactly as fresh as - * recomputing would have been.

+ *

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(long generation, String scopeId, UnifiedDiff diff, - ChangeGraph graph, List intents) { + private record IntentsCacheEntry(String scopeId, UnifiedDiff diff, ChangeGraph graph, + long groupingVersion, List intents) { } - /** Bumped at the top of every {@link #refreshReviewState()}; see {@link #intentsCache}. */ - private long refreshGeneration; - /** The scopes this session offers, once {@link SessionReviewScopes} has measured them. */ private Optional scopes = Optional.empty(); @@ -558,9 +581,11 @@ public SessionReviewView(Host host, DiffService diffService, McpActivityLog acti // from an empty diff and never recovers. diffColumn.setOnDiffResolved((scopeId, outcome) -> { outcomeByScope.put(scopeId, outcome); - if (outcome instanceof DiffOutcome.Loaded loaded) { + if (outcome instanceof DiffOutcome.Loaded loaded + && scopeById(scopeId).map(candidate -> !host.hasReviewerGrouping(candidate)) + .orElse(true)) { requestGraph(scopeId, loaded.diff()); - } else { + } else if (!(outcome instanceof DiffOutcome.Loaded)) { graphByScope.remove(scopeId); } // Only the selected scope's arrival changes what is on screen; @@ -730,6 +755,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; @@ -890,12 +931,12 @@ public void refreshCounts() { * from a cached value silently discards the other writer's work. */ public void refreshReviewState() { - // Invalidates #intentsCache: this call is the "re-read from the - // store" contract above, so every intents() lookup this pass makes - // must recompute at least once, even if scope/diff/graph are - // unchanged from the last pass -- a reviewer's grouping can have - // changed without any of those changing. - refreshGeneration++; + // #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(); @@ -1089,13 +1130,14 @@ private List intents() { 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.generation() == refreshGeneration - && cached.scopeId().equals(scopeId) && cached.diff() == diff && cached.graph() == graph) { + if (cached != null && cached.scopeId().equals(scopeId) && cached.diff() == diff + && cached.graph() == graph && cached.groupingVersion() == groupingVersion) { return cached.intents(); } List computed = host.intents(scope.get(), diff, Optional.ofNullable(graph)); - intentsCache = new IntentsCacheEntry(refreshGeneration, scopeId, diff, graph, computed); + intentsCache = new IntentsCacheEntry(scopeId, diff, graph, groupingVersion, computed); return computed; } @@ -2086,6 +2128,18 @@ 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/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java b/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java index f5708fcd..7720bdae 100644 --- a/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java +++ b/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java @@ -493,6 +493,16 @@ public List intents(ReviewScope scope, UnifiedDiff diff, return List.of(); } + @Override + 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(); 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 a07b474c..cdd76d79 100644 --- a/app/src/test/java/app/drydock/ui/review/FakeReviewHost.java +++ b/app/src/test/java/app/drydock/ui/review/FakeReviewHost.java @@ -119,6 +119,16 @@ public List intents(ReviewScope scope, UnifiedDiff diff, 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, String hunkDigest) { return store.verdict(scope.id(), hunkDigest); diff --git a/app/src/test/java/app/drydock/ui/review/SectionRailSwapTest.java b/app/src/test/java/app/drydock/ui/review/SectionRailSwapTest.java index 206771a8..dc937b41 100644 --- a/app/src/test/java/app/drydock/ui/review/SectionRailSwapTest.java +++ b/app/src/test/java/app/drydock/ui/review/SectionRailSwapTest.java @@ -2,9 +2,11 @@ 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; @@ -22,6 +24,8 @@ 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; /** @@ -112,6 +116,79 @@ void theRailSettlesOnTheComputedGroupingWithDistinctContentDerivedIds() { 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()); } From 5f0dcec1f0975f2f27bdb6d2194bbc70e2987dc0 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 15:16:34 +0200 Subject: [PATCH 060/113] A computed section's id now genuinely depends on its hunk digest too substring(0, 16) on the concatenated files+hunks digest kept only the leading files digest and discarded the hunks digest entirely, so the id was a pure function of the file set -- the opposite of what the removed comment claimed. Three sections over the same single file with 0, 1 and 3 hunks all minted the identical id. Taking 8 hex characters from each digest instead keeps both properties: two hunkless sections still mint distinct ids (the file digest differs), and a section keeps its id across a reorder that leaves its own hunks untouched (the hunk digest is unchanged). --- .../java/app/drydock/review/IntentGrouping.java | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/app/drydock/review/IntentGrouping.java b/app/src/main/java/app/drydock/review/IntentGrouping.java index 3318f562..d4e8d818 100644 --- a/app/src/main/java/app/drydock/review/IntentGrouping.java +++ b/app/src/main/java/app/drydock/review/IntentGrouping.java @@ -232,14 +232,17 @@ private static String computedId(Sections.Section section) { Collections.sort(sortedFiles); List sortedHunks = new ArrayList<>(section.hunkIds()); Collections.sort(sortedHunks); - // Files and hunks are hashed SEPARATELY, then the two digests are - // concatenated -- rather than joined into one string with a - // separator, which is one more thing to get exactly right. Each - // digest is already unambiguous within its own sorted, newline- - // joined list, so nothing is lost by keeping them apart. + // 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 + hunks).substring(0, 16); + return "computed:" + files.substring(0, 8) + hunks.substring(0, 8); } private static String sha256Hex(String material) { From 98cbbe69b801c03aa23b5f91ebcecd609407ba2b Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 15:16:40 +0200 Subject: [PATCH 061/113] belongsToIntent's javadoc admits the safety relaxation it made The write-path filter this method replaced ended in .orElse(true): an unnamed blocking finding blocked approval of every intent. belongsToIntent has no such clause, so an unnamed finding now blocks only the intents whose files it touches -- consistent with what the verdict bar already rendered, but a real change in when approval is refused that the method's own documentation did not say. --- .../java/app/drydock/ui/review/SessionReviewView.java | 9 +++++++++ 1 file changed, 9 insertions(+) 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 38c93c23..6cb262b9 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -1078,6 +1078,15 @@ private boolean belongsToCurrentIntent(ReviewAnnotation finding) { * 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 belongsToIntent(ReviewAnnotation finding, ReviewIntent intent) { if (intent == null) { From cc7d34ccf7c5b01eecff5baceb623342132f70cd Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 15:24:56 +0200 Subject: [PATCH 062/113] 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. No cheap access to a ChangeGraph exists yet: the UI's own cache (SessionReviewView.graphByScope) is private to a review tab that may never have been opened, so McpSessionContext has nothing to reuse. Building the graph inline is therefore the only route today, and it is gated twice to keep it from becoming a hidden cost: only when include names "sections", and only on the cursor-absent (first) page of a paged read, so a multi-page read does not re-parse the same diff once per page for a payload that would not have changed anyway. --- .../java/app/drydock/mcp/McpToolRouter.java | 33 ++++++- .../java/app/drydock/mcp/ReviewToolCodec.java | 22 +++++ .../app/drydock/mcp/McpRouterFixture.java | 90 +++++++++++++++++++ .../mcp/McpToolRouterSectionsTest.java | 38 ++++++++ 4 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 app/src/test/java/app/drydock/mcp/McpRouterFixture.java create mode 100644 app/src/test/java/app/drydock/mcp/McpToolRouterSectionsTest.java diff --git a/app/src/main/java/app/drydock/mcp/McpToolRouter.java b/app/src/main/java/app/drydock/mcp/McpToolRouter.java index 99a195bf..003fc7b2 100644 --- a/app/src/main/java/app/drydock/mcp/McpToolRouter.java +++ b/app/src/main/java/app/drydock/mcp/McpToolRouter.java @@ -7,11 +7,13 @@ 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.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.VerdictMerge; import app.drydock.state.json.JsonValue; @@ -100,7 +102,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, " @@ -262,9 +267,9 @@ 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); + ReviewToolCodec.ScopePage page = ReviewToolCodec.pageHunks(diff, cursor, maxBytes); JsonObject result = JsonObject.empty() .put("scope", ReviewToolCodec.scopeToJson(scope)) @@ -281,9 +286,31 @@ private JsonValue reviewScope(ManagedSessionId caller, JsonValue arguments) thro result.put("priorThreads", new JsonArray(context.findingsOf(scope.id()).stream() .map(ReviewToolCodec::findingStateToJson) .toList())); + + // 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. + if (cursor.isEmpty() && includesSections(args)) { + result.put("sections", ReviewToolCodec.sectionsToJson(Sections.of(diff, ChangeGraph.of(diff)))); + } return result; } + /** + * 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); diff --git a/app/src/main/java/app/drydock/mcp/ReviewToolCodec.java b/app/src/main/java/app/drydock/mcp/ReviewToolCodec.java index 19e7edf3..e77dfa1b 100644 --- a/app/src/main/java/app/drydock/mcp/ReviewToolCodec.java +++ b/app/src/main/java/app/drydock/mcp/ReviewToolCodec.java @@ -6,6 +6,7 @@ import app.drydock.review.ReviewAnnotation; import app.drydock.review.ReviewIntent; import app.drydock.review.ReviewScope; +import app.drydock.review.Sections; import app.drydock.review.Severity; import app.drydock.state.json.JsonValue; import app.drydock.state.json.JsonValue.JsonArray; @@ -225,6 +226,27 @@ private 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 { 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..fd1dc0cc --- /dev/null +++ b/app/src/test/java/app/drydock/mcp/McpRouterFixture.java @@ -0,0 +1,90 @@ +package app.drydock.mcp; + +import app.drydock.domain.ManagedSessionId; +import app.drydock.git.UnifiedDiff; +import app.drydock.mcp.McpSessionRegistry.Spawn; +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; + +/** + * 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. + */ +class McpRouterFixture { + + private static final String SCOPE = "rs_sections"; + + private final ManagedSessionId caller = ManagedSessionId.newId(); + 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")); + McpSessionRegistry registry = new McpSessionRegistry(); + registry.mint(caller, Spawn.ALLOWED); + router = new McpToolRouter(context, registry); + + context.grant(caller, SCOPE); + context.reviewScopes.put(SCOPE, new ReviewScope(SCOPE, ReviewScope.Kind.WORKTREE, + Path.of("/repos/drydock"), Optional.of(Path.of("/wt/feat")), "master", "feat", + Optional.empty(), Optional.empty(), Optional.empty())); + context.reviewDiff = parseableDiff(); + } + + String scopeId() { + return SCOPE; + } + + /** Calls {@code review_scope}, returning the raw JSON response as a string. */ + String callReviewScope(String scopeId, String include) { + JsonObject args = JsonObject.empty().put("scopeId", new JsonString(scopeId)); + if (include != null) { + args.put("include", new JsonString(include)); + } + try { + JsonValue result = router.call(callerId(), "review_scope", args); + return JsonWriter.write(result); + } catch (McpToolException e) { + throw new AssertionError(e); + } + } + + ManagedSessionId callerId() { + return caller; + } + + /** A single-file diff with real Java, so {@code ChangeGraph} finds a hub symbol to name a section after. */ + private static UnifiedDiff parseableDiff() { + List lines = new ArrayList<>(); + String[] added = { + "public class Widget {", + " void run() {", + " System.out.println(\"hi\");", + " }", + "}", + }; + 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(List.of(new UnifiedDiff.FileDiff("src/Widget.java", "A", + added.length, 0, false, false, List.of(new UnifiedDiff.Hunk("@@", lines))))); + } +} 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..e81d6fae --- /dev/null +++ b/app/src/test/java/app/drydock/mcp/McpToolRouterSectionsTest.java @@ -0,0 +1,38 @@ +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\"")); + } +} From 18dea225a6ed05b42702a8c47d40a36c6bc91fda Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 15:43:34 +0200 Subject: [PATCH 063/113] review_scope charges sections against the budget and degrades gracefully Three gaps a review found in the first pass, adjudicated rather than self-certified. The cost-safety property (graph built at most once, only when asked) had no test that could actually fail on it: the omission tests only checked the wire, which cannot tell "never built" from "built and discarded", and the fixture had no way to express a later page at all. McpToolRouter gains a package-private test-seam constructor (mirrors GitStatusService's ssh-executable one) so a test can count real builds and force a build to fail, without a mocking library. McpRouterFixture now wires a counting graphBuilder and exposes cursor-aware calls; four new tests cover a later page, the byte-budget charge, an over-budget grouping, and a build failure. Sections overlap by design -- a shared foundation file repeats in every section that needs it -- so the payload scales as sections x shared files, not by file count, unlike scope/files/priorThreads. It is now charged against the same budget hunks pays from rather than added on top unaccounted; hunks get whatever remains. When the grouping alone outgrows the whole budget it is still emitted in full -- truncating it mid-array would be worse than a blown budget -- but sectionsOverBudget makes the overage visible rather than silent. Measured on two synthetic diffs: a 2-file cross-referencing pair encodes at ~400 bytes; nine files sharing one foundation (spec 5.6's repeat-per-section case) run to ~1.9KB, already past the router's own 1_000-byte maxBytes floor. ChangeGraph.of/SymbolScan can throw unchecked on a parse edge case. That failure is now caught in computeSections, logged at WARNING with the scope id, and only sections is omitted -- hunks, scope and files still return. Settled precedent: review_state already degrades the same way when its own diff dependency fails. --- .../java/app/drydock/mcp/McpToolRouter.java | 71 ++++++++-- .../java/app/drydock/mcp/ReviewToolCodec.java | 6 +- .../app/drydock/mcp/McpRouterFixture.java | 95 +++++++++++-- .../mcp/McpToolRouterSectionsTest.java | 131 ++++++++++++++++++ 4 files changed, 280 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/app/drydock/mcp/McpToolRouter.java b/app/src/main/java/app/drydock/mcp/McpToolRouter.java index 003fc7b2..d279601b 100644 --- a/app/src/main/java/app/drydock/mcp/McpToolRouter.java +++ b/app/src/main/java/app/drydock/mcp/McpToolRouter.java @@ -15,6 +15,7 @@ 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; @@ -33,6 +34,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.function.Function; import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Stream; @@ -65,10 +67,24 @@ public final class McpToolRouter { private final McpSessionContext context; private final McpSessionRegistry registry; + private final Function graphBuilder; 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() { @@ -269,7 +285,24 @@ private JsonValue reviewScope(ManagedSessionId caller, JsonValue arguments) thro 1_000, MAX_SCOPE_BYTES); Optional cursor = optionalStringArg(args, "cursor"); UnifiedDiff diff = context.reviewDiff(scope); - ReviewToolCodec.ScopePage page = ReviewToolCodec.pageHunks(diff, 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)) @@ -287,17 +320,39 @@ private JsonValue reviewScope(ManagedSessionId caller, JsonValue arguments) thro .map(ReviewToolCodec::findingStateToJson) .toList())); - // 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. - if (cursor.isEmpty() && includesSections(args)) { - result.put("sections", ReviewToolCodec.sectionsToJson(Sections.of(diff, ChangeGraph.of(diff)))); + 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}. + */ + private Optional computeSections(ReviewScope scope, UnifiedDiff diff) { + try { + return Optional.of(ReviewToolCodec.sectionsToJson( + Sections.of(diff, graphBuilder.apply(diff)))); + } 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 diff --git a/app/src/main/java/app/drydock/mcp/ReviewToolCodec.java b/app/src/main/java/app/drydock/mcp/ReviewToolCodec.java index e77dfa1b..4091301c 100644 --- a/app/src/main/java/app/drydock/mcp/ReviewToolCodec.java +++ b/app/src/main/java/app/drydock/mcp/ReviewToolCodec.java @@ -218,11 +218,13 @@ 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; } diff --git a/app/src/test/java/app/drydock/mcp/McpRouterFixture.java b/app/src/test/java/app/drydock/mcp/McpRouterFixture.java index fd1dc0cc..7b3487ff 100644 --- a/app/src/test/java/app/drydock/mcp/McpRouterFixture.java +++ b/app/src/test/java/app/drydock/mcp/McpRouterFixture.java @@ -3,6 +3,7 @@ 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; @@ -15,6 +16,9 @@ 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 @@ -22,12 +26,20 @@ * 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; @@ -36,9 +48,12 @@ void setUpFixture() { context = new FakeMcpSessionContext(); context.repositoryRoot = Optional.of(Path.of("/repos/drydock")); context.worktreePath = Optional.of(Path.of("/repos/drydock")); - McpSessionRegistry registry = new McpSessionRegistry(); + registry = new McpSessionRegistry(); registry.mint(caller, Spawn.ALLOWED); - router = new McpToolRouter(context, registry); + router = new McpToolRouter(context, registry, diff -> { + graphBuilds.incrementAndGet(); + return ChangeGraph.of(diff); + }); context.grant(caller, SCOPE); context.reviewScopes.put(SCOPE, new ReviewScope(SCOPE, ReviewScope.Kind.WORKTREE, @@ -51,40 +66,94 @@ String scopeId() { return SCOPE; } - /** Calls {@code review_scope}, returning the raw JSON response as a string. */ + /** 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) { - JsonObject args = JsonObject.empty().put("scopeId", new JsonString(scopeId)); + 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 { - JsonValue result = router.call(callerId(), "review_scope", args); - return JsonWriter.write(result); + 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; } - /** A single-file diff with real Java, so {@code ChangeGraph} finds a hub symbol to name a section after. */ + /** + * 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() { - List lines = new ArrayList<>(); - String[] added = { + 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(List.of(new UnifiedDiff.FileDiff("src/Widget.java", "A", - added.length, 0, false, false, List.of(new UnifiedDiff.Hunk("@@", lines))))); + 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/McpToolRouterSectionsTest.java b/app/src/test/java/app/drydock/mcp/McpToolRouterSectionsTest.java index e81d6fae..595d7305 100644 --- a/app/src/test/java/app/drydock/mcp/McpToolRouterSectionsTest.java +++ b/app/src/test/java/app/drydock/mcp/McpToolRouterSectionsTest.java @@ -1,8 +1,21 @@ 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 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; /** @@ -18,6 +31,7 @@ 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 @@ -26,6 +40,7 @@ void reviewScopeIncludesSectionsWhenAsked() { assertTrue(response.contains("\"sections\"")); assertTrue(response.contains("\"hunkIds\"")); + assertEquals(1, graphBuilds()); } /** An unknown include is ignored, not an error: it is an optional read. */ @@ -34,5 +49,121 @@ 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")); + } + + // ---- fixtures ----------------------------------------------------------- + + /** + * 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))); } } From b91d8e1ae6a848eef3c06bfa247d4b93f5f09348 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 15:55:06 +0200 Subject: [PATCH 064/113] Staleness also notices a base move in a file a section only references The half deferred at Phase 1: a base commit touching a file a section 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 moved into SectionStates, which now carries the scope's cached graph on its Board and widens each section's file set with ChangeGraph.filesReferencedBy before asking couldMatter. Falls back to the section's own files when the graph is not already built -- widening is never a reason to parse one. 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 spec's boundary, and closing it is the agent recheck's job. --- .../app/drydock/ui/review/SectionStates.java | 46 +++++++++++++++++-- .../drydock/ui/review/SessionReviewView.java | 3 +- .../drydock/ui/review/SectionStatesTest.java | 38 ++++++++++++++- 3 files changed, 80 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/app/drydock/ui/review/SectionStates.java b/app/src/main/java/app/drydock/ui/review/SectionStates.java index 5c11d9b3..af1fa253 100644 --- a/app/src/main/java/app/drydock/ui/review/SectionStates.java +++ b/app/src/main/java/app/drydock/ui/review/SectionStates.java @@ -2,6 +2,7 @@ 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.ReviewIntent; @@ -10,6 +11,7 @@ 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; @@ -17,6 +19,8 @@ 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 @@ -122,13 +126,25 @@ static SectionState notInDiff() { * 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) { + 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; @@ -243,7 +259,7 @@ private Set staleDigests(Board board) { Set stale = new LinkedHashSet<>(); String base = host.currentBase(board.scope()); for (ReviewIntent intent : counted(board)) { - List files = filesOf(board, intent); + Collection files = filesAffectingScope(board, intent); for (String digest : digestsOf(board, intent)) { if (stale.contains(digest)) { continue; @@ -284,7 +300,7 @@ SectionState stateOf(Board board, ReviewIntent intent) { : SectionState.notInDiff(); } String base = host.currentBase(board.scope()); - List files = filesOf(board, intent); + Collection files = filesAffectingScope(board, intent); List> perHunk = new ArrayList<>(); Set elsewhere = new LinkedHashSet<>(); Staleness staleness = Staleness.FRESH; @@ -326,7 +342,7 @@ SectionState stateOf(Board board, ReviewIntent intent) { * move and must not be rendered as one. */ private Staleness stalenessOf(Board board, ReviewVerdict verdict, String base, - List files) { + Collection files) { if (!verdict.staleAgainst(base)) { return Staleness.FRESH; } @@ -337,6 +353,28 @@ private Staleness stalenessOf(Board board, ReviewVerdict verdict, String base, 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 marks of the OTHER sections sharing {@code digest}, so a count that * advanced without the reader touching this card is explained. 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 6cb262b9..e85d0734 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -1274,7 +1274,8 @@ private Optional loadedDiff() { */ private Optional board() { return selectedScope().flatMap(scope -> loadedDiff() - .map(diff -> new SectionStates.Board(scope, diff, intents()))); + .map(diff -> new SectionStates.Board(scope, diff, intents(), + Optional.ofNullable(graphByScope.get(scope.id()))))); } /** The content digests of the hunks {@code intent} covers; none without a diff. */ diff --git a/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java b/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java index 0b391e9a..7884c641 100644 --- a/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java +++ b/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java @@ -2,6 +2,7 @@ import app.drydock.git.UnifiedDiff; import app.drydock.review.BaseMove; +import app.drydock.review.ChangeGraph; import app.drydock.review.HunkDigest; import app.drydock.review.ReviewIntent; import app.drydock.review.ReviewScope; @@ -237,6 +238,30 @@ void aKnownMoveOutranksAnUnknownOne() { 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 -------------------------------- /** @@ -505,12 +530,21 @@ private void approve(String file) { } private void record(String file, ReviewVerdict.Decision decision, String base) { - host.store.putVerdict(new ReviewVerdict(scope.id(), digestOf(file), decision, + 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 diff.files().stream() + 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))) From 9c3f0a1601255be52e113e778f035e8aaee700cd Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 17:09:47 +0200 Subject: [PATCH 065/113] One bounded git grep recovers the strongest entry-point signal 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. Plain git grep -n 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. -z avoids the quoting but changes the framing to filelinetext per match, not git diff's NUL-joined names, so parsing is written and tested against that shape directly -- confirmed against a real git binary and a scratch repo with a non-ASCII filename, not assumed from the diff case. 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. Co-Authored-By: Claude Opus 5 (1M context) --- .../app/drydock/review/OutOfDiffFanIn.java | 170 ++++++++++++++++ .../drydock/review/OutOfDiffFanInTest.java | 192 ++++++++++++++++++ 2 files changed, 362 insertions(+) create mode 100644 app/src/main/java/app/drydock/review/OutOfDiffFanIn.java create mode 100644 app/src/test/java/app/drydock/review/OutOfDiffFanInTest.java 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..124c568e --- /dev/null +++ b/app/src/main/java/app/drydock/review/OutOfDiffFanIn.java @@ -0,0 +1,170 @@ +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.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.SortedSet; +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). + * + *

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 that happens to contain the + * same text, the same trade this codebase already makes for an + * ungrammared file (see {@link SymbolScan}). Attributing an occurrence to + * every changed declaration whose name it textually contains -- rather than + * resolving which one, if any, it actually refers to -- can occasionally + * over-attribute when one changed symbol's name is a substring of + * another's; a repository-wide semantic index would not have this + * imprecision, which is exactly the cost this class is built to avoid + * paying.

+ * + *

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. */ + 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() { + } + + /** + * 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); + List command = List.of("git", "grep", "-z", "-n", "-F", "-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 -> occurrence.text().contains(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); + } + } + } + } + + /** + * 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/test/java/app/drydock/review/OutOfDiffFanInTest.java b/app/src/test/java/app/drydock/review/OutOfDiffFanInTest.java new file mode 100644 index 00000000..e0d665e3 --- /dev/null +++ b/app/src/test/java/app/drydock/review/OutOfDiffFanInTest.java @@ -0,0 +1,192 @@ +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()); + } + + 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); + } + } +} From 19a05e1ced8bf333a2f97955dfcd8ec5515dca3b Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 17:19:15 +0200 Subject: [PATCH 066/113] A real no-matches spawn pins exit code 1 as an empty answer The exit-code contract -- 1 is no-matches, a valid empty answer; anything above is a failure -- was only exercised through the could-not-launch path (a non-existent directory), never through a real git grep that actually runs and finds nothing. A regression narrowing > 1 to >= 1 would have passed every existing test while turning every ordinary empty result into unavailable, which is precisely the absent-versus-zero confusion this class exists to avoid. A new test builds a real committed repo and greps it for a symbol name that appears nowhere, so the distinction is pinned through an actual spawn, not just through reading the branch. Occurrence's javadoc now says explicitly that keeping the source line's leading whitespace is a choice for the popover's benefit, not a missed .strip() from the brief's sketch. Co-Authored-By: Claude Opus 5 (1M context) --- .../app/drydock/review/OutOfDiffFanIn.java | 8 ++++++- .../drydock/review/OutOfDiffFanInTest.java | 23 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/drydock/review/OutOfDiffFanIn.java b/app/src/main/java/app/drydock/review/OutOfDiffFanIn.java index 124c568e..af61acad 100644 --- a/app/src/main/java/app/drydock/review/OutOfDiffFanIn.java +++ b/app/src/main/java/app/drydock/review/OutOfDiffFanIn.java @@ -69,7 +69,13 @@ public final class OutOfDiffFanIn { private static final Duration TIMEOUT = Duration.ofSeconds(30); private static final char FIELD_SEPARATOR = '\0'; - /** One place {@code symbol} is used, outside the change. */ + /** + * 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) { } diff --git a/app/src/test/java/app/drydock/review/OutOfDiffFanInTest.java b/app/src/test/java/app/drydock/review/OutOfDiffFanInTest.java index e0d665e3..f0dee0a5 100644 --- a/app/src/test/java/app/drydock/review/OutOfDiffFanInTest.java +++ b/app/src/test/java/app/drydock/review/OutOfDiffFanInTest.java @@ -160,6 +160,29 @@ void aScopeWithNoChangedDeclarationsScansNothing(@TempDir Path dir) { 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()); + } + private static Path initCommittedRepoWithFanIn(Path parent) throws IOException, InterruptedException { Path repo = Files.createDirectories(parent.resolve("repo")); runGit(repo, "init", "-b", "main"); From 79fec4ff8be5444be9fad18e1537c3c9f54a0a21 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 17:41:03 +0200 Subject: [PATCH 067/113] 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. Marking is therefore not a pass at all: the first step emitted is the entry point, by construction. Six signals, in the spec's order: called from outside the change, then in-degree within it, then not-a-test, then not-a-leaf, then the fallback kind order, then the path. 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 file with nothing pointing into it. It ranks ahead of the kind order because the kind order is what the rank degrades to, not one of its signals. The path is what makes the comparator TOTAL, and total is not a nicety: Graphs keeps its ready set in a TreeSet ordered by it, so two units comparing equal would collapse into one and a unit would fall silently out of the path. Not-a-leaf is, at file granularity, exactly in-degree zero, so the signal ahead of it has already decided every case it could decide. It is written out anyway because it is one of the four the spec names; the task report records the mutation that proves it cannot fire. 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. Deduplication by target hunk is what makes the three kinds one relationship rather than three: the call is the more specific thing to say, so same-concept ends up meaning what it should -- two files using the same thing, neither defining it. A reason names a file rather than a section number when the edge stays inside one section: sections carry the files their unit depends on, so that is the common case, and "referenced by (1)" on a row that is itself in section 1 tells a reviewer nothing. An unavailable fan-in scan is not a scan that found nothing. It contributes no rank, and the one reason that would otherwise imply nothing outside the change uses this file says the outside callers are unknown instead. isTest is promoted rather than copied: two copies of that vocabulary drifted the last time they existed, which is why SymbolWords exists. Co-Authored-By: Claude Opus 5 (1M context) --- .../app/drydock/review/FallbackIntents.java | 15 +- .../java/app/drydock/review/ReadingPath.java | 434 ++++++++++++++++++ .../app/drydock/review/ReadingPathTest.java | 376 +++++++++++++++ 3 files changed, 824 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/app/drydock/review/ReadingPath.java create mode 100644 app/src/test/java/app/drydock/review/ReadingPathTest.java diff --git a/app/src/main/java/app/drydock/review/FallbackIntents.java b/app/src/main/java/app/drydock/review/FallbackIntents.java index bbe89e49..97c49455 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; @@ -226,6 +226,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/ReadingPath.java b/app/src/main/java/app/drydock/review/ReadingPath.java new file mode 100644 index 00000000..0945980e --- /dev/null +++ b/app/src/main/java/app/drydock/review/ReadingPath.java @@ -0,0 +1,434 @@ +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 not-a-leaf, then {@link + * FallbackIntents}' kind order, then the path. 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.

+ * + *

Not-a-leaf is, at file granularity, exactly "in-degree is zero", so the + * signal ahead of it has already decided every case it could decide. It is + * written out anyway because it is one of the four signals §6.2 names, and a + * chain that reads like the spec is worth more than one comparator step + * saved; see the task report for the finding.

+ * + *

Links are file-level. {@link SymbolScan.Symbol} does + * not carry a line, so nothing here can tell which hunk of a file a symbol + * sits in. Every hunk of a file therefore carries that file's links, and a + * link points at the first hunk of its target file. Narrowing this needs a + * line on {@code Symbol}, not a guess here.

+ * + *

{@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) { + public Link { + Objects.requireNonNull(kind, "kind"); + Objects.requireNonNull(targetHunkId, "targetHunkId"); + Objects.requireNonNull(label, "label"); + } + } + + /** + * One hunk, in reading order. {@code sectionNumber} is the 1-based place + * in the rail 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) { + public Step { + Objects.requireNonNull(hunkId, "hunkId"); + Objects.requireNonNull(file, "file"); + Objects.requireNonNull(reason, "reason"); + links = List.copyOf(links); + } + } + + /** + * {@code diff}'s hunks in reading order. 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 List 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 List.of(); + } + + Map sectionByHunk = sectionNumbers(sections); + Map fanInByFile = fanInByFile(graph, fanIn); + Map>> concepts = concepts(graph); + Comparator rank = rank(graph, fanInByFile); + + List> units = + Graphs.topologicalOrder(nodes, file -> dependencies(graph, nodes, file), rank); + + List steps = new ArrayList<>(); + for (List unit : units) { + for (String file : unit) { + UnifiedDiff.FileDiff fileDiff = byPath.get(file); + if (fileDiff == null) { + continue; + } + List links = linksFrom(file, graph, concepts, byPath, sectionByHunk); + String reason = reasonFor(file, graph, byPath, sectionByHunk, + fanInByFile.getOrDefault(file, 0), fanIn.unavailable()); + for (int hunk = 0; hunk < fileDiff.hunks().size(); hunk++) { + String hunkId = ReviewIntent.hunkId(file, hunk); + steps.add(new Step(hunkId, file, sectionByHunk.getOrDefault(hunkId, 0), + reason, links, steps.isEmpty())); + } + } + } + return List.copyOf(steps); + } + + // ---- 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 not-a-leaf, then the kind order, then the path. + * + *

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 -> graph.filesReferencing(file).isEmpty() ? 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 file}'s links, cross-file and deduplicated by target hunk. + * + *

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: a + * file that calls another and shares its symbol is one relationship, and + * the call is the more specific thing to say about it. It is also why + * same-concept ends up meaning what §2.2 wants -- two files that use the + * same thing without either defining it -- rather than restating every + * edge.

+ */ + private static List linksFrom(String file, ChangeGraph graph, + Map>> concepts, + Map byPath, + Map sectionByHunk) { + List links = new ArrayList<>(); + Set claimed = new LinkedHashSet<>(); + + for (String target : graph.filesReferencedBy(file)) { + // The symbols target declares that file uses: a real location in + // the target, so the label may point at it. + String symbol = best(sharedBetween(graph, target, file), graph); + addLink(links, claimed, byPath, sectionByHunk, CALLS, target, + symbol == null ? "" : ":" + symbol); + } + for (String source : graph.filesReferencing(file)) { + // The symbols file declares that source uses. They live HERE, not + // in the target, so the label says what the target does with them + // rather than pointing into it. + String symbol = best(sharedBetween(graph, file, source), graph); + addLink(links, claimed, byPath, sectionByHunk, CALLED_BY, source, + symbol == null ? "" : " · uses " + symbol); + } + Map> sharedWith = + concepts.getOrDefault(file, Map.of()); + for (Map.Entry> shared : sharedWith.entrySet()) { + String symbol = best(shared.getValue(), graph); + addLink(links, claimed, byPath, sectionByHunk, SAME_CONCEPT, shared.getKey(), + " · both touch " + symbol); + } + return List.copyOf(links); + } + + private static void addLink(List links, Set claimed, + Map byPath, + Map sectionByHunk, + String kind, String target, String suffix) { + UnifiedDiff.FileDiff targetDiff = byPath.get(target); + if (targetDiff == null || targetDiff.hunks().isEmpty()) { + // Nothing to click through to; a link to no hunk is a dead row. + return; + } + String hunkId = ReviewIntent.hunkId(target, 0); + if (!claimed.add(hunkId)) { + return; + } + String marker = marker(sectionByHunk.getOrDefault(hunkId, 0)); + String label = (marker.isEmpty() ? "" : marker + " ") + + FallbackIntents.fileName(target) + suffix; + links.add(new Link(kind, hunkId, label)); + } + + /** The changed names {@code declarer} declares and {@code user} references. */ + private static SortedSet sharedBetween(ChangeGraph graph, String declarer, + String user) { + SortedSet shared = new TreeSet<>(); + for (String symbol : graph.declarationsIn(declarer)) { + if (graph.filesReferencingSymbol(symbol).contains(user)) { + shared.add(symbol); + } + } + return shared; + } + + /** + * For each changed file, every other changed file it shares a changed + * symbol with and the names they share. A file touches a symbol by + * declaring it or by referencing it; the name has to be uniquely declared + * in the scope, which {@link ChangeGraph#changedDeclarations()} already + * guarantees -- the same test an edge passes (§4.2), so an ambiguous name + * links nothing. + * + *

Built once for the whole change rather than per file: the question + * is symmetric, and asking it file by file re-walks every changed + * declaration once per changed file.

+ */ + private static Map>> concepts(ChangeGraph graph) { + Map>> byFile = new TreeMap<>(); + for (String symbol : graph.changedDeclarations()) { + SortedSet touching = new TreeSet<>(graph.filesReferencingSymbol(symbol)); + graph.fileDeclaring(symbol).ifPresent(touching::add); + for (String file : touching) { + for (String other : touching) { + if (!other.equals(file)) { + byFile.computeIfAbsent(file, key -> new TreeMap<>()) + .computeIfAbsent(other, key -> new TreeSet<>()) + .add(symbol); + } + } + } + } + return byFile; + } + + /** + * 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 ----------------------------------------------------------- + + /** + * Each hunk's section number, 1-based. Sections overlap (§5.6), so a hunk + * can be in several; the first one wins, which is the one the reviewer + * meets first in the rail. + */ + 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/test/java/app/drydock/review/ReadingPathTest.java b/app/src/test/java/app/drydock/review/ReadingPathTest.java new file mode 100644 index 00000000..266d0b22 --- /dev/null +++ b/app/src/test/java/app/drydock/review/ReadingPathTest.java @@ -0,0 +1,376 @@ +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); + } + + 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 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, and neither is a leaf + * -- so the not-a-leaf signal cannot decide this one. + */ + @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)); + } + + @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()); + } + } + + /** 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())); + } + + @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() { + 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); + List sections = Sections.of(diff, graph); + + for (ReadingPath.Step step : ReadingPath.of(diff, graph, sections, NO_FAN_IN)) { + int expected = 0; + for (int index = 0; index < sections.size(); index++) { + if (sections.get(index).hunkIds().contains(step.hunkId())) { + expected = index + 1; + break; + } + } + assertNotEquals(0, expected, step.hunkId()); + assertEquals(expected, step.sectionNumber(), step.hunkId()); + } + } + + @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 ReadingPath.Step stepFor(List path, String file) { + Optional found = + path.stream().filter(step -> step.file().equals(file)).findFirst(); + return found.orElseThrow(); + } +} From 20c5648af26a58b76004f75765555e84a550928e Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 17:58:45 +0200 Subject: [PATCH 068/113] The entry-point rank drops the term that cannot discriminate Not-a-leaf is, at file granularity, exactly in-degree zero, so the term ahead of it has already decided every case it could decide: wherever leafness differs in-degree differs, and wherever in-degree ties leafness ties. Proved analytically and then empirically -- 300 generated diffs come out byte-identical with the term removed. A comparator step that provably cannot discriminate asserts a distinction that does not exist, and the javadoc having to apologise for it was the tell. The class javadoc now records why the spec's fourth signal is absent and what would make it real: redefining "leaf" at unit level, where a cycle's members each carry in-degree from inside the cycle while the unit as a whole is an endpoint. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/app/drydock/review/ReadingPath.java | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/app/drydock/review/ReadingPath.java b/app/src/main/java/app/drydock/review/ReadingPath.java index 0945980e..967b4ab9 100644 --- a/app/src/main/java/app/drydock/review/ReadingPath.java +++ b/app/src/main/java/app/drydock/review/ReadingPath.java @@ -41,11 +41,14 @@ * ranked ahead of the kind order deliberately (§6.1): the kind order is what * the rank degrades to, not one of its signals.

* - *

Not-a-leaf is, at file granularity, exactly "in-degree is zero", so the - * signal ahead of it has already decided every case it could decide. It is - * written out anyway because it is one of the four signals §6.2 names, and a - * chain that reads like the spec is worth more than one comparator step - * saved; see the task report for the finding.

+ *

§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.

* *

Links are file-level. {@link SymbolScan.Symbol} does * not carry a line, so nothing here can tell which hunk of a file a symbol @@ -160,7 +163,8 @@ public static List of(UnifiedDiff diff, ChangeGraph graph, /** * 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 not-a-leaf, then the kind order, then the path. + * 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.

@@ -170,7 +174,6 @@ private static Comparator rank(ChangeGraph graph, Map f .comparingInt((String file) -> -fanInByFile.getOrDefault(file, 0)) .thenComparingInt(file -> -graph.filesReferencing(file).size()) .thenComparingInt(file -> FallbackIntents.isTestPath(file) ? 1 : 0) - .thenComparingInt(file -> graph.filesReferencing(file).isEmpty() ? 1 : 0) .thenComparingInt( file -> FallbackIntents.readingOrder(FallbackIntents.kindOf(file))) .thenComparing(Comparator.naturalOrder()); From c9a8eedd34e692ec395cd99d80beb4b24141dcbc Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 18:08:04 +0200 Subject: [PATCH 069/113] The change graph answers per hunk as well as per file A symbol now carries the index of the hunk it sits in. That index is free -- SymbolScan.of is already looping hunks -- and it is the whole difference between "these two files are related" and a claim about one hunk. The graph gains declarationsIn(Hunk), referencesIn(Hunk), hunksDeclaring(symbol) and hunksReferencingSymbol(symbol) alongside the file-level view, which is untouched because grouping legitimately asks the coarser question and Sections depends on it. Both views are built from the same symbols in one pass and apply the same cross-file test, so they cannot drift apart about what an edge is. hunksDeclaring returns every hunk of the declaring file that declares the name, not one: a name unique to a FILE may still be declared in two of its hunks, and a link has to point at both rather than guess. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/app/drydock/review/ChangeGraph.java | 111 +++++++++++++++++- .../java/app/drydock/review/SymbolScan.java | 56 +++++---- .../app/drydock/review/ChangeGraphTest.java | 66 +++++++++++ .../app/drydock/review/SymbolScanTest.java | 50 ++++++++ 4 files changed, 259 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/app/drydock/review/ChangeGraph.java b/app/src/main/java/app/drydock/review/ChangeGraph.java index d75ea526..df5607c3 100644 --- a/app/src/main/java/app/drydock/review/ChangeGraph.java +++ b/app/src/main/java/app/drydock/review/ChangeGraph.java @@ -7,6 +7,7 @@ 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; @@ -21,6 +22,16 @@ * 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.

@@ -37,13 +48,48 @@ public final class ChangeGraph { 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> 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; @@ -67,6 +113,7 @@ public static ChangeGraph of(UnifiedDiff diff) { // 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()) { @@ -74,6 +121,10 @@ public static ChangeGraph of(UnifiedDiff diff) { .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()); } } } @@ -88,6 +139,9 @@ public static ChangeGraph of(UnifiedDiff diff) { 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 @@ -110,11 +164,36 @@ public static ChangeGraph of(UnifiedDiff diff) { 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); + return new ChangeGraph(files, declarationsByFile, unique, out, in, inBySymbol, + declarationsByHunk, referencesByHunk, hunksDeclaringSymbol, + hunksReferencingSymbol); } /** Every changed file, in this scope. */ @@ -159,7 +238,35 @@ 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/SymbolScan.java b/app/src/main/java/app/drydock/review/SymbolScan.java index 989ad02d..12c8dbc4 100644 --- a/app/src/main/java/app/drydock/review/SymbolScan.java +++ b/app/src/main/java/app/drydock/review/SymbolScan.java @@ -65,8 +65,16 @@ */ public final class SymbolScan { - /** One symbol occurrence. */ - public record Symbol(String name, String path, boolean declaration, boolean onChangedLine) { + /** + * 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) { } /** @@ -165,18 +173,19 @@ public static List of(UnifiedDiff.FileDiff file) { } Optional grammar = GrammarRegistry.forPath(file.path()); List symbols = new ArrayList<>(); - for (UnifiedDiff.Hunk hunk : file.hunks()) { + 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(), hunk, UnifiedDiff.Line.Kind.ADD, true, - symbols); - scanView(grammar.get(), file.path(), hunk, UnifiedDiff.Line.Kind.DEL, false, - symbols); + 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(), line.text(), isChanged(line)); + lexical(symbols, file.path(), index, line.text(), isChanged(line)); } } } @@ -226,9 +235,9 @@ private static boolean plausiblyCode(String path) { * 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, UnifiedDiff.Hunk hunk, - UnifiedDiff.Line.Kind changedKind, boolean reportContext, - List out) { + 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()) { @@ -261,12 +270,13 @@ private static void scanView(TSLanguage language, String path, UnifiedDiff.Hunk // look the same. for (int index = 0; index < lines.size(); index++) { if (fragment.reports(index)) { - lexical(out, path, lines.get(index).text(), fragment.changed(index)); + lexical(out, path, hunkIndex, lines.get(index).text(), + fragment.changed(index)); } } return; } - walk(tree.getRootNode(), fragment, path, out); + walk(tree.getRootNode(), fragment, path, hunkIndex, out); } /** @@ -349,7 +359,8 @@ private static boolean isChanged(UnifiedDiff.Line line) { * 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, List out) { + 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++) { @@ -358,19 +369,19 @@ private static void walk(TSNode node, Fragment fragment, String path, List out, Fragment fragment, TSNode node, String path, - boolean declaration) { + int hunkIndex, boolean declaration) { int start = node.getStartByte(); int index = fragment.lineAt(start); if (!fragment.reports(index)) { @@ -395,16 +406,17 @@ private static void addSymbol(List out, Fragment fragment, TSNode node, String name = new String(fragment.utf8(), start, node.getEndByte() - start, StandardCharsets.UTF_8); if (SymbolWords.isSymbol(name)) { - out.add(new Symbol(name, path, declaration, fragment.changed(index))); + out.add(new Symbol(name, path, hunkIndex, declaration, fragment.changed(index))); } } - private static void lexical(List out, String path, String text, boolean changed) { + 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, false, changed)); + out.add(new Symbol(name, path, hunkIndex, false, changed)); } } } diff --git a/app/src/test/java/app/drydock/review/ChangeGraphTest.java b/app/src/test/java/app/drydock/review/ChangeGraphTest.java index ce5ceafa..0670b7f1 100644 --- a/app/src/test/java/app/drydock/review/ChangeGraphTest.java +++ b/app/src/test/java/app/drydock/review/ChangeGraphTest.java @@ -121,6 +121,72 @@ void fanInIsCountedPerSymbolNotPerDeclaringFile() { 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() { diff --git a/app/src/test/java/app/drydock/review/SymbolScanTest.java b/app/src/test/java/app/drydock/review/SymbolScanTest.java index 9317b868..7341faab 100644 --- a/app/src/test/java/app/drydock/review/SymbolScanTest.java +++ b/app/src/test/java/app/drydock/review/SymbolScanTest.java @@ -32,6 +32,56 @@ private static UnifiedDiff.FileDiff file(String path, String... addedLines) { 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); From c6143dda09134bc97793637ac478087b0d1a010d Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 18:08:15 +0200 Subject: [PATCH 070/113] Links state something true about one hunk A link renders as a footer row beneath a single hunk, so a file-level answer spread over a file's hunks ships "calls guards.cpp" under hunks that call nothing. That is a false statement about a specific hunk, not a loose one about the file, and a surface whose whole value is that its markers state true things cannot ship it. Both ends move: the source is the hunk that makes the reference, and the target is the hunk that declares or uses the symbol rather than its file's first hunk. Deduplication by target hunk still keeps one link per pair, so two hunks that genuinely reference each other show one. At file granularity that silently dropped the reverse direction of every mutual pair -- the cycles 6.1 says are worth showing. At hunk granularity it only happens where the same two HUNKS reference each other, and the section names that cycle. Same concept stays cross-FILE rather than merely cross-hunk: two overloads in one file both declare the name, and linking them would point a footer at its own file. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/app/drydock/review/ReadingPath.java | 180 ++++++++---------- .../app/drydock/review/ReadingPathTest.java | 121 ++++++++++++ 2 files changed, 204 insertions(+), 97 deletions(-) diff --git a/app/src/main/java/app/drydock/review/ReadingPath.java b/app/src/main/java/app/drydock/review/ReadingPath.java index 967b4ab9..48aa561b 100644 --- a/app/src/main/java/app/drydock/review/ReadingPath.java +++ b/app/src/main/java/app/drydock/review/ReadingPath.java @@ -50,11 +50,13 @@ * unit level, where a cycle's members each have in-degree from inside the * cycle while the unit as a whole is an endpoint.

* - *

Links are file-level. {@link SymbolScan.Symbol} does - * not carry a line, so nothing here can tell which hunk of a file a symbol - * sits in. Every hunk of a file therefore carries that file's links, and a - * link points at the first hunk of its target file. Narrowing this needs a - * line on {@code Symbol}, not a guess here.

+ *

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 @@ -132,7 +134,6 @@ public static List of(UnifiedDiff diff, ChangeGraph graph, Map sectionByHunk = sectionNumbers(sections); Map fanInByFile = fanInByFile(graph, fanIn); - Map>> concepts = concepts(graph); Comparator rank = rank(graph, fanInByFile); List> units = @@ -145,11 +146,12 @@ public static List of(UnifiedDiff diff, ChangeGraph graph, if (fileDiff == null) { continue; } - List links = linksFrom(file, graph, concepts, byPath, sectionByHunk); String reason = reasonFor(file, graph, byPath, sectionByHunk, fanInByFile.getOrDefault(file, 0), fanIn.unavailable()); - for (int hunk = 0; hunk < fileDiff.hunks().size(); hunk++) { - String hunkId = ReviewIntent.hunkId(file, hunk); + 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())); } @@ -272,108 +274,92 @@ private static String markers(SortedSet files, int own, // ---- links -------------------------------------------------------------- /** - * {@code file}'s links, cross-file and deduplicated by target hunk. + * {@code hunk}'s links, cross-file and deduplicated by target hunk. * - *

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: a - * file that calls another and shares its symbol is one relationship, and - * the call is the more specific thing to say about it. It is also why - * same-concept ends up meaning what §2.2 wants -- two files that use the - * same thing without either defining it -- rather than restating every - * edge.

+ *

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(String file, ChangeGraph graph, - Map>> concepts, + private static List linksFrom(ChangeGraph.Hunk hunk, ChangeGraph graph, Map byPath, Map sectionByHunk) { - List links = new ArrayList<>(); - Set claimed = new LinkedHashSet<>(); + SortedSet declared = graph.declarationsIn(hunk); + SortedSet referenced = graph.referencesIn(hunk); - for (String target : graph.filesReferencedBy(file)) { - // The symbols target declares that file uses: a real location in - // the target, so the label may point at it. - String symbol = best(sharedBetween(graph, target, file), graph); - addLink(links, claimed, byPath, sectionByHunk, CALLS, target, - symbol == null ? "" : ":" + symbol); + Map> calls = new TreeMap<>(); + for (String symbol : referenced) { + for (ChangeGraph.Hunk target : graph.hunksDeclaring(symbol)) { + calls.computeIfAbsent(target, key -> new TreeSet<>()).add(symbol); + } } - for (String source : graph.filesReferencing(file)) { - // The symbols file declares that source uses. They live HERE, not - // in the target, so the label says what the target does with them - // rather than pointing into it. - String symbol = best(sharedBetween(graph, file, source), graph); - addLink(links, claimed, byPath, sectionByHunk, CALLED_BY, source, - symbol == null ? "" : " · uses " + symbol); + Map> calledBy = new TreeMap<>(); + for (String symbol : declared) { + for (ChangeGraph.Hunk source : graph.hunksReferencingSymbol(symbol)) { + calledBy.computeIfAbsent(source, key -> new TreeSet<>()).add(symbol); + } } - Map> sharedWith = - concepts.getOrDefault(file, Map.of()); - for (Map.Entry> shared : sharedWith.entrySet()) { - String symbol = best(shared.getValue(), graph); - addLink(links, claimed, byPath, sectionByHunk, SAME_CONCEPT, shared.getKey(), - " · both touch " + 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); + } + } } - return List.copyOf(links); - } - private static void addLink(List links, Set claimed, - Map byPath, - Map sectionByHunk, - String kind, String target, String suffix) { - UnifiedDiff.FileDiff targetDiff = byPath.get(target); - if (targetDiff == null || targetDiff.hunks().isEmpty()) { - // Nothing to click through to; a link to no hunk is a dead row. - return; - } - String hunkId = ReviewIntent.hunkId(target, 0); - if (!claimed.add(hunkId)) { - return; - } - String marker = marker(sectionByHunk.getOrDefault(hunkId, 0)); - String label = (marker.isEmpty() ? "" : marker + " ") - + FallbackIntents.fileName(target) + suffix; - links.add(new Link(kind, hunkId, label)); + 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); } - /** The changed names {@code declarer} declares and {@code user} references. */ - private static SortedSet sharedBetween(ChangeGraph graph, String declarer, - String user) { - SortedSet shared = new TreeSet<>(); - for (String symbol : graph.declarationsIn(declarer)) { - if (graph.filesReferencingSymbol(symbol).contains(user)) { - shared.add(symbol); + 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; } - } - return shared; - } - - /** - * For each changed file, every other changed file it shares a changed - * symbol with and the names they share. A file touches a symbol by - * declaring it or by referencing it; the name has to be uniquely declared - * in the scope, which {@link ChangeGraph#changedDeclarations()} already - * guarantees -- the same test an edge passes (§4.2), so an ambiguous name - * links nothing. - * - *

Built once for the whole change rather than per file: the question - * is symmetric, and asking it file by file re-walks every changed - * declaration once per changed file.

- */ - private static Map>> concepts(ChangeGraph graph) { - Map>> byFile = new TreeMap<>(); - for (String symbol : graph.changedDeclarations()) { - SortedSet touching = new TreeSet<>(graph.filesReferencingSymbol(symbol)); - graph.fileDeclaring(symbol).ifPresent(touching::add); - for (String file : touching) { - for (String other : touching) { - if (!other.equals(file)) { - byFile.computeIfAbsent(file, key -> new TreeMap<>()) - .computeIfAbsent(other, key -> new TreeSet<>()) - .add(symbol); - } - } + 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)); } - return byFile; } /** diff --git a/app/src/test/java/app/drydock/review/ReadingPathTest.java b/app/src/test/java/app/drydock/review/ReadingPathTest.java index 266d0b22..1b3aaa73 100644 --- a/app/src/test/java/app/drydock/review/ReadingPathTest.java +++ b/app/src/test/java/app/drydock/review/ReadingPathTest.java @@ -42,6 +42,17 @@ private static UnifiedDiff.Hunk hunk(int firstLine, String... added) { 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) { ChangeGraph graph = ChangeGraph.of(diff); return ReadingPath.of(diff, graph, Sections.of(diff, graph), fanIn); @@ -242,6 +253,85 @@ void aTargetHunkIsLinkedOnce() { } } + /** + * 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() { @@ -255,6 +345,24 @@ void aFileDoesNotLinkToItself() { 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( @@ -368,6 +476,19 @@ 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(); From ba1bfac0568123d9b8073709f7dd53baf382c430 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 18:11:07 +0200 Subject: [PATCH 071/113] The path carries the section order it implies There were two reading orders and nobody reconciled them. 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 exact failure the rank-inside-the-sort rule exists to prevent, one level up. Reordering the rail without moving the numbering is the mirror defect: every marker minted into a reason or a label would point at the wrong card. So of() returns both, and a step's sectionNumber indexes into the order it returns. A consumer renders sections() down the rail and has nothing left to reconcile: card 1 is the entry point's section because the first step is the entry point, by the same construction. A section's place is its earliest hunk in the path; ties keep the order the grouping gave them. A section the path never reaches goes to the end rather than out of the rail, because a card falling out is worse than one sitting last. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/app/drydock/review/ReadingPath.java | 128 ++++++++++++++---- .../app/drydock/review/ReadingPathTest.java | 85 +++++++++++- 2 files changed, 183 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/app/drydock/review/ReadingPath.java b/app/src/main/java/app/drydock/review/ReadingPath.java index 48aa561b..66c8e7e2 100644 --- a/app/src/main/java/app/drydock/review/ReadingPath.java +++ b/app/src/main/java/app/drydock/review/ReadingPath.java @@ -50,6 +50,15 @@ * 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 -- @@ -97,9 +106,10 @@ public record Link(String kind, String targetHunkId, String label) { /** * One hunk, in reading order. {@code sectionNumber} is the 1-based place - * in the rail 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. + * 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) { @@ -112,52 +122,87 @@ public record Step(String hunkId, String file, int sectionNumber, String reason, } /** - * {@code diff}'s hunks in reading order. Blocking only in the sense its - * inputs are; never call the {@link ChangeGraph#of} that feeds it on the - * FX thread. + * 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 List of(UnifiedDiff diff, ChangeGraph graph, - List sections, OutOfDiffFanIn.Result fanIn) { + 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 List.of(); + return new Path(List.of(), List.copyOf(sections)); } - Map sectionByHunk = sectionNumbers(sections); Map fanInByFile = fanInByFile(graph, fanIn); Comparator rank = rank(graph, fanInByFile); - List> units = Graphs.topologicalOrder(nodes, file -> dependencies(graph, nodes, file), rank); - List steps = new ArrayList<>(); + // 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; } - String reason = reasonFor(file, graph, byPath, sectionByHunk, - fanInByFile.getOrDefault(file, 0), fanIn.unavailable()); + files.add(file); 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())); + hunkIds.add(ReviewIntent.hunkId(file, index)); } } } - return List.copyOf(steps); + 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())); + } + } + return new Path(steps, ordered); } // ---- order -------------------------------------------------------------- @@ -384,9 +429,46 @@ private static String best(SortedSet symbols, ChangeGraph graph) { // ---- sections ----------------------------------------------------------- /** - * Each hunk's section number, 1-based. Sections overlap (§5.6), so a hunk - * can be in several; the first one wins, which is the one the reviewer - * meets first in the rail. + * {@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<>(); diff --git a/app/src/test/java/app/drydock/review/ReadingPathTest.java b/app/src/test/java/app/drydock/review/ReadingPathTest.java index 1b3aaa73..17d4ba5e 100644 --- a/app/src/test/java/app/drydock/review/ReadingPathTest.java +++ b/app/src/test/java/app/drydock/review/ReadingPathTest.java @@ -54,6 +54,10 @@ private static UnifiedDiff.FileDiff multiHunk(String path, String... oneLinePerH } 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); } @@ -381,16 +385,14 @@ void everyHunkIsOnThePathExactlyOnce() { @Test void everyStepCarriesTheSectionItsHunkIsIn() { - UnifiedDiff diff = new UnifiedDiff(List.of( + ReadingPath.Path path = fullPathOf(new UnifiedDiff(List.of( file("src/guards.cpp", "class JmpCtxScope { };"), - file("src/profiler.cpp", "void go() { new JmpCtxScope(); }"))); - ChangeGraph graph = ChangeGraph.of(diff); - List sections = Sections.of(diff, graph); + file("src/profiler.cpp", "void go() { new JmpCtxScope(); }"))), NO_FAN_IN); - for (ReadingPath.Step step : ReadingPath.of(diff, graph, sections, NO_FAN_IN)) { + for (ReadingPath.Step step : path.steps()) { int expected = 0; - for (int index = 0; index < sections.size(); index++) { - if (sections.get(index).hunkIds().contains(step.hunkId())) { + for (int index = 0; index < path.sections().size(); index++) { + if (path.sections().get(index).hunkIds().contains(step.hunkId())) { expected = index + 1; break; } @@ -400,6 +402,75 @@ void everyStepCarriesTheSectionItsHunkIsIn() { } } + /** + * 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( From e3813b16ca82622cf0fc4bc0ba230dd9a1a4938e Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 18:27:58 +0200 Subject: [PATCH 072/113] The rank's authoritative list names the terms the rank actually has Deleting the not-a-leaf term rewrote the paragraph explaining its absence and the comparator's own javadoc, but left the "rank, in full" enumeration thirteen lines above still listing it. The class doc therefore contradicted itself and the code, and a reader trusts the list that says "in full" over the implementation -- especially in the class that mints the ordering. Also drops the same stale reference from a test's javadoc, where the fixture's point is now that in-degree is the only thing separating the two files. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/main/java/app/drydock/review/ReadingPath.java | 7 ++++--- app/src/test/java/app/drydock/review/ReadingPathTest.java | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/app/drydock/review/ReadingPath.java b/app/src/main/java/app/drydock/review/ReadingPath.java index 66c8e7e2..c70cc555 100644 --- a/app/src/main/java/app/drydock/review/ReadingPath.java +++ b/app/src/main/java/app/drydock/review/ReadingPath.java @@ -27,9 +27,10 @@ * construction.

* *

The rank, in full. Out-of-diff fan-in, then in-degree - * within the changed set, then not-a-test, then not-a-leaf, then {@link - * FallbackIntents}' kind order, then the path. The path is what makes it - * TOTAL, and total is not a nicety here: {@code Graphs} keeps its ready set + * 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).

diff --git a/app/src/test/java/app/drydock/review/ReadingPathTest.java b/app/src/test/java/app/drydock/review/ReadingPathTest.java index 17d4ba5e..52b1fac8 100644 --- a/app/src/test/java/app/drydock/review/ReadingPathTest.java +++ b/app/src/test/java/app/drydock/review/ReadingPathTest.java @@ -123,8 +123,8 @@ void outOfDiffFanInOutranksInDegree() { /** * 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, and neither is a leaf - * -- so the not-a-leaf signal cannot decide this one. + * 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() { From 5d542755f87efb392b8e492c5bb7849fc3c9bc47 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 19:08:25 +0200 Subject: [PATCH 073/113] 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. Renders Path.steps() only -- never Sections.of()'s own order -- so START HERE and every section badge come from the same numbering the entry point was computed against; mutation-verified against a fixture where the two orders genuinely disagree. [ and ] step whatever the rail is currently listing and n keeps meaning next-unsettled, now stated over hunks rather than over intents, so the mode costs one key rather than a parallel set. Every row's reason is scoped to the FILE, never the hunk, since a hunk's reason is copied from its file and a hunk that does nothing structurally interesting must not read as if it does. McpToolRouter's review_scope sections now carry the same order, so an agent and a human never disagree about which card is (1). Entering PATH mode lazily requests a ChangeGraph even where a reviewer's own grouping made building one for the intents rail's own purposes pure waste -- the two are independent uses of the same graph. A scene-snapshot screenshot caught a real gap this introduced: the diff column fell back to "whole scope" when PATH mode was entered before its graph existed, and nothing re-narrowed it once the graph landed a moment later -- fixed by revealing the current selection again from the graph's own completion callback. --- .../java/app/drydock/mcp/McpToolRouter.java | 23 +- .../main/java/app/drydock/ui/PanelHeader.java | 5 + .../java/app/drydock/ui/ShortcutsOverlay.java | 18 +- .../drydock/ui/review/ReviewIntentRail.java | 164 +++++++++ .../drydock/ui/review/SessionReviewView.java | 315 +++++++++++++++++- .../ui/ShortcutsOverlayParityTest.java | 2 +- .../drydock/ui/review/ReviewPathModeTest.java | 155 +++++++++ .../ui/review/ReviewPathOrderTest.java | 146 ++++++++ 8 files changed, 809 insertions(+), 19 deletions(-) create mode 100644 app/src/test/java/app/drydock/ui/review/ReviewPathModeTest.java create mode 100644 app/src/test/java/app/drydock/ui/review/ReviewPathOrderTest.java diff --git a/app/src/main/java/app/drydock/mcp/McpToolRouter.java b/app/src/main/java/app/drydock/mcp/McpToolRouter.java index d279601b..823a22b8 100644 --- a/app/src/main/java/app/drydock/mcp/McpToolRouter.java +++ b/app/src/main/java/app/drydock/mcp/McpToolRouter.java @@ -9,6 +9,8 @@ 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.ReviewAnnotation; import app.drydock.review.ReviewIntent; import app.drydock.review.ReviewScope; @@ -335,6 +337,21 @@ private JsonValue reviewScope(ManagedSessionId caller, JsonValue arguments) thro return result; } + /** + * 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} here 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. No out-of-diff fan-in scan backs the rank + * here -- {@link OutOfDiffFanIn#scan} spawns a blocking {@code git grep} + * per call, a separate concern from reordering an existing payload -- so + * {@code unavailable=true} is the honest input for a signal nothing + * computed, the same choice the rail makes. + */ + private static final OutOfDiffFanIn.Result NO_FAN_IN_SCAN = new OutOfDiffFanIn.Result(Map.of(), true); + /** * {@code sections}, or empty if none was requested or the graph could not * be built. {@link ChangeGraph#of} (via {@link SymbolScan}) can throw @@ -344,8 +361,10 @@ private JsonValue reviewScope(ManagedSessionId caller, JsonValue arguments) thro */ private Optional computeSections(ReviewScope scope, UnifiedDiff diff) { try { - return Optional.of(ReviewToolCodec.sectionsToJson( - Sections.of(diff, graphBuilder.apply(diff)))); + ChangeGraph graph = graphBuilder.apply(diff); + List sections = Sections.of(diff, graph); + ReadingPath.Path path = ReadingPath.of(diff, graph, sections, NO_FAN_IN_SCAN); + 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); 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 b1d87ee6..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,8 +49,9 @@ 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"}, + {"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"}, @@ -90,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/ReviewIntentRail.java b/app/src/main/java/app/drydock/ui/review/ReviewIntentRail.java index 1b46e6cb..acb50f85 100644 --- a/app/src/main/java/app/drydock/ui/review/ReviewIntentRail.java +++ b/app/src/main/java/app/drydock/ui/review/ReviewIntentRail.java @@ -1,6 +1,7 @@ package app.drydock.ui.review; import app.drydock.review.ChangeGraph; +import app.drydock.review.ReadingPath; import app.drydock.review.ReviewIntent; import app.drydock.review.ReviewVerdict; import app.drydock.ui.PanelHeader; @@ -28,6 +29,7 @@ import java.util.Optional; import java.util.function.Consumer; import java.util.function.Function; +import java.util.stream.Collectors; /** * The intent rail (spec §4.2): one card per intent, with its number, title, @@ -73,6 +75,32 @@ final class ReviewIntentRail extends VBox { 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; + + /** + * {@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 -> { }; private List intents = List.of(); /** @@ -128,6 +156,15 @@ 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; + } + void setOnToggleCollapse(Runnable handler) { this.onToggleCollapse = handler == null ? () -> { } : handler; } @@ -166,12 +203,31 @@ String message() { /** Replaces the rail's contents and marks {@code selectedIntentId} as current. */ void setIntents(List newIntents, String selectedIntentId, Empty reason) { + this.mode = Mode.INTENTS; 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) { @@ -274,7 +330,16 @@ 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(); @@ -308,6 +373,92 @@ 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.

+ */ + private Button buildPathRow(ReadingPath.Step step, int indexInFile, int hunksInFile) { + Button row = new Button(); + row.setText(pathRowText(step, indexInFile, hunksInFile)); + row.getStyleClass().add("review-intent-card"); + row.setWrapText(true); + row.setMaxWidth(Double.MAX_VALUE); + row.setAlignment(Pos.TOP_LEFT); + row.setOnAction(e -> onPathSelected.accept(step)); + return row; + } + + private static String pathRowText(ReadingPath.Step step, int indexInFile, int hunksInFile) { + String badge = step.entryPoint() + ? "START HERE " + SectionStates.sectionMark(step.sectionNumber()) + : SectionStates.sectionMark(step.sectionNumber()); + String where = hunksInFile > 1 + ? step.file() + " · hunk " + (indexInFile + 1) + "/" + hunksInFile + : step.file(); + StringBuilder text = new StringBuilder(badge).append(" ").append(where) + .append('\n').append("file ").append(step.reason()); + if (!step.links().isEmpty()) { + text.append('\n').append(step.links().size() == 1 ? "→ " : "→ " + step.links().size() + " links: ") + .append(step.links().stream().map(ReadingPath.Link::label) + .collect(Collectors.joining("; "))); + } + return text.toString(); + } + private Button buildCard(ReviewIntent intent) { Button card = new Button(); card.getStyleClass().add("review-intent-card"); @@ -470,5 +621,18 @@ 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. + */ + List diagPathRowTexts() { + return buttonsByHunkId.values().stream().map(Button::getText).toList(); } } 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 e85d0734..f6874c76 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -7,10 +7,14 @@ import app.drydock.review.BaseMove; import app.drydock.review.ChangeGraph; 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.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; @@ -425,6 +429,45 @@ private record IntentsCacheEntry(String scopeId, UnifiedDiff diff, ChangeGraph g 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; + + /** + * The reading path's rank has no out-of-diff fan-in scan behind it here: + * {@link OutOfDiffFanIn#scan} spawns a blocking {@code git grep} per + * scope, and wiring that in is a separate concern from giving the rail a + * second mode. {@code unavailable=true} is the honest input for a signal + * that was never computed -- {@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. + */ + private static final OutOfDiffFanIn.Result NO_FAN_IN_SCAN = new OutOfDiffFanIn.Result(Map.of(), true); + + /** 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. */ + private record PathCacheEntry(String scopeId, UnifiedDiff diff, ChangeGraph graph, ReadingPath.Path path) { + } + /** The scopes this session offers, once {@link SessionReviewScopes} has measured them. */ private Optional scopes = Optional.empty(); @@ -562,6 +605,15 @@ 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(); + } + }); margin.setOnFilterChanged(filter -> refreshReviewState()); diffColumn.setPinSource(new PinSource()); diffColumn.setCommentSink(annotation -> selectedScope().ifPresent(scope -> { @@ -581,19 +633,28 @@ public SessionReviewView(Host host, DiffService diffService, McpActivityLog acti // from an empty diff and never recovers. diffColumn.setOnDiffResolved((scopeId, outcome) -> { outcomeByScope.put(scopeId, outcome); - if (outcome instanceof DiffOutcome.Loaded loaded - && scopeById(scopeId).map(candidate -> !host.hasReviewerGrouping(candidate)) - .orElse(true)) { - requestGraph(scopeId, loaded.diff()); - } else if (!(outcome instanceof DiffOutcome.Loaded)) { + boolean selected = selectedScope().map(scope -> scope.id().equals(scopeId)).orElse(false); + if (outcome instanceof DiffOutcome.Loaded loaded) { + boolean noReviewerGrouping = scopeById(scopeId) + .map(candidate -> !host.hasReviewerGrouping(candidate)).orElse(true); + // PATH mode needs a graph even where a reviewer's own + // grouping already made building one for the rail's OWN + // purposes pure waste (Host#hasReviewerGrouping) -- a + // re-diff of the selected scope while PATH mode is showing + // must still refresh what it lists, not silently keep + // rendering the previous diff's steps. + if (noReviewerGrouping || (pathMode && selected)) { + 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(); } }); @@ -805,6 +866,7 @@ private void renderSelectedScope() { headerTitle.setText(headerTitleFor(scope)); headerContext.setText(headerContextFor(scope)); intentIndex = 0; + pathIndex = 0; // 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 @@ -824,7 +886,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(); } /** @@ -972,8 +1034,17 @@ public void refreshReviewState() { margin.invalidate(null); margin.setFindings(findingsForMargin(scope.get())); diffColumn.refreshPins(); - intentRail.setIntents(currentIntents, currentIntent().map(ReviewIntent::id).orElse(null), - emptyReason()); + if (pathMode) { + List steps = currentPath().steps(); + 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 { + intentRail.setIntents(currentIntents, currentIntent().map(ReviewIntent::id).orElse(null), + emptyReason()); + } intentRail.setGroupingPending(graphBuilding.contains(scopeId)); mcpPanel.filter(Node::isVisible) .ifPresent(panel -> panel.setScope(scope.get())); @@ -1150,6 +1221,48 @@ private List intents() { 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; + } + PathCacheEntry cached = pathCache; + if (cached != null && cached.scopeId().equals(scopeId) && cached.diff() == diff + && cached.graph() == graph) { + return cached.path(); + } + ReadingPath.Path computed = + ReadingPath.of(diff, graph, Sections.of(diff, graph), NO_FAN_IN_SCAN); + pathCache = new PathCacheEntry(scopeId, diff, graph, computed); + return computed; + } + /** * Kicks off building {@code diff}'s {@link ChangeGraph} on {@link * #SECTION_GRAPH_EXECUTOR}, off the FX thread. Until it finishes, {@code @@ -1218,6 +1331,15 @@ private void requestGraph(String scopeId, UnifiedDiff diff) { 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(); } }); }); @@ -1434,6 +1556,167 @@ 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; + 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; + } + } + } + + /** {@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(), + List.of(step.hunkId()), Optional.empty(), false); + } + + /** 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)); + } + + /** + * 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. + */ + 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 @@ -1965,9 +2248,15 @@ 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 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; diff --git a/app/src/test/java/app/drydock/ui/ShortcutsOverlayParityTest.java b/app/src/test/java/app/drydock/ui/ShortcutsOverlayParityTest.java index 6efa73ff..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", "⇧A", "⇧R"); + "⏎", "⇧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/review/ReviewPathModeTest.java b/app/src/test/java/app/drydock/ui/review/ReviewPathModeTest.java new file mode 100644 index 00000000..bde366bf --- /dev/null +++ b/app/src/test/java/app/drydock/ui/review/ReviewPathModeTest.java @@ -0,0 +1,155 @@ +package app.drydock.ui.review; + +import app.drydock.review.HunkDigest; +import app.drydock.review.ReviewVerdict; +import app.drydock.ui.ShortcutsOverlay; + +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.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")); + } + + // ---- 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(); + } +} 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..e42e9acc --- /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.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()); + stage.setScene(scene); + stage.show(); + } + + @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); + } + } +} From 0823a81310363de6ff87c5edc21794291bf59901 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 19:58:02 +0200 Subject: [PATCH 074/113] PATH rows get their own text fills instead of a Button's default A screenshot measured the selected row's own contrast at 1.13:1 -- worse than the unselected rows' 1.70:1, because the lighter :selected background made a light-on-light problem worse. buildPathRow put its text on the Button itself, and .review-intent-card sets border and background only, so the text fell back to modena's default button-face colour (tuned for a light face) against this rail's dark background. buildCard's intents cards never hit this: their text lives in child Labels carrying their own -fx-text-fill, brightened explicitly on :selected. Rebuilt PATH rows the same way -- badge/file/reason/links each on their own Label, styled by .review-path-* rules that mirror .review-intent-number/-title token for token, so a selected row's file name is provably the same colour as a selected intent card's title, never darker than its own unselected state. Mutation-verified: stripping .review-path-file's fill (simulating the original bug) fails the new equality test; the earlier screenshot finding the diff column stuck on "whole scope" no longer applies here, this is presentation only. --- .../drydock/ui/review/ReviewIntentRail.java | 94 ++++++++-- app/src/main/resources/app/drydock/ui/app.css | 35 ++++ .../ui/review/ReviewPathRowContrastTest.java | 165 ++++++++++++++++++ 3 files changed, 278 insertions(+), 16 deletions(-) create mode 100644 app/src/test/java/app/drydock/ui/review/ReviewPathRowContrastTest.java 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 acb50f85..4d4b95f8 100644 --- a/app/src/main/java/app/drydock/ui/review/ReviewIntentRail.java +++ b/app/src/main/java/app/drydock/ui/review/ReviewIntentRail.java @@ -12,6 +12,7 @@ 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.ScrollPane; @@ -430,33 +431,72 @@ private int hunksInFile(String file) { * 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(); - row.setText(pathRowText(step, indexInFile, hunksInFile)); row.getStyleClass().add("review-intent-card"); - row.setWrapText(true); row.setMaxWidth(Double.MAX_VALUE); row.setAlignment(Pos.TOP_LEFT); row.setOnAction(e -> onPathSelected.accept(step)); - return row; - } - private static String pathRowText(ReadingPath.Step step, int indexInFile, int hunksInFile) { - String badge = step.entryPoint() + Label badge = new Label(step.entryPoint() ? "START HERE " + SectionStates.sectionMark(step.sectionNumber()) - : SectionStates.sectionMark(step.sectionNumber()); - String where = hunksInFile > 1 + : SectionStates.sectionMark(step.sectionNumber())); + badge.getStyleClass().add("review-path-badge"); + + Label where = new Label(hunksInFile > 1 ? step.file() + " · hunk " + (indexInFile + 1) + "/" + hunksInFile - : step.file(); - StringBuilder text = new StringBuilder(badge).append(" ").append(where) - .append('\n').append("file ").append(step.reason()); + : 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, 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()) { - text.append('\n').append(step.links().size() == 1 ? "→ " : "→ " + step.links().size() + " links: ") - .append(step.links().stream().map(ReadingPath.Link::label) - .collect(Collectors.joining("; "))); + 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); } - return text.toString(); + // 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; } private Button buildCard(ReviewIntent intent) { @@ -631,8 +671,30 @@ private void applySelection() { * 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::getText).toList(); + 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); + return texts; + } + + private static void collectLabelTexts(Node node, List into) { + if (node instanceof Label label) { + into.add(label.getText()); + } else if (node instanceof Parent parent) { + for (Node child : parent.getChildrenUnmodifiable()) { + collectLabelTexts(child, into); + } + } } } diff --git a/app/src/main/resources/app/drydock/ui/app.css b/app/src/main/resources/app/drydock/ui/app.css index 1763946d..c3cdea40 100644 --- a/app/src/main/resources/app/drydock/ui/app.css +++ b/app/src/main/resources/app/drydock/ui/app.css @@ -3257,6 +3257,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; 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..2a013f17 --- /dev/null +++ b/app/src/test/java/app/drydock/ui/review/ReviewPathRowContrastTest.java @@ -0,0 +1,165 @@ +package app.drydock.ui.review; + +import app.drydock.review.ReadingPath; +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()); + stage.setScene(scene); + stage.show(); + } + + @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)); + 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); + } +} From 68cb9d952d7bb525326239581651b23e9c7c1296 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 19:58:55 +0200 Subject: [PATCH 075/113] A part-settled stale hunk no longer reads as untouched Correction 6a, authorised now rather than deferred again: a section with one stale-approved hunk and one genuinely unread one had settledHunks()==0, dropping the whole "N/M hunks" progress label -- the card looked exactly like one nobody had opened, mitigated only by the separate base-moved banner. SectionState gains recordedHunks: every hunk with ANY verdict, stale or not. It agrees with settledHunks whenever nothing is stale, so this changes what the label shows in exactly the gap the correction named -- verified by reverting the recorded++ and watching the new test fail. Correction 6b, same authorisation: with three or more sections sharing a hunk, collectSharingSections named every one of them as "reviewed in", crediting sections that -- since a verdict is keyed by digest alone, never by section -- this model cannot actually show did anything. Stops at the first sharer found; the two-section tests this class already had are the case where "at most one" and "the only one" coincide, and stay green unchanged. Mutation-verified: removing the early return fails the new three-way test. --- .../drydock/ui/review/ReviewIntentRail.java | 13 +++-- .../app/drydock/ui/review/SectionStates.java | 44 ++++++++++++++--- .../ReviewIntentRailCardHeightTest.java | 4 +- .../drydock/ui/review/SectionStatesTest.java | 49 +++++++++++++++++++ 4 files changed, 97 insertions(+), 13 deletions(-) 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 4d4b95f8..aefaea6a 100644 --- a/app/src/main/java/app/drydock/ui/review/ReviewIntentRail.java +++ b/app/src/main/java/app/drydock/ui/review/ReviewIntentRail.java @@ -595,11 +595,18 @@ protected double computePrefHeight(double width) { adrift.getStyleClass().add("review-intent-adrift"); adrift.setWrapText(true); content.getChildren().add(adrift); - } else if (state.settledHunks() > 0) { + } 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. - Label progress = new Label(state.settledHunks() + "/" + state.totalHunks() + " hunks"); + // 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); } diff --git a/app/src/main/java/app/drydock/ui/review/SectionStates.java b/app/src/main/java/app/drydock/ui/review/SectionStates.java index af1fa253..aad966e1 100644 --- a/app/src/main/java/app/drydock/ui/review/SectionStates.java +++ b/app/src/main/java/app/drydock/ui/review/SectionStates.java @@ -78,6 +78,16 @@ enum Staleness { * 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 @@ -89,8 +99,8 @@ enum Staleness { * which must not be mistaken for a section nobody has read */ record SectionState(Optional decision, int settledHunks, - int totalHunks, Staleness staleness, List settledElsewhere, - boolean hunksMissing) { + int recordedHunks, int totalHunks, Staleness staleness, + List settledElsewhere, boolean hunksMissing) { SectionState { settledElsewhere = List.copyOf(settledElsewhere); @@ -103,7 +113,7 @@ record SectionState(Optional decision, int settledHunks, * not the same claim. */ static SectionState unknown() { - return new SectionState(Optional.empty(), 0, 0, Staleness.UNKNOWN, List.of(), false); + return new SectionState(Optional.empty(), 0, 0, 0, Staleness.UNKNOWN, List.of(), false); } /** @@ -114,7 +124,7 @@ static SectionState unknown() { * and would otherwise refuse Submit forever with no visible reason. */ static SectionState notInDiff() { - return new SectionState(Optional.empty(), 0, 0, Staleness.FRESH, List.of(), true); + return new SectionState(Optional.empty(), 0, 0, 0, Staleness.FRESH, List.of(), true); } } @@ -305,10 +315,12 @@ SectionState stateOf(Board board, ReviewIntent intent) { Set elsewhere = new LinkedHashSet<>(); Staleness staleness = Staleness.FRESH; 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); @@ -323,14 +335,17 @@ SectionState stateOf(Board board, ReviewIntent intent) { // 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. + // 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, digests.size(), + return new SectionState(VerdictMerge.derive(perHunk), settled, recorded, digests.size(), staleness, List.copyOf(elsewhere), false); } @@ -376,13 +391,25 @@ private static Collection filesAffectingScope(Board board, ReviewIntent } /** - * The marks of the OTHER sections sharing {@code digest}, so a count that - * advanced without the reader touching this card is explained. + * 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) { @@ -392,6 +419,7 @@ private void collectSharingSections(Board board, String digest, ReviewIntent sel } if (digestsOf(board, other).contains(digest)) { into.add(sectionMark(other.number())); + return; } } } 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 ae3b1b69..a5b3087f 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewIntentRailCardHeightTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewIntentRailCardHeightTest.java @@ -110,7 +110,7 @@ void oneIntentIsOneCardTall() { void theSettledElsewhereMarkerNamingSeveralSectionsFitsAtNarrowWidth() { narrow(); rail.setSectionStateLookup(intent -> new SectionStates.SectionState( - Optional.empty(), 1, 3, SectionStates.Staleness.FRESH, + Optional.empty(), 1, 1, 3, SectionStates.Staleness.FRESH, List.of("①", "②", "③", "④", "⑤"), false)); showIntents(List.of(intent(1, "guards.h", ReviewIntent.Kind.CHANGE, "shared hunk"))); @@ -130,7 +130,7 @@ void theAdriftMessageFitsAtNarrowWidth() { void theStaleBannerFitsAtNarrowWidth() { narrow(); rail.setSectionStateLookup(intent -> new SectionStates.SectionState( - Optional.of(ReviewVerdict.Decision.APPROVED), 2, 2, + Optional.of(ReviewVerdict.Decision.APPROVED), 2, 2, 2, SectionStates.Staleness.MOVED, List.of(), false)); showIntents(List.of(intent(1, "guards.h", ReviewIntent.Kind.CHANGE, ""))); diff --git a/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java b/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java index 7884c641..54e3c8ae 100644 --- a/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java +++ b/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java @@ -156,6 +156,34 @@ void aSectionSharingNoHunkNamesNobody() { 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 @@ -199,6 +227,27 @@ void settledHunksExcludesAStaleOneButTheDecisionStillMergesIt() { "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() { From 1ea3c60c372263f4ab1338bc1e3f4ff92959ab46 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 19:59:33 +0200 Subject: [PATCH 076/113] a, r and u in PATH mode settle the row on screen, not a section settleUnit() returned SECTION unless real Scene focus was inside the diff column, and nothing about entering PATH mode ever moved it there -- so pressing a approved every digest of whatever intent the (now invisible) intents-mode cursor happened to sit on, while the verdict bar's own label read "Approve (section)" over a single selected row. Confirmed on a real screenshot before this commit. settleUnit() now returns the row PATH mode is showing outright when that mode is active, regardless of focus. pathVerdictAction/ undoPathVerdict settle and undo exactly the selected step's one hunk (every hunk of its file for the whole-file variant), keyed by a throwaway single-hunk ReviewIntent purely as the label host.setVerdict already takes -- verdicts themselves are keyed (scopeId, hunkDigest), never by intent, so this costs nothing SectionStates would need to change for. The verdict bar's own Approve/Request-changes buttons are routed the same way, so a click and a keypress can never disagree. PATH_STEP is a new SettleUnit purely for the bar's own label text ("hunk", distinct from HUNK's "next unread hunk" -- that one still means a search within a section, which PATH mode never does). Mutation-verified per the coordinator's own instruction: disabling the pathMode branch in verdictAction (settling by section again) fails the new one-hunk-only test. --- .../drydock/ui/review/ReviewVerdictBar.java | 5 + .../drydock/ui/review/SessionReviewView.java | 161 +++++++++++++++++- .../drydock/ui/review/ReviewPathModeTest.java | 50 ++++++ .../ui/review/ReviewVerdictBarFitTest.java | 1 + 4 files changed, 214 insertions(+), 3 deletions(-) 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 6ae8c935..e6c3e1c5 100644 --- a/app/src/main/java/app/drydock/ui/review/ReviewVerdictBar.java +++ b/app/src/main/java/app/drydock/ui/review/ReviewVerdictBar.java @@ -347,6 +347,11 @@ private static String unitWord(SessionReviewView.SettleUnit 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"; }; } 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 f6874c76..403462b1 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -6,6 +6,7 @@ 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; @@ -285,8 +286,8 @@ void setVerdict(ReviewScope scope, ReviewIntent intent, List hunkDigests /** * What {@code a} / {@code r} / {@code u} act on (spec §9.6). Reading is - * per hunk; settling usually is not, so one key needs three possible - * targets rather than three keys needing one each. + * 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. */ @@ -294,7 +295,17 @@ enum SettleUnit { /** 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 + 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; @@ -517,6 +528,21 @@ private record PathCacheEntry(String scopeId, UnifiedDiff diff, ChangeGraph grap */ 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; @@ -874,6 +900,8 @@ private void renderSelectedScope() { // 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 @@ -1464,6 +1492,14 @@ private List countedSections() { * 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; @@ -1792,6 +1828,16 @@ public void setPostToPr(ReviewAnnotation finding, boolean post) { private final class VerdictHost implements ReviewVerdictBar.Host { @Override 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))); @@ -1799,6 +1845,10 @@ public void approve(ReviewIntent intent, SettleUnit unit) { @Override 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))); @@ -2082,6 +2132,15 @@ private void setFocusMode(boolean on) { * regardless of what has focus */ 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.isEmpty() || intent.isEmpty()) { @@ -2098,6 +2157,7 @@ private void verdictAction(ReviewVerdict.Decision decision, boolean wholeFile) { if (!applied) { return; } + lastSettledWasPath = false; lastSettledIntentId = Optional.of(intent.get().id()); lastSettledDigests = digests; if (decisionOf(intent.get()).filter(decision::equals).isPresent()) { @@ -2105,6 +2165,62 @@ private void verdictAction(ReviewVerdict.Decision decision, boolean wholeFile) { } } + /** + * 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), + blockingFindingOpen(scope.get(), synthetic)); + 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(); + } + + /** 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 exactly the digests {@code a}/{@code r} last * recorded -- NOT the whole intent the cursor currently sits on, and NOT @@ -2120,6 +2236,10 @@ private void verdictAction(ReviewVerdict.Decision decision, boolean wholeFile) { * than reaching for an unrelated intent's verdict. */ private void undoVerdict() { + if (lastSettledWasPath) { + undoPathVerdict(); + return; + } Optional scope = selectedScope(); if (scope.isEmpty() || lastSettledIntentId.isEmpty() || lastSettledDigests.isEmpty()) { return; @@ -2149,6 +2269,41 @@ private void undoVerdict() { 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 diff --git a/app/src/test/java/app/drydock/ui/review/ReviewPathModeTest.java b/app/src/test/java/app/drydock/ui/review/ReviewPathModeTest.java index bde366bf..70225f09 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewPathModeTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewPathModeTest.java @@ -113,6 +113,56 @@ 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"); + } + // ---- helpers -------------------------------------------------------------- private void pressP() { 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 6c23eaf2..4979e22b 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewVerdictBarFitTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewVerdictBarFitTest.java @@ -179,6 +179,7 @@ private static String unitWord(SessionReviewView.SettleUnit unit) { case HUNK -> "next unread hunk"; case SECTION -> "section"; case FILE -> "file"; + case PATH_STEP -> "hunk"; }; } From dd92b6a8a5dacc4aa2d65309a4745e2c7398c810 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 19:59:51 +0200 Subject: [PATCH 077/113] Computed intent cards number off the reading path too review_scope's sections order agreed with the computed rail's own numbering before this task's earlier commit, and disagreed after it -- an agent's (1) became the human's (2) the moment PATH mode was showing, and pressing p silently renumbered every card underneath whichever section a finding or verdict named. IntentGrouping.intentsFor now feeds Sections.of's own list through ReadingPath.of before numbering the computed path, exactly as the rail and McpToolRouter already do -- unifying all three surfaces on one order, so card (1) means the same section everywhere at once. sameAsFallback still compares against Sections.of's own (unreordered) list; reordering cannot create or hide content equality with the fallback, so that check is untouched. A reviewer-supplied grouping is unaffected and explicitly so: its own 1..N stays exactly IntentGrouping.set's dense numbering over whatever order the reviewer supplied. Only the computed path is drydock's own call to make. Verified against the same disagreeing fixture ReadingPathTest and this task's own ReviewPathOrderTest already use (zbase.cpp's in-degree outranks mid.cpp's alphabetical lead); mutation-verified by reverting to the unreordered list, which fails the new test. --- .../app/drydock/review/IntentGrouping.java | 30 ++++++++++++- .../drydock/review/IntentGroupingTest.java | 45 +++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/app/drydock/review/IntentGrouping.java b/app/src/main/java/app/drydock/review/IntentGrouping.java index d4e8d818..1b1c1a71 100644 --- a/app/src/main/java/app/drydock/review/IntentGrouping.java +++ b/app/src/main/java/app/drydock/review/IntentGrouping.java @@ -30,6 +30,16 @@ */ 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<>(); @@ -104,7 +114,11 @@ public List intentsFor(String scopeId, UnifiedDiff 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.

+ * overruling the reviewer -- so its {@code number}s stay exactly {@link + * #set}'s own dense 1..N over whatever order the reviewer supplied, + * 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 @@ -126,9 +140,21 @@ public List intentsFor(String scopeId, UnifiedDiff diff, 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()) { @@ -137,7 +163,7 @@ public List intentsFor(String scopeId, UnifiedDiff diff, } List computed = new ArrayList<>(); int number = 1; - for (Sections.Section section : sections) { + for (Sections.Section section : ordered) { computed.add(new ReviewIntent(computedId(section), number, section.title(), kindOf(section, fallbackByHunk), riskOf(section, fallbackByHunk), rationale(section), section.hunkIds(), Optional.empty(), false)); diff --git a/app/src/test/java/app/drydock/review/IntentGroupingTest.java b/app/src/test/java/app/drydock/review/IntentGroupingTest.java index 237b5060..e597552c 100644 --- a/app/src/test/java/app/drydock/review/IntentGroupingTest.java +++ b/app/src/test/java/app/drydock/review/IntentGroupingTest.java @@ -171,6 +171,51 @@ void aStructurelessDiffKeepsTheFallbacksOwnIdentity() { + "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 From 12e2df8ea8479f97417df5ce081e01ff7259adbb Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 20:47:59 +0200 Subject: [PATCH 078/113] PATH mode no longer bypasses the blocking-finding refusal pathVerdictAction asked blockingFindingOpen about a throwaway synthetic "path:" + hunkId intent, which a finding's real intentId can never equal -- so an agent-attributed blocking finding (the common case, and the whole reason review_finding carries an id) silently failed to refuse a in PATH mode, while INTENTS mode correctly refused the exact same finding and the bar simultaneously rendered "a blocking finding is still open". Unnamed findings still blocked via the touches(file) fallback, so the hole was specific to named ones. blockingFindingOpenForPathStep resolves the step's REAL covering intents first (sections overlap, so possibly several) and asks the existing blockingFindingOpen rule about each of them, falling back to a bare file match only when no real intent claims the hunk at all -- the same fallback belongsToIntent already uses for an unresolvable name. Mutation-verified: reverting to the synthetic-id call reproduces the exact approve-anyway-while-refusing-in-INTENTS-mode bug the review found, by execution. --- .../drydock/ui/review/SessionReviewView.java | 57 ++++++++++++++++++- .../drydock/ui/review/ReviewPathModeTest.java | 39 +++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) 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 403462b1..552c2dd6 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -1732,6 +1732,23 @@ private static Optional digestOfPathStep(UnifiedDiff diff, ReadingPath.S return digests.isEmpty() ? Optional.empty() : Optional.of(digests.get(0)); } + /** + * The REAL intents (sections overlap, so possibly several) that + * actually cover {@code step}'s one hunk -- what {@link + * #blockingFindingOpenForPathStep} resolves a step through, since a + * blocking finding cannot be asked about a throwaway synthetic id + * ({@link #pathStepAsIntent}) it 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(); + } + /** * Test-only: the row {@code [} / {@code ]} / {@code n} last selected in * PATH mode. Routed through {@link ReviewDiagFxThread} like every other @@ -2191,7 +2208,7 @@ private void pathVerdictAction(ReviewVerdict.Decision decision, boolean wholeFil return; } host.setVerdict(scope.get(), synthetic, digests, Optional.of(decision), - blockingFindingOpen(scope.get(), synthetic)); + blockingFindingOpenForPathStep(scope.get(), step, wholeFile)); boolean applied = digests.stream().allMatch(digest -> host.verdict(scope.get(), digest) .filter(v -> v.decision() == decision).isPresent()); if (!applied) { @@ -2207,6 +2224,44 @@ private void pathVerdictAction(ReviewVerdict.Decision decision, boolean wholeFil 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()) { diff --git a/app/src/test/java/app/drydock/ui/review/ReviewPathModeTest.java b/app/src/test/java/app/drydock/ui/review/ReviewPathModeTest.java index 70225f09..e34df3c9 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewPathModeTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewPathModeTest.java @@ -1,7 +1,11 @@ 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.input.KeyCode; @@ -13,6 +17,7 @@ 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; /** @@ -163,6 +168,40 @@ void uInPathModeUndoesOnlyWhatAJustSettled() { "u must clear the one verdict a just recorded, leaving nothing settled"); } + /** + * 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"); + } + // ---- helpers -------------------------------------------------------------- private void pressP() { From 91f926839625531fe0f920fe1a8a4f97bbf11bc4 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 20:49:05 +0200 Subject: [PATCH 079/113] The verdict bar reflects and acts on the selected PATH row renderVerdictBar rendered off currentIntent() regardless of mode, so in PATH mode the bar's label, decision, staleness and blocked flag all described the (now invisible) intents cursor's whole section, not the row on screen -- proved by execution: after settling every hunk on the board, PATH mode's selected row read "START HERE (1) src/guards.cpp" while the bar read "2 . Profiler", settled, with an Undo button: that button cleared two hunks nowhere near the visible row and left the visible row's own verdict alone. Undo, askAgentToFix, confirmStillGood and the bar's own next/previous buttons were all in the same state -- the last three because VerdictHost's methods either passed the SAME stale intent straight through, or (previousIntent/nextIntent, next Unsettled) called the intents-only method directly regardless of mode. renderVerdictBarForPathStep reuses SectionStates against a throwaway single-hunk ReviewIntent (pathStepAsIntent) for decision/staleness -- "what does this one-hunk grouping's state look like" is exactly the question SectionStates.stateOf already answers correctly for any ReviewIntent, real or synthetic, so nothing there needed to change. Undo/confirmStillGood now resolve the current PATH step directly (digestOfPathStep) instead of trusting whatever intent the bar was handed; askAgentToFix resolves findings through the step's REAL covering intents the same way the blocking-finding fix does (sharing intentsCoveringPathStep); previousIntent/nextIntent/nextUnsettled now dispatch through moveSelection/nextUnsettled the same way the keyboard already does. Progress stays the whole-review count either way -- it was never the current intent's own count, so PATH mode changes nothing about it. Mutation-verified by reverting BOTH the render fix and undo's own resolution together (each alone still left the other correct, since either one independently resolves the right target -- a deliberate redundancy, not a coincidence): the combined revert reproduces the exact "Undo clears hunks nowhere near the screen" trace the review found. previousIntent/nextIntent verified independently: reverting just that dispatch back to moveIntent fails the bar's-own-next-button test on its own. --- .../drydock/ui/review/SessionReviewView.java | 115 ++++++++++++++++-- .../drydock/ui/review/ReviewPathModeTest.java | 74 +++++++++++ 2 files changed, 182 insertions(+), 7 deletions(-) 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 552c2dd6..32887f08 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -1515,8 +1515,12 @@ private static boolean isDescendantOf(Node node, Node ancestor) { } private void renderVerdictBar(ReviewScope scope) { - Optional current = currentIntent(); 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); @@ -1537,6 +1541,41 @@ private void renderVerdictBar(ReviewScope scope) { 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. * @@ -1732,12 +1771,22 @@ private static Optional digestOfPathStep(UnifiedDiff diff, ReadingPath.S return digests.isEmpty() ? Optional.empty() : Optional.of(digests.get(0)); } + /** 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} resolves a step through, since a - * blocking finding cannot be asked about a throwaway synthetic id - * ({@link #pathStepAsIntent}) it could never actually name. + * #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(); @@ -1749,6 +1798,24 @@ private List intentsCoveringPathStep(ReadingPath.Step step) { .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 @@ -1873,6 +1940,16 @@ public void requestChanges(ReviewIntent intent, SettleUnit unit) { @Override public void 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). + if (pathMode) { + currentPathStep().ifPresent(step -> selectedScope().ifPresent(scope -> + host.askAgentToFix(scope, pathStepAsIntent(step), + openFindingsForPathStep(scope, step)))); + return; + } selectedScope().ifPresent(scope -> host.askAgentToFix(scope, intent, host.findings(scope).stream() .filter(finding -> !finding.resolved()) @@ -1887,12 +1964,32 @@ public void undo(ReviewIntent intent) { // 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(); @@ -1901,7 +1998,11 @@ public void confirmStillGood(ReviewIntent intent) { @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 @@ -1911,12 +2012,12 @@ public void submit() { @Override public void previousIntent() { - moveIntent(-1); + moveSelection(-1); } @Override public void nextIntent() { - moveIntent(1); + moveSelection(1); } } diff --git a/app/src/test/java/app/drydock/ui/review/ReviewPathModeTest.java b/app/src/test/java/app/drydock/ui/review/ReviewPathModeTest.java index e34df3c9..fbf408bd 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewPathModeTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewPathModeTest.java @@ -8,6 +8,7 @@ import app.drydock.review.Severity; import app.drydock.ui.ShortcutsOverlay; +import javafx.scene.control.Button; import javafx.scene.input.KeyCode; import org.junit.jupiter.api.Test; import org.testfx.util.WaitForAsyncUtils; @@ -168,6 +169,63 @@ void uInPathModeUndoesOnlyWhatAJustSettled() { "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 @@ -241,4 +299,20 @@ private String digestOf(String file, int hunkIndex) { .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(); + } } From d1eff4cf41b1d8003fef243b3241711807a443a3 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 21:24:12 +0200 Subject: [PATCH 080/113] Pin the verdict bar's PATH-mode render, and askAgentToFix's hand-off renderVerdictBarForPathStep had no regression test at all -- reverting its dispatch left the entire targeted suite green, the eighth correct-but-unpinned behaviour in this plan. Settling section-1 with one keyboard a (rail focused) auto-advances the intents cursor to section-2 ("Profiler", still unsettled) while also settling guards.cpp's hunk as a side effect, since it is section-1's own third hunk -- so the intents cursor and the selected PATH row genuinely disagree, and the new test asserts the bar names guards.cpp and shows settled, not "2 . Profiler" with Approve/Request-changes buttons. Also pins askAgentToFix's hand-off (reasoned through and disclosed untested last round, now covered): two findings on section-1, one on section-2, PATH mode walked to profiler.cpp's row, asserts the hand-off carries exactly that one finding. Mutation applied exactly as instructed: removed the pathMode branch from renderVerdictBar. FAILED at the new test's label assertion -- the bar rendered "2 . Profiler" and did not render the settled state, instead of naming guards.cpp as settled. Reverted; confirmed green, including a second full run of the targeted package after an unrelated one-off flake in a real-focus TestFX test (passed in isolation and on re-run, unrelated to this change). --- .../drydock/ui/review/ReviewPathModeTest.java | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/app/src/test/java/app/drydock/ui/review/ReviewPathModeTest.java b/app/src/test/java/app/drydock/ui/review/ReviewPathModeTest.java index fbf408bd..74cbed8a 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewPathModeTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewPathModeTest.java @@ -9,6 +9,7 @@ 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; @@ -260,6 +261,76 @@ void aInPathModeIsRefusedByABlockingFindingNamingTheRealSection() { + "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() { @@ -315,4 +386,28 @@ private void clickUndoButton() { .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)); + } } From 70bf8bc1e001231a5e01bb8cc54db4895711cbf6 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 22:02:48 +0200 Subject: [PATCH 081/113] A hunk says what it has to do with the hunks around it Link rows live in the hunk's own row model (ReviewDiffRow.LinkRow), 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 come straight from ReadingPath.Link.label(), which already names files and symbols rather than raw hunk ids; a glyph in front of it (calls / called by / same-concept) names the relationship the label itself does not state. Clicking resolves the link's target hunk id back to (file, index) through ReviewIntent.parseHunkId (now public, for exactly this) and reveals it through the column's existing revealHunk path -- the same one an intent selection or a PATH step already uses, so a link click and a rail click land through the same code rather than two that could drift apart. .review-link-row carries its own -fx-text-fill, the same fix .review-collapsed-run already needed: an unstyled Button falls back to modena's light-button text colour against this column's dark background, which is Task 18's 1.13:1 defect on a different row. Measured on a real screenshot against a genuinely cross-referencing diff: 5.35:1 and 5.71:1. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/app/drydock/review/ReviewIntent.java | 10 +- .../drydock/ui/review/ReviewDiffColumn.java | 85 +++++++- .../app/drydock/ui/review/ReviewDiffRow.java | 18 ++ .../app/drydock/ui/review/ReviewDiffRows.java | 44 +++- .../drydock/ui/review/SessionReviewView.java | 20 ++ app/src/main/resources/app/drydock/ui/app.css | 40 +++- .../drydock/ui/review/ReviewDiffRowsTest.java | 97 +++++++++ .../ui/review/ReviewLinkFooterWiringTest.java | 188 ++++++++++++++++ .../drydock/ui/review/ReviewLinkRowTest.java | 203 ++++++++++++++++++ 9 files changed, 688 insertions(+), 17 deletions(-) create mode 100644 app/src/test/java/app/drydock/ui/review/ReviewLinkFooterWiringTest.java create mode 100644 app/src/test/java/app/drydock/ui/review/ReviewLinkRowTest.java diff --git a/app/src/main/java/app/drydock/review/ReviewIntent.java b/app/src/main/java/app/drydock/review/ReviewIntent.java index 4e261f4c..6fb47598 100644 --- a/app/src/main/java/app/drydock/review/ReviewIntent.java +++ b/app/src/main/java/app/drydock/review/ReviewIntent.java @@ -180,7 +180,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/ui/review/ReviewDiffColumn.java b/app/src/main/java/app/drydock/ui/review/ReviewDiffColumn.java index 953d7ac4..ad3fd768 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,9 @@ import app.drydock.git.DiffScope; import app.drydock.git.DiffService; import app.drydock.git.UnifiedDiff; +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; @@ -37,6 +39,7 @@ import java.util.ArrayList; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; @@ -226,6 +229,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, @@ -1062,7 +1074,30 @@ 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())); } /** @@ -1188,6 +1223,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 @@ -1532,6 +1568,53 @@ 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 -> revealHunk(anchor.file(), anchor.hunkIndex())); + } + 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..22db695f 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 @@ -107,4 +108,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..d4430083 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) { @@ -104,8 +122,13 @@ 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))); + 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/SessionReviewView.java b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java index 32887f08..b1d2b040 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -41,6 +41,7 @@ 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; @@ -1062,6 +1063,7 @@ public void refreshReviewState() { margin.invalidate(null); margin.setFindings(findingsForMargin(scope.get())); diffColumn.refreshPins(); + diffColumn.setLinks(linksByHunk()); if (pathMode) { List steps = currentPath().steps(); if (!steps.isEmpty()) { @@ -1291,6 +1293,24 @@ private ReadingPath.Path currentPath() { 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 diff --git a/app/src/main/resources/app/drydock/ui/app.css b/app/src/main/resources/app/drydock/ui/app.css index c3cdea40..cbdfe836 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; } 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/ReviewLinkFooterWiringTest.java b/app/src/test/java/app/drydock/ui/review/ReviewLinkFooterWiringTest.java new file mode 100644 index 00000000..6dc72b01 --- /dev/null +++ b/app/src/test/java/app/drydock/ui/review/ReviewLinkFooterWiringTest.java @@ -0,0 +1,188 @@ +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.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()); + stage.setScene(scene); + stage.show(); + } + + @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)); + interact(() -> view.diagShowDiff(scope, host.diff)); + 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"); + } + + /** 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"); + } + + 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..2bc9d190 --- /dev/null +++ b/app/src/test/java/app/drydock/ui/review/ReviewLinkRowTest.java @@ -0,0 +1,203 @@ +package app.drydock.ui.review; + +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()); + stage.setScene(scene); + stage.show(); + } + + @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()); + } + + /** 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()); + } + + // ---- 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; + } + + private List renderedHunkFiles() { + List files = new ArrayList<>(); + interact(() -> lookup(".review-hunk-file").queryAll() + .forEach(node -> files.add(((Label) node).getText()))); + return files; + } + + 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)); + } +} From ffd71c2e7f8a03d3abcc6c9cbda76ba1813ea81d Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 22:40:37 +0200 Subject: [PATCH 082/113] revealHunk matches a hunk's real index, not its position among rendered headers HunkHeader now carries the hunk's real index into its file's own hunk list. A filtered view -- an intent naming only some of a file's hunks, which is exactly the shape a link footer's cross-file target routinely has -- used to make the Nth RENDERED header stand in for hunk N, landing on whichever hunk happened to render first while still reporting success. Matching on the header's own real index instead of a counted position fixes that for every caller of revealHunk, not only links. Co-Authored-By: Claude Opus 5 (1M context) --- .../drydock/ui/review/ReviewDiffColumn.java | 22 +++++-- .../app/drydock/ui/review/ReviewDiffRow.java | 10 ++- .../app/drydock/ui/review/ReviewDiffRows.java | 2 +- .../ui/review/DiffLineSelectionTest.java | 2 +- .../drydock/ui/review/ReviewLinkRowTest.java | 63 +++++++++++++++++++ 5 files changed, 90 insertions(+), 9 deletions(-) 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 ad3fd768..1e6b23a2 100644 --- a/app/src/main/java/app/drydock/ui/review/ReviewDiffColumn.java +++ b/app/src/main/java/app/drydock/ui/review/ReviewDiffColumn.java @@ -882,10 +882,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 @@ -897,7 +908,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)) { @@ -906,7 +916,7 @@ boolean revealHunk(String file, int hunkIndex) { if (firstCard < 0) { firstCard = i; } - if (seen++ == hunkIndex) { + if (header.hunkIndex() == hunkIndex) { list.scrollTo(i); return true; } 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 22db695f..2ebce2f2 100644 --- a/app/src/main/java/app/drydock/ui/review/ReviewDiffRow.java +++ b/app/src/main/java/app/drydock/ui/review/ReviewDiffRow.java @@ -34,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() { 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 d4430083..50dfc347 100644 --- a/app/src/main/java/app/drydock/ui/review/ReviewDiffRows.java +++ b/app/src/main/java/app/drydock/ui/review/ReviewDiffRows.java @@ -121,7 +121,7 @@ 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())); + 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())) { 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/ReviewLinkRowTest.java b/app/src/test/java/app/drydock/ui/review/ReviewLinkRowTest.java index 2bc9d190..072cb7e1 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewLinkRowTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewLinkRowTest.java @@ -137,6 +137,45 @@ void aLinkRowIsFocusTraversable() { assertTrue(link.isFocusTraversable()); } + + + /** + * 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) { @@ -157,6 +196,30 @@ private List renderedHunkFiles() { .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( From 24248f12b2eacb586b7ac12ce08ca5f8ee27483d Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 22:41:13 +0200 Subject: [PATCH 083/113] A link click widens out of a filter that hides its own target PATH mode narrows the diff column to a synthetic one-hunk intent before a footer's click can even fire, and an ordinary intent filter can just as easily name only some of a file's hunks -- a link is cross-file by construction, so its target is routinely outside whichever narrow view is showing. Widening to the whole scope first is what makes the click land instead of silently searching a row list that never contained the target. revealHunk's own return value is no longer discarded: a target it could not reach (past the row cap) now logs a warning instead of looking identical to a click that worked. Co-Authored-By: Claude Opus 5 (1M context) --- .../drydock/ui/review/ReviewDiffColumn.java | 22 ++++++++++++- .../drydock/ui/review/ReviewLinkRowTest.java | 33 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) 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 1e6b23a2..60dc3267 100644 --- a/app/src/main/java/app/drydock/ui/review/ReviewDiffColumn.java +++ b/app/src/main/java/app/drydock/ui/review/ReviewDiffColumn.java @@ -1622,7 +1622,27 @@ private static String glyphFor(String kind) { * place through the same code. */ private void selectLinkTarget(String hunkId) { - ReviewIntent.parseHunkId(hunkId).ifPresent(anchor -> revealHunk(anchor.file(), anchor.hunkIndex())); + 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) { diff --git a/app/src/test/java/app/drydock/ui/review/ReviewLinkRowTest.java b/app/src/test/java/app/drydock/ui/review/ReviewLinkRowTest.java index 072cb7e1..991d2114 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewLinkRowTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewLinkRowTest.java @@ -137,6 +137,39 @@ void aLinkRowIsFocusTraversable() { 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()); + } + /** From 8fcd5a52864789ee02df18431feeba777c6baee6 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 22:41:37 +0200 Subject: [PATCH 084/113] setLinks keeps an open comment composer instead of silently dropping it The graph a link map is computed from lands asynchronously, well after a reader may have already opened the gutter composer -- unlike rebuild(), setLinks rebuilt the row list without re-inserting the composer row, so a background graph completion could erase an in-progress comment through no gesture of the reader's own. Co-Authored-By: Claude Opus 5 (1M context) --- .../drydock/ui/review/ReviewDiffColumn.java | 5 ++ .../drydock/ui/review/ReviewLinkRowTest.java | 48 +++++++++++++++++-- 2 files changed, 50 insertions(+), 3 deletions(-) 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 60dc3267..06a7262d 100644 --- a/app/src/main/java/app/drydock/ui/review/ReviewDiffColumn.java +++ b/app/src/main/java/app/drydock/ui/review/ReviewDiffColumn.java @@ -1108,6 +1108,11 @@ void setLinks(Map> byHunkId) { } 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(); } /** diff --git a/app/src/test/java/app/drydock/ui/review/ReviewLinkRowTest.java b/app/src/test/java/app/drydock/ui/review/ReviewLinkRowTest.java index 991d2114..f365e94f 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewLinkRowTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewLinkRowTest.java @@ -126,6 +126,28 @@ void clickingALinkScrollsToTheLabelledTargetHunk() { "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() { @@ -170,8 +192,6 @@ void clickingALinkFilteredOutOfTheCurrentViewWidensAndReachesItsTarget() { + 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 @@ -223,12 +243,35 @@ private List linkRowTexts() { 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() @@ -253,7 +296,6 @@ private static UnifiedDiff.FileDiff threeHunkFile() { 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();"), From 1d953a53e9472a1c5ce828ad599eb21fe2504aa9 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 22:42:24 +0200 Subject: [PATCH 085/113] Request the scope's graph unconditionally, so links show under a reviewer's own grouping too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The graph link footers are computed from used to build only where no reviewer grouping existed or PATH mode was showing, so a scope reviewed through review_intents -- the intended path -- rendered zero footers. requestGraph is a no-op for a diff instance already graphed or building, so this costs nothing on a re-diff or a re-selection. The rail's own "refining grouping…" banner is gated separately now, on whether the RAIL's own content depends on this build (PATH mode, or no reviewer grouping) rather than on whether a graph is building at all -- a reviewer's already-final INTENTS grouping must not flash it purely because a background build is running for links. Co-Authored-By: Claude Opus 5 (1M context) --- .../drydock/ui/review/SessionReviewView.java | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) 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 b1d2b040..59d9b50c 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -662,17 +662,18 @@ public SessionReviewView(Host host, DiffService diffService, McpActivityLog acti outcomeByScope.put(scopeId, outcome); boolean selected = selectedScope().map(scope -> scope.id().equals(scopeId)).orElse(false); if (outcome instanceof DiffOutcome.Loaded loaded) { - boolean noReviewerGrouping = scopeById(scopeId) - .map(candidate -> !host.hasReviewerGrouping(candidate)).orElse(true); - // PATH mode needs a graph even where a reviewer's own - // grouping already made building one for the rail's OWN - // purposes pure waste (Host#hasReviewerGrouping) -- a - // re-diff of the selected scope while PATH mode is showing - // must still refresh what it lists, not silently keep - // rendering the previous diff's steps. - if (noReviewerGrouping || (pathMode && selected)) { - requestGraph(scopeId, loaded.diff()); - } + // 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); } @@ -1075,7 +1076,17 @@ public void refreshReviewState() { intentRail.setIntents(currentIntents, currentIntent().map(ReviewIntent::id).orElse(null), emptyReason()); } - intentRail.setGroupingPending(graphBuilding.contains(scopeId)); + // 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()); From 7dbedb7ac6a8e926add68bd5cbafdf2dcf7039e9 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 23:24:41 +0200 Subject: [PATCH 086/113] Pin the reviewer-grouping link footer fix that mutation testing found unguarded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 19 round 2: the round-1 fix (requestGraph called unconditionally on Loaded, and the rail's own banner re-gated on hasReviewerGrouping) had no test that could catch its loss -- the full targeted suite passed 534/534 with either half of it reverted. Adds a test with a reviewer grouping installed (host.intents.set), asserting both that link footers still render and that the "refining grouping…" banner never flashes for an already-final grouping while the graph builds purely for those footers. The banner is sampled synchronously inside the same FX-thread task that triggers the diff, since the background build can finish faster than a subsequent poll would ever observe it. The existing ungrouped test gains the same sample as its control, asserting the banner DOES show there -- otherwise the new test's absence assertion would pass just as well against a banner that is simply broken. Mutation-verified: reverting the requestGraph gate back to the narrow `noReviewerGrouping || (pathMode && selected)` times out the new test with "no link footer ever rendered"; reverting the banner gate back to unconditional `graphBuilding.contains(scopeId)` fails only the new test's banner assertion while the ungrouped control still passes. Production code is unchanged from a7ec842. Co-Authored-By: Claude Opus 5 (1M context) --- .../ui/review/ReviewLinkFooterWiringTest.java | 76 ++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/app/drydock/ui/review/ReviewLinkFooterWiringTest.java b/app/src/test/java/app/drydock/ui/review/ReviewLinkFooterWiringTest.java index 6dc72b01..d6b0480f 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewLinkFooterWiringTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewLinkFooterWiringTest.java @@ -2,6 +2,7 @@ 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; @@ -104,7 +105,14 @@ void theRealReadingPathsLinksReachTheDiffColumnWithNoPathModeNeeded() { // build without PATH mode ever being entered. interact(() -> view.showScopes(new SessionReviewScopes.Scopes(scope, Optional.empty()), SessionReviewScopes.Choice.LOCAL)); - interact(() -> view.diagShowDiff(scope, host.diff)); + // 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(); @@ -130,6 +138,44 @@ void theRealReadingPathsLinksReachTheDiffColumnWithNoPathModeNeeded() { "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(); @@ -144,6 +190,34 @@ private List awaitLinkFooters() { 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() From 20484936e350f0f5f980580cd573ce97414ba9fc Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sat, 22 Aug 2026 23:58:31 +0200 Subject: [PATCH 087/113] Called from 3 places outside the change, and you can see which three The out-of-diff fan-in scan had zero production callers: all three sites hardcoded a placeholder saying "unavailable", so rank term 1 was permanently zero and every reason read "outside callers unknown". It runs now. On the board it runs off the FX thread, on the same virtual-thread executor the graph build uses and from that build's completion -- the scan's patterns ARE the graph's changed declarations, so it can neither run before one exists nor run on the thread that draws. A scan that comes back saying exactly what the board already showed records nothing and refreshes nothing: re-rendering the rail to say nothing changed moves the ground under whoever is mid-review. At the MCP boundary it runs synchronously, because that call is already off the FX thread and handing an agent a first-call-always-unavailable answer it will then act on is worse than making it wait. The count then becomes a control. The rail's reason label is the button's graphic rather than the button minting its own text, so ReadingPath stays the one author of that sentence and it keeps the fill that makes it readable. Its ActionEvent is consumed -- un-consumed it bubbles to the row button, selects the row, rebuilds the rail, detaches the node the popover is anchored to, and closes the popover in the same gesture that opened it. The popover is the symbol lens's, on a third source and in the same field, so Escape already unwinds it. It shows every occurrence with its file and line, says "N usages outside this change" in those words, and reports a refused Explorer jump instead of appearing to do nothing. The two occurrence records are not contorted into one: fan-in has no in-diff flag and needs none, since every row is outside the change by construction. 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 click from the party that can answer: a button -- never a key, `a` is the approve gesture -- files the question as a real review comment on the declaring file and hands it to the bound session. An unavailable scan shows no count rather than a zero: absent and none must not look the same. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/app/drydock/mcp/McpToolRouter.java | 33 +- .../app/drydock/review/OutOfDiffFanIn.java | 28 ++ .../drydock/ui/review/ReviewDiffColumn.java | 128 +++++ .../drydock/ui/review/ReviewIntentRail.java | 68 ++- .../drydock/ui/review/SessionReviewView.java | 279 ++++++++++- app/src/main/resources/app/drydock/ui/app.css | 44 ++ .../ui/review/ReviewFanInPopoverTest.java | 459 ++++++++++++++++++ 7 files changed, 1009 insertions(+), 30 deletions(-) create mode 100644 app/src/test/java/app/drydock/ui/review/ReviewFanInPopoverTest.java diff --git a/app/src/main/java/app/drydock/mcp/McpToolRouter.java b/app/src/main/java/app/drydock/mcp/McpToolRouter.java index 823a22b8..199f4453 100644 --- a/app/src/main/java/app/drydock/mcp/McpToolRouter.java +++ b/app/src/main/java/app/drydock/mcp/McpToolRouter.java @@ -337,33 +337,34 @@ private JsonValue reviewScope(ManagedSessionId caller, JsonValue arguments) thro return result; } - /** - * 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} here 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. No out-of-diff fan-in scan backs the rank - * here -- {@link OutOfDiffFanIn#scan} spawns a blocking {@code git grep} - * per call, a separate concern from reordering an existing payload -- so - * {@code unavailable=true} is the honest input for a signal nothing - * computed, the same choice the rail makes. - */ - private static final OutOfDiffFanIn.Result NO_FAN_IN_SCAN = new OutOfDiffFanIn.Result(Map.of(), true); - /** * {@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) { try { ChangeGraph graph = graphBuilder.apply(diff); List sections = Sections.of(diff, graph); - ReadingPath.Path path = ReadingPath.of(diff, graph, sections, NO_FAN_IN_SCAN); + ReadingPath.Path path = ReadingPath.of(diff, graph, sections, + OutOfDiffFanIn.forScope(scope, graph, diff)); return Optional.of(ReviewToolCodec.sectionsToJson(path.sections())); } catch (RuntimeException e) { LOG.log(Level.WARNING, "review_scope: could not compute sections for scope " diff --git a/app/src/main/java/app/drydock/review/OutOfDiffFanIn.java b/app/src/main/java/app/drydock/review/OutOfDiffFanIn.java index af61acad..d7766905 100644 --- a/app/src/main/java/app/drydock/review/OutOfDiffFanIn.java +++ b/app/src/main/java/app/drydock/review/OutOfDiffFanIn.java @@ -1,5 +1,6 @@ package app.drydock.review; +import app.drydock.git.UnifiedDiff; import app.drydock.process.ProcessResult; import app.drydock.process.ProcessRunner; import app.drydock.process.ProcessTimeoutException; @@ -13,9 +14,11 @@ 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; @@ -86,6 +89,31 @@ 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 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 06a7262d..260cba75 100644 --- a/app/src/main/java/app/drydock/ui/review/ReviewDiffColumn.java +++ b/app/src/main/java/app/drydock/ui/review/ReviewDiffColumn.java @@ -3,6 +3,7 @@ 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; @@ -1555,6 +1556,133 @@ 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, Runnable 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. + Button ask = new Button("Ask the agent about these callers"); + ask.getStyleClass().add("review-fanin-ask"); + ask.setMaxWidth(Double.MAX_VALUE); + ask.setOnAction(e -> { + hideLens(); + askTheAgent.run(); + }); + + // 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); + + 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) { + lensPopup.show(anchor, 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) { 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 aefaea6a..5f7919aa 100644 --- a/app/src/main/java/app/drydock/ui/review/ReviewIntentRail.java +++ b/app/src/main/java/app/drydock/ui/review/ReviewIntentRail.java @@ -28,8 +28,10 @@ import java.util.List; import java.util.Map; import java.util.Optional; +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; /** @@ -103,6 +105,21 @@ enum Mode { INTENTS, PATH } 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(); /** * How a card learns what its section adds up to. A section has no verdict @@ -166,6 +183,17 @@ 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; } @@ -472,7 +500,7 @@ private Button buildPathRow(ReadingPath.Step step, int indexInFile, int hunksInF reason.getStyleClass().add("review-path-reason"); reason.setWrapText(true); - VBox content = new VBox(4, headerRow, reason) { + VBox content = new VBox(4, headerRow, reasonNode(step, reason)) { @Override protected double computePrefHeight(double width) { // Same reason buildCard's own content VBox overrides this: @@ -499,6 +527,44 @@ protected double computePrefHeight(double width) { 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"); + button.setGraphic(reason); + 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"); 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 59d9b50c..6d7cf4b6 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -38,6 +38,7 @@ 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; @@ -452,15 +453,47 @@ private record IntentsCacheEntry(String scopeId, UnifiedDiff diff, ChangeGraph g private boolean pathMode; /** - * The reading path's rank has no out-of-diff fan-in scan behind it here: - * {@link OutOfDiffFanIn#scan} spawns a blocking {@code git grep} per - * scope, and wiring that in is a separate concern from giving the rail a - * second mode. {@code unavailable=true} is the honest input for a signal - * that was never computed -- {@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. + * 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 NO_FAN_IN_SCAN = new OutOfDiffFanIn.Result(Map.of(), true); + 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; @@ -476,8 +509,13 @@ private record IntentsCacheEntry(String scopeId, UnifiedDiff diff, ChangeGraph g 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. */ - private record PathCacheEntry(String scopeId, UnifiedDiff diff, ChangeGraph graph, ReadingPath.Path path) { + /** + * 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. */ @@ -641,6 +679,12 @@ public SessionReviewView(Host host, DiffService diffService, McpActivityLog acti 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 -> { @@ -1293,14 +1337,15 @@ private ReadingPath.Path currentPath() { 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.graph() == graph && cached.fanIn() == fanIn) { return cached.path(); } ReadingPath.Path computed = - ReadingPath.of(diff, graph, Sections.of(diff, graph), NO_FAN_IN_SCAN); - pathCache = new PathCacheEntry(scopeId, diff, graph, computed); + ReadingPath.of(diff, graph, Sections.of(diff, graph), fanIn); + pathCache = new PathCacheEntry(scopeId, diff, graph, fanIn, computed); return computed; } @@ -1344,6 +1389,12 @@ private void requestGraph(String scopeId, UnifiedDiff diff) { 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) -> { @@ -1374,6 +1425,7 @@ private void requestGraph(String scopeId, UnifiedDiff diff) { 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 @@ -1404,6 +1456,131 @@ private void requestGraph(String scopeId, UnifiedDiff diff) { }); } + /** + * 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; + } + /** * Which empty the rail is showing. A scope with a checkout whose diff has * not arrived is loading; one without a checkout never will; a loaded @@ -1802,6 +1979,77 @@ private static Optional digestOfPathStep(UnifiedDiff diff, ReadingPath.S 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 void askAboutFanIn(ReadingPath.Step step) { + Optional scope = selectedScope(); + Map> bySymbol = fanInOccurrences(step.file()); + Optional lineKey = lineKeyOfPathStep(step); + if (scope.isEmpty() || bySymbol.isEmpty() || lineKey.isEmpty()) { + return; + } + 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); + host.askAgentToFix(scope.get(), pathStepAsIntent(step), List.of(stamped)); + refreshReviewState(); + diffColumn.refreshPins(); + } + + /** + * 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(); @@ -1864,6 +2112,11 @@ int selectedPathStepForTest() { * {@code diag*} accessor is: it reads the rail's {@code ObservableList} * of rows, which the FX thread rebuilds wholesale on every render. */ + /** See {@link #fanInScanThread} -- the thread the last fan-in scan ran on. */ + String diagFanInScanThread() { + return fanInScanThread; + } + List pathRowTextsForTest() { return ReviewDiagFxThread.call(intentRail::diagPathRowTexts); } diff --git a/app/src/main/resources/app/drydock/ui/app.css b/app/src/main/resources/app/drydock/ui/app.css index cbdfe836..391bf460 100644 --- a/app/src/main/resources/app/drydock/ui/app.css +++ b/app/src/main/resources/app/drydock/ui/app.css @@ -3566,6 +3566,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/ui/review/ReviewFanInPopoverTest.java b/app/src/test/java/app/drydock/ui/review/ReviewFanInPopoverTest.java new file mode 100644 index 00000000..425e7ba9 --- /dev/null +++ b/app/src/test/java/app/drydock/ui/review/ReviewFanInPopoverTest.java @@ -0,0 +1,459 @@ +package app.drydock.ui.review; + +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 3 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. Reverting the wiring to the old placeholder fails + * {@link #aScanThatRanAndFoundNothingIsNotAnUnavailableScan} and + * {@link #clickingTheFanInCountListsTheCallersWithFileAndLine} at once.

+ * + *

Absent is not zero. The three scope variants below are + * the whole point of the class: a scan that found callers, a scan that ran + * and found none, and a scan 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, and the only one the diff carries. */ + private static final String CHANGED_FILE = "src/Guards.java"; + + /** What the change declares, and what the unchanged files below use. */ + private static final String SYMBOL = "JmpCtxScope"; + + /** Declared by the change, referenced nowhere in the repository. */ + 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()); + stage.setScene(scene); + stage.show(); + } + + @AfterEach + void tearDown() { + interact(view::close); + diffService.close(); + host.store.close(); + } + + // ---- the popover -------------------------------------------------------- + + @Test + void clickingTheFanInCountListsTheCallersWithFileAndLine() { + showBoard(repo, SYMBOL); + awaitFanInCount(); + + clickOn(".review-fanin-count"); + WaitForAsyncUtils.waitForFxEvents(); + + List texts = popoverTexts(); + assertTrue(texts.stream().anyMatch(text -> text.matches("src/Other\\.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(CHANGED_FILE + ":")), + "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() { + showBoard(repo, SYMBOL); + 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 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() { + showBoard(repo, SYMBOL); + 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(CHANGED_FILE), "the question must name the file: " + body); + assertTrue(body.contains(SYMBOL), "the question must name the symbol: " + body); + assertEquals(CHANGED_FILE, host.findings(scope).get(0).file()); + assertEquals(1, host.handedOffPrompts.size(), + "the question must reach the bound session, not just the store"); + assertFalse(popoverShowing(), "asking closes the popover"); + } + + /** Escape unwinds the topmost thing; this popover is now the topmost thing. */ + @Test + void escapeClosesTheFanInPopover() { + showBoard(repo, SYMBOL); + 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; + showBoard(repo, SYMBOL); + 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; + showBoard(repo, SYMBOL); + 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() { + showBoard(repo, SYMBOL); + 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"); + } + + // ---- 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() { + showBoard(repo, LONELY_SYMBOL); + 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() { + showBoard(notARepo, SYMBOL); + 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() { + showBoard(repo, SYMBOL); + 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 every time + * it is opened. + */ + @Test + void thePopoverListsTheSameCallersInTheSameOrderEveryTime() { + showBoard(repo, SYMBOL); + awaitFanInCount(); + + clickOn(".review-fanin-count"); + WaitForAsyncUtils.waitForFxEvents(); + List first = whereRows(); + interact(view::unwindOne); + + clickOn(".review-fanin-count"); + WaitForAsyncUtils.waitForFxEvents(); + List second = whereRows(); + + assertEquals(first, second); + assertFalse(first.isEmpty(), "there is nothing to compare if nothing rendered"); + } + + // ---- board --------------------------------------------------------------- + + /** + * Shows a board whose scope is checked out at {@code worktree} and whose + * one changed file declares {@code declared}, then enters PATH mode -- + * where the reading path's reasons, and so the fan-in count, live. + */ + private void showBoard(Path worktree, String declared) { + scope = registry.mint(ReviewScopeRegistry.spec(ReviewScope.Kind.WORKING_TREE, + worktree, Optional.of(worktree), "main", "HEAD", + Optional.empty(), Optional.empty())); + UnifiedDiff diff = new UnifiedDiff(List.of(new UnifiedDiff.FileDiff( + CHANGED_FILE, "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), "class " + declared + " { }"))))))); + host.diff = diff; + interact(() -> view.showScopes(new SessionReviewScopes.Scopes(scope, Optional.empty()), + SessionReviewScopes.Choice.LOCAL)); + interact(() -> view.diagShowDiff(scope, diff)); + WaitForAsyncUtils.waitForFxEvents(); + press(KeyCode.P).release(KeyCode.P); + WaitForAsyncUtils.waitForFxEvents(); + await("PATH mode to populate its rows", () -> !view.pathRowTextsForTest().isEmpty()); + } + + 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); + } + })); + return texts; + } + + /** Just the {@code file:line} rows, in rendered order. */ + private List whereRows() { + return popoverTexts().stream().filter(text -> text.matches("[^\\s]+:\\d+")).toList(); + } + + private java.util.stream.Stream openPopups() { + return Window.getWindows().stream() + .filter(PopupWindow.class::isInstance) + .map(PopupWindow.class::cast) + .filter(Window::isShowing); + } + + private static void collectText(Node node, List into) { + if (node instanceof Labeled labeled && labeled.getText() != null + && !labeled.getText().isBlank()) { + into.add(labeled.getText()); + } + if (node instanceof Parent parent) { + for (Node child : parent.getChildrenUnmodifiable()) { + collectText(child, into); + } + } + } + + // ---- a real repository --------------------------------------------------- + + /** + * A committed repository where {@link #SYMBOL} is declared in the changed + * file and 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. + */ + 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("src/Guards.java"), "class " + SYMBOL + " { }\n"); + Files.writeString(repo.resolve("src/Other.java"), + "class Other {\n void a() { new " + SYMBOL + "(); }\n}\n"); + Files.writeString(repo.resolve("src/More.java"), + "class More {\n void b() { new " + SYMBOL + "(); }\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)); + } +} From 4bf9e7bec4618a247e44e8a1f809945c61a47bbf Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sun, 23 Aug 2026 01:12:01 +0200 Subject: [PATCH 088/113] Keep the reader on their hunk when the scan re-sorts the path under them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fan-in is the reading path's first rank term, so the grep landing mid-read re-sorts the rail. pathIndex is a POSITION, and clamping it is not keeping a place: step ① became a different file, the diff column re-narrowed to it, and since settleUnit() is PATH_STEP unconditionally in this mode the reader's next `a` approved a hunk they were never shown. The third time on this branch that a gesture's scope has silently stopped matching what the reader sees. The cursor is re-anchored by hunk id now, the way reanchorCursor already does it for INTENTS and under the same only-when-it-changed guard, so [ and ] still move where they are told. Two more silences, both the shape ruling 1 legislated against. askAgentToFix is a boolean now rather than a void that discarded sendToBoundSession's answer: with no session bound, the popover used to close on a question nobody had been asked, leaving a review comment the reviewer never knowingly wrote. It says so instead, and the button says what it does. And the popover was owned by the rail row that every refresh replaces -- so it closed on the refresh its own ask button caused. It is owned by the column now, which outlives a rebuild; consuming the fan-in event is pinned by the cursor not moving rather than by the popover surviving. An occurrence is attributed to a symbol only when the line names it as a whole word. ZetaSymHelper was two uses of ZetaSym before, and that number is rank term 1 -- an inflated count does not merely read wrong, it reorders what a human reads next. The MCP router scans for real, so an agent and a human see the same ① rather than two orders, and caches per (scope, diff) so an agent polling a review does not spawn one full-worktree grep per poll. The rail's own accessor reads getGraphic explicitly: a Labeled's graphic is not one of its children until its skin exists, and for ~80ms after every render a fan-in row read as having no reason at all -- which makes any assertion over those texts pass or fail on timing. And the screenshot earned its keep, again. The reason was cut to one line and ellipsized in the running app -- "file called from 16 places outside the…" -- because a Button asks its graphic for prefHeight(-1) and a wrapping Label answers that as one line. The test suite could not see it: at full rail width that sentence occupies 189px of a 190px slot and fits. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/app/drydock/DrydockApplication.java | 51 ++- .../java/app/drydock/mcp/McpToolRouter.java | 34 ++ .../app/drydock/review/OutOfDiffFanIn.java | 74 +++- .../java/app/drydock/ui/MainWorkspace.java | 29 +- .../drydock/ui/review/ReviewDiffColumn.java | 38 +- .../drydock/ui/review/ReviewIntentRail.java | 99 ++++- .../drydock/ui/review/SessionReviewView.java | 86 +++- .../app/drydock/mcp/McpRouterFixture.java | 14 +- .../mcp/McpToolRouterSectionsTest.java | 115 ++++++ .../drydock/review/OutOfDiffFanInTest.java | 74 ++++ .../ui/OpenSessionTabReviewSubTabTest.java | 3 +- .../app/drydock/ui/review/FakeReviewHost.java | 14 +- .../ui/review/ReviewFanInPopoverTest.java | 369 +++++++++++++++--- 13 files changed, 893 insertions(+), 107 deletions(-) diff --git a/app/src/main/java/app/drydock/DrydockApplication.java b/app/src/main/java/app/drydock/DrydockApplication.java index a7144ab9..c2bfd4d7 100644 --- a/app/src/main/java/app/drydock/DrydockApplication.java +++ b/app/src/main/java/app/drydock/DrydockApplication.java @@ -1455,6 +1455,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 @@ -1660,7 +1682,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/mcp/McpToolRouter.java b/app/src/main/java/app/drydock/mcp/McpToolRouter.java index 199f4453..fa31368d 100644 --- a/app/src/main/java/app/drydock/mcp/McpToolRouter.java +++ b/app/src/main/java/app/drydock/mcp/McpToolRouter.java @@ -35,6 +35,7 @@ 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; @@ -71,6 +72,28 @@ public final class McpToolRouter { 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.

+ */ + 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); } @@ -360,11 +383,22 @@ private JsonValue reviewScope(ManagedSessionId caller, JsonValue arguments) thro * 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 " diff --git a/app/src/main/java/app/drydock/review/OutOfDiffFanIn.java b/app/src/main/java/app/drydock/review/OutOfDiffFanIn.java index d7766905..df4d7753 100644 --- a/app/src/main/java/app/drydock/review/OutOfDiffFanIn.java +++ b/app/src/main/java/app/drydock/review/OutOfDiffFanIn.java @@ -43,15 +43,24 @@ * occurrence popover.

* *

A grep match is a lexical count, not a call count: it cannot tell a - * real reference from an unrelated identifier that happens to contain the - * same text, the same trade this codebase already makes for an - * ungrammared file (see {@link SymbolScan}). Attributing an occurrence to - * every changed declaration whose name it textually contains -- rather than - * resolving which one, if any, it actually refers to -- can occasionally - * over-attribute when one changed symbol's name is a substring of - * another's; a repository-wide semantic index would not have this - * imprecision, which is exactly the cost this class is built to avoid - * paying.

+ * 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 @@ -129,7 +138,15 @@ public static Result scan(Path worktree, ChangeGraph graph, Set changedF try { patterns = Files.createTempFile("drydock-fanin-", ".patterns"); Files.writeString(patterns, String.join("\n", symbols), StandardCharsets.UTF_8); - List command = List.of("git", "grep", "-z", "-n", "-F", "-f", + // -w is a PRE-FILTER, not the correctness mechanism: {@link + // #mentions} below is, and it subsumes this (a mutation dropping + // -w alone changes no result, which was checked rather than + // assumed). It earns its place by keeping git from streaming + // back -- and this class from allocating an Occurrence for -- + // every line that merely contains a changed name as a substring, + // 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 @@ -143,7 +160,7 @@ public static Result scan(Path worktree, ChangeGraph graph, Set changedF Map> bySymbol = new TreeMap<>(); for (String symbol : symbols) { List hits = occurrences.stream() - .filter(occurrence -> occurrence.text().contains(symbol)) + .filter(occurrence -> mentions(occurrence.text(), symbol)) .toList(); if (!hits.isEmpty()) { bySymbol.put(symbol, hits); @@ -170,6 +187,41 @@ public static Result scan(Path worktree, ChangeGraph graph, Set changedF } } + /** + * 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 diff --git a/app/src/main/java/app/drydock/ui/MainWorkspace.java b/app/src/main/java/app/drydock/ui/MainWorkspace.java index 77e96eb6..1dcfed06 100644 --- a/app/src/main/java/app/drydock/ui/MainWorkspace.java +++ b/app/src/main/java/app/drydock/ui/MainWorkspace.java @@ -1740,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) @@ -2090,10 +2101,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: "); @@ -2103,12 +2114,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 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 260cba75..82503b73 100644 --- a/app/src/main/java/app/drydock/ui/review/ReviewDiffColumn.java +++ b/app/src/main/java/app/drydock/ui/review/ReviewDiffColumn.java @@ -43,6 +43,7 @@ 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 @@ -1579,7 +1580,7 @@ private void showLens(String symbol, Node anchor) { * owns determinism (see {@code SessionReviewView.fanInOccurrences}).

*/ void showFanIn(String file, Map> bySymbol, - Node anchor, Runnable askTheAgent) { + Node anchor, BooleanSupplier askTheAgent) { if (bySymbol.isEmpty()) { return; } @@ -1606,13 +1607,14 @@ void showFanIn(String file, Map> bySymbo // 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. - Button ask = new Button("Ask the agent about these callers"); + // 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.setOnAction(e -> { - hideLens(); - askTheAgent.run(); - }); + 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 @@ -1623,6 +1625,22 @@ void showFanIn(String file, Map> bySymbo 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()) { @@ -1655,7 +1673,13 @@ void showFanIn(String file, Map> bySymbo lensPopup.getContent().add(scroll); var bounds = anchor.localToScreen(anchor.getBoundsInLocal()); if (bounds != null) { - lensPopup.show(anchor, bounds.getMinX(), bounds.getMaxY() + 4); + // 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); } } 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 5f7919aa..7d45baff 100644 --- a/app/src/main/java/app/drydock/ui/review/ReviewIntentRail.java +++ b/app/src/main/java/app/drydock/ui/review/ReviewIntentRail.java @@ -15,6 +15,7 @@ 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; @@ -24,10 +25,13 @@ 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.Map; import java.util.Optional; +import java.util.Set; import java.util.function.BiConsumer; import java.util.function.Consumer; import java.util.function.Function; @@ -57,6 +61,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)", @@ -548,7 +559,29 @@ private Node reasonNode(ReadingPath.Step step, Label reason) { } Button button = new Button(); button.getStyleClass().add("review-fanin-count"); - button.setGraphic(reason); + // 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 " @@ -718,6 +751,41 @@ private static String decisionStyleClass(Optional decisi .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. */ String diagCards() { StringBuilder sb = new StringBuilder(intents.size() + " intents · " @@ -757,16 +825,37 @@ List diagPathRowTexts() { /** Every {@link Label}'s text under {@code node}, depth-first. */ private static List labelTexts(Node node) { List texts = new ArrayList<>(); - collectLabelTexts(node, texts); + collectLabelTexts(node, texts, + Collections.newSetFromMap(new IdentityHashMap())); return texts; } - private static void collectLabelTexts(Node node, List into) { + /** + * 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()); - } else if (node instanceof Parent parent) { + } + if (node instanceof Labeled labeled) { + collectLabelTexts(labeled.getGraphic(), into, seen); + } + if (node instanceof Parent parent) { for (Node child : parent.getChildrenUnmodifiable()) { - collectLabelTexts(child, into); + collectLabelTexts(child, into, seen); } } } 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 6d7cf4b6..bb031bd6 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -248,8 +248,16 @@ void setVerdict(ReviewScope scope, ReviewIntent intent, List hunkDigests /** 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} @@ -546,6 +554,16 @@ private record PathCacheEntry(String scopeId, UnifiedDiff diff, ChangeGraph grap /** 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 snap the cursor back to it -- see {@link @@ -1111,6 +1129,19 @@ public void refreshReviewState() { 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); } @@ -1966,6 +1997,32 @@ private void nextUnsettledPathStep() { } } + /** + * 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(), @@ -1995,12 +2052,12 @@ private static Optional digestOfPathStep(UnifiedDiff diff, ReadingPath.S * 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 void askAboutFanIn(ReadingPath.Step step) { + 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; + return false; } int total = bySymbol.values().stream().mapToInt(List::size).sum(); String question = "This change alters " + String.join(", ", bySymbol.keySet()) @@ -2020,9 +2077,13 @@ private void askAboutFanIn(ReadingPath.Step step) { .map(ReviewIntent::id); ReviewAnnotation stamped = asked.withIntentId(intentId); host.addComment(scope.get(), stamped); - host.askAgentToFix(scope.get(), pathStepAsIntent(step), List.of(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; } /** @@ -2112,6 +2173,21 @@ int selectedPathStepForTest() { * {@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; diff --git a/app/src/test/java/app/drydock/mcp/McpRouterFixture.java b/app/src/test/java/app/drydock/mcp/McpRouterFixture.java index 7b3487ff..497343ad 100644 --- a/app/src/test/java/app/drydock/mcp/McpRouterFixture.java +++ b/app/src/test/java/app/drydock/mcp/McpRouterFixture.java @@ -56,10 +56,20 @@ void setUpFixture() { }); 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(Path.of("/wt/feat")), "master", "feat", + Path.of("/repos/drydock"), Optional.of(worktree), "master", "feat", Optional.empty(), Optional.empty(), Optional.empty())); - context.reviewDiff = parseableDiff(); } String scopeId() { diff --git a/app/src/test/java/app/drydock/mcp/McpToolRouterSectionsTest.java b/app/src/test/java/app/drydock/mcp/McpToolRouterSectionsTest.java index 595d7305..1cd4f749 100644 --- a/app/src/test/java/app/drydock/mcp/McpToolRouterSectionsTest.java +++ b/app/src/test/java/app/drydock/mcp/McpToolRouterSectionsTest.java @@ -6,7 +6,10 @@ 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; @@ -129,8 +132,120 @@ void aSectionsBuildFailureDegradesGracefully() { 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: diff --git a/app/src/test/java/app/drydock/review/OutOfDiffFanInTest.java b/app/src/test/java/app/drydock/review/OutOfDiffFanInTest.java index f0dee0a5..78de2e85 100644 --- a/app/src/test/java/app/drydock/review/OutOfDiffFanInTest.java +++ b/app/src/test/java/app/drydock/review/OutOfDiffFanInTest.java @@ -183,6 +183,80 @@ void aSymbolMatchingNowhereIsAnEmptyAnswerNotUnavailable(@TempDir Path dir) 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"); diff --git a/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java b/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java index 7720bdae..d7018013 100644 --- a/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java +++ b/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java @@ -552,7 +552,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 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 cdd76d79..f13bb02d 100644 --- a/app/src/test/java/app/drydock/ui/review/FakeReviewHost.java +++ b/app/src/test/java/app/drydock/ui/review/FakeReviewHost.java @@ -207,12 +207,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/ReviewFanInPopoverTest.java b/app/src/test/java/app/drydock/ui/review/ReviewFanInPopoverTest.java index 425e7ba9..8e0beff4 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewFanInPopoverTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewFanInPopoverTest.java @@ -37,7 +37,7 @@ /** * The out-of-diff fan-in count, as an affordance rather than a statistic - * (spec §7.4): "called from 3 places outside the change" opens the same + * (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. @@ -47,26 +47,46 @@ * 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. Reverting the wiring to the old placeholder fails - * {@link #aScanThatRanAndFoundNothingIsNotAnUnavailableScan} and - * {@link #clickingTheFanInCountListsTheCallersWithFileAndLine} at once.

+ * test builds, and the counts come from the {@code git grep} the real board + * spawns.

* - *

Absent is not zero. The three scope variants below are - * the whole point of the class: a scan that found callers, a scan that ran - * and found none, and a scan 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.

+ *

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, and the only one the diff carries. */ - private static final String CHANGED_FILE = "src/Guards.java"; + /** 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"; + + /** + * {@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"); - /** What the change declares, and what the unchanged files below use. */ - private static final String SYMBOL = "JmpCtxScope"; + /** Declared by {@link #ALPHA}; referenced nowhere outside the change. */ + private static final String ALPHA_SYMBOL = "AlphaOnly"; - /** Declared by the change, referenced nowhere in the repository. */ + /** 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(); @@ -108,29 +128,80 @@ void tearDown() { 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); + } + // ---- the popover -------------------------------------------------------- @Test void clickingTheFanInCountListsTheCallersWithFileAndLine() { - showBoard(repo, SYMBOL); + 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/Other\\.java:\\d+")), + 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(CHANGED_FILE + ":")), + 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() { - showBoard(repo, SYMBOL); + showRichBoard(repo); awaitFanInCount(); clickOn(".review-fanin-count"); @@ -142,6 +213,10 @@ void thePopoverOffersUsagesAndAskTheAgent() { 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); } /** @@ -151,7 +226,7 @@ void thePopoverOffersUsagesAndAskTheAgent() { */ @Test void askingTheAgentPostsAQuestionPointedAtTheRightFile() { - showBoard(repo, SYMBOL); + showRichBoard(repo); awaitFanInCount(); clickOn(".review-fanin-count"); WaitForAsyncUtils.waitForFxEvents(); @@ -161,18 +236,43 @@ void askingTheAgentPostsAQuestionPointedAtTheRightFile() { 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(CHANGED_FILE), "the question must name the file: " + body); - assertTrue(body.contains(SYMBOL), "the question must name the symbol: " + body); - assertEquals(CHANGED_FILE, host.findings(scope).get(0).file()); + 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(), "asking closes the popover"); + 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() { - showBoard(repo, SYMBOL); + showRichBoard(repo); awaitFanInCount(); clickOn(".review-fanin-count"); WaitForAsyncUtils.waitForFxEvents(); @@ -193,7 +293,7 @@ void escapeClosesTheFanInPopover() { @Test void aRefusedExplorerJumpSaysSoInThePopover() { host.explorerAvailable = false; - showBoard(repo, SYMBOL); + showRichBoard(repo); awaitFanInCount(); clickOn(".review-fanin-count"); WaitForAsyncUtils.waitForFxEvents(); @@ -209,7 +309,7 @@ void aRefusedExplorerJumpSaysSoInThePopover() { @Test void anAcceptedExplorerJumpOpensTheOutsideFile() { host.explorerAvailable = true; - showBoard(repo, SYMBOL); + showRichBoard(repo); awaitFanInCount(); clickOn(".review-fanin-count"); WaitForAsyncUtils.waitForFxEvents(); @@ -232,7 +332,7 @@ void anAcceptedExplorerJumpOpensTheOutsideFile() { */ @Test void theFanInRowStaysCardSized() { - showBoard(repo, SYMBOL); + showRichBoard(repo); awaitFanInCount(); double height = lookup(".review-fanin-count").query().getScene().getRoot() @@ -245,6 +345,70 @@ void theFanInRowStaysCardSized() { "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 ------------------------------------------------- /** @@ -259,7 +423,7 @@ void theFanInRowStaysCardSized() { */ @Test void aScanThatRanAndFoundNothingIsNotAnUnavailableScan() { - showBoard(repo, LONELY_SYMBOL); + showLonelyBoard(repo); await("the scan to report an empty-but-available answer", () -> railTexts().stream().noneMatch(text -> text.contains("outside callers unknown"))); @@ -272,7 +436,7 @@ void aScanThatRanAndFoundNothingIsNotAnUnavailableScan() { @Test void anUnavailableScanShowsNoCountRatherThanZero() { - showBoard(notARepo, SYMBOL); + showLonelyBoard(notARepo); await("the scan to fail against a directory git cannot grep", () -> railTexts().stream().anyMatch(text -> text.contains("outside callers unknown"))); @@ -295,7 +459,7 @@ void anUnavailableScanShowsNoCountRatherThanZero() { */ @Test void theScanNeverRunsOnTheFxThread() { - showBoard(repo, SYMBOL); + showRichBoard(repo); awaitFanInCount(); assertEquals("drydock-section-graph", view.diagFanInScanThread(), @@ -304,54 +468,96 @@ void theScanNeverRunsOnTheFxThread() { /** * 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 every time - * it is opened. + * §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 thePopoverListsTheSameCallersInTheSameOrderEveryTime() { - showBoard(repo, SYMBOL); + void thePopoverListsEverySymbolInTheGraphsSortedOrder() { + showRichBoard(repo); awaitFanInCount(); clickOn(".review-fanin-count"); WaitForAsyncUtils.waitForFxEvents(); - List first = whereRows(); + List first = symbolRows(); + List firstRows = whereRows(); interact(view::unwindOne); clickOn(".review-fanin-count"); WaitForAsyncUtils.waitForFxEvents(); - List second = whereRows(); - assertEquals(first, second); - assertFalse(first.isEmpty(), "there is nothing to compare if nothing rendered"); + 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 --------------------------------------------------------------- /** - * Shows a board whose scope is checked out at {@code worktree} and whose - * one changed file declares {@code declared}, then enters PATH mode -- - * where the reading path's reasons, and so the fan-in count, live. + * 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, String declared) { + private void showBoard(Path worktree, UnifiedDiff diff) { scope = registry.mint(ReviewScopeRegistry.spec(ReviewScope.Kind.WORKING_TREE, worktree, Optional.of(worktree), "main", "HEAD", Optional.empty(), Optional.empty())); - UnifiedDiff diff = new UnifiedDiff(List.of(new UnifiedDiff.FileDiff( - CHANGED_FILE, "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), "class " + declared + " { }"))))))); host.diff = diff; interact(() -> view.showScopes(new SessionReviewScopes.Scopes(scope, Optional.empty()), SessionReviewScopes.Choice.LOCAL)); - interact(() -> view.diagShowDiff(scope, diff)); - WaitForAsyncUtils.waitForFxEvents(); + // 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(); } @@ -395,7 +601,18 @@ private List popoverTexts() { List texts = new ArrayList<>(); interact(() -> openPopups().forEach(popup -> { if (popup.getScene() != null) { - collectText(popup.getScene().getRoot(), texts); + 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; @@ -403,7 +620,13 @@ private List popoverTexts() { /** Just the {@code file:line} rows, in rendered order. */ private List whereRows() { - return popoverTexts().stream().filter(text -> text.matches("[^\\s]+:\\d+")).toList(); + 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() { @@ -413,14 +636,21 @@ private java.util.stream.Stream openPopups() { .filter(Window::isShowing); } - private static void collectText(Node node, List into) { + /** 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()) { + && !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()) { - collectText(child, into); + if (!(node instanceof Labeled labeled) || child != labeled.getGraphic()) { + collectText(child, into, styleClass); + } } } } @@ -428,20 +658,31 @@ private static void collectText(Node node, List into) { // ---- a real repository --------------------------------------------------- /** - * A committed repository where {@link #SYMBOL} is declared in the changed - * file and 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. + * 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("src/Guards.java"), "class " + SYMBOL + " { }\n"); - Files.writeString(repo.resolve("src/Other.java"), - "class Other {\n void a() { new " + SYMBOL + "(); }\n}\n"); + 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 b() { new " + SYMBOL + "(); }\n}\n"); + "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"); From 3105001895874304ef249f40d6d4b07e6faa4d56 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sun, 23 Aug 2026 01:45:45 +0200 Subject: [PATCH 089/113] The bar's "Ask the agent to fix it" can no longer do nothing quietly The same defect round 1 fixed on the fan-in popover, one surface over: askAgentToFix was void on the bar's Host and the boolean under it was discarded, so with no session bound -- or no open finding to hand over -- the button did nothing at all and looked exactly as though it had worked. The destination returns the real answer through both of its branches now, the button acts on it, and the button holds :refused while the message is up. Where the refusal goes was measured, not chosen. Beside its own button is where it belongs, and the fit test refused that: at the code column's floor the action row has about 25px of slack once its four actions have taken their widths, and the label was laid out at 25 of the 319px it asked for -- a refusal elided to a sliver is the same defect as the silence it replaces. The footer, one row down, already houses the submit refusal; it did not have room either until the standing shortcuts hint gave way to it, the trade fitActionRow already makes in the row above, and until the message got short enough to fit with the sentence moved into its tooltip. It names both causes because a boolean cannot tell them apart, and naming the wrong one would be worse than naming the pair. assertNothingTruncated now measures refusal labels the way it measures buttons. It looked at .button and the wrapping stale banner only -- and a plain refusal label does not wrap, it elides, which was invisible to both. That gap is why the first placement's defect was findable at all. Co-Authored-By: Claude Opus 5 (1M context) --- .../drydock/ui/review/ReviewVerdictBar.java | 112 +++++++++++++++++- .../drydock/ui/review/SessionReviewView.java | 28 +++-- app/src/main/resources/app/drydock/ui/app.css | 9 ++ .../review/ReviewFindingsAndVerdictsTest.java | 68 +++++++++++ .../ui/review/ReviewVerdictBarFitTest.java | 47 +++++++- .../ReviewVerdictBarNavigationTest.java | 2 +- 6 files changed, 249 insertions(+), 17 deletions(-) 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 e6c3e1c5..11f7f630 100644 --- a/app/src/main/java/app/drydock/ui/review/ReviewVerdictBar.java +++ b/app/src/main/java/app/drydock/ui/review/ReviewVerdictBar.java @@ -44,8 +44,18 @@ interface Host { void requestChanges(ReviewIntent intent, SessionReviewView.SettleUnit unit); - /** "Ask the agent to fix it" -- hands the intent's findings to the bound session. */ - void askAgentToFix(ReviewIntent intent); + /** + * "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); @@ -92,6 +102,20 @@ record StaleInfo(String oldBase, String newBase) { private final Button undoButton = new Button("change"); private final Label settledLabel = new Label(); 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"); @@ -195,7 +219,27 @@ record StaleInfo(String oldBase, String newBase) { 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, or nowhere to send it", + "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."); + })); + // 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)")); @@ -247,8 +291,8 @@ record StaleInfo(String oldBase, String newBase) { Region footerSpacer = new Region(); HBox.setHgrow(footerSpacer, Priority.ALWAYS); - HBox footer = new HBox(10, progressLabel, progressBar, hintLabel, submitRefusalLabel, - footerSpacer, submitButton); + HBox footer = new HBox(10, progressLabel, progressBar, hintLabel, askRefusalLabel, + submitRefusalLabel, footerSpacer, submitButton); footer.setAlignment(Pos.CENTER_LEFT); footer.getStyleClass().add("review-verdict-footer"); @@ -292,6 +336,9 @@ void update(ReviewIntent currentIntent, Optional current // 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(); } @@ -368,6 +415,7 @@ void showSubmitRefused(String reason) { submitRefusalLabel.setVisible(true); submitRefusalLabel.setManaged(true); submitButton.pseudoClassStateChanged(javafx.css.PseudoClass.getPseudoClass("refused"), true); + fitFooter(); } /** The short form a human recognises a commit by; the sha itself if it is already short. */ @@ -375,10 +423,64 @@ 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); + 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, 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.

+ */ + private void fitFooter() { + boolean refusing = askRefusalLabel.isManaged() || submitRefusalLabel.isManaged(); + hintLabel.setVisible(!refusing); + hintLabel.setManaged(!refusing); + } + private void clearSubmitRefused() { submitRefusalLabel.setVisible(false); submitRefusalLabel.setManaged(false); submitButton.pseudoClassStateChanged(javafx.css.PseudoClass.getPseudoClass("refused"), false); + fitFooter(); } private void render() { 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 bb031bd6..a2966acc 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -2299,22 +2299,30 @@ public void requestChanges(ReviewIntent intent, SettleUnit unit) { } @Override - public void askAgentToFix(ReviewIntent intent) { + 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) { - currentPathStep().ifPresent(step -> selectedScope().ifPresent(scope -> - host.askAgentToFix(scope, pathStepAsIntent(step), - openFindingsForPathStep(scope, step)))); - return; + return currentPathStep().flatMap(step -> selectedScope().map(scope -> + host.askAgentToFix(scope, pathStepAsIntent(step), + openFindingsForPathStep(scope, step)))) + .orElse(false); } - selectedScope().ifPresent(scope -> host.askAgentToFix(scope, intent, - host.findings(scope).stream() - .filter(finding -> !finding.resolved()) - .filter(SessionReviewView.this::belongsToCurrentIntent) - .toList())); + return selectedScope().map(scope -> host.askAgentToFix(scope, intent, + host.findings(scope).stream() + .filter(finding -> !finding.resolved()) + .filter(SessionReviewView.this::belongsToCurrentIntent) + .toList())) + .orElse(false); } @Override diff --git a/app/src/main/resources/app/drydock/ui/app.css b/app/src/main/resources/app/drydock/ui/app.css index 391bf460..6dd7e50c 100644 --- a/app/src/main/resources/app/drydock/ui/app.css +++ b/app/src/main/resources/app/drydock/ui/app.css @@ -3136,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; 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 a23811b9..bc935772 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewFindingsAndVerdictsTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewFindingsAndVerdictsTest.java @@ -562,6 +562,74 @@ 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("⚠ nothing to send, or nowhere to send it", 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"); + } + + 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() 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 4979e22b..fafe0fbf 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewVerdictBarFitTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewVerdictBarFitTest.java @@ -5,6 +5,7 @@ import javafx.scene.Scene; import javafx.scene.control.Button; +import javafx.scene.Node; import javafx.scene.control.Label; import javafx.stage.Stage; import org.junit.jupiter.api.AfterEach; @@ -39,7 +40,7 @@ public void start(Stage stage) { bar = new ReviewVerdictBar(new ReviewVerdictBar.Host() { @Override public void approve(ReviewIntent intent, SessionReviewView.SettleUnit unit) { } @Override public void requestChanges(ReviewIntent intent, SessionReviewView.SettleUnit unit) { } - @Override public void askAgentToFix(ReviewIntent intent) { } + @Override public boolean askAgentToFix(ReviewIntent intent) { return askSucceeds; } @Override public void undo(ReviewIntent intent) { } @Override public void confirmStillGood(ReviewIntent intent) { } @Override public void nextUnsettled() { } @@ -62,8 +63,12 @@ public void start(Stage stage) { 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(); } @@ -174,6 +179,32 @@ void theApproveButtonNamesTheUnitAndFitsForEveryUnitAtTheFloor() { } } + /** + * 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(() -> 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")) + .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(); + } + private static String unitWord(SessionReviewView.SettleUnit unit) { return switch (unit) { case HUNK -> "next unread hunk"; @@ -280,6 +311,20 @@ private void assertNothingTruncated() { + 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)); + } + })); assertTrue(squeezed.isEmpty(), "at " + (int) RailLayout.CODE_MIN_WIDTH + "px these controls were truncated or mis-wrapped: " + squeezed); } 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 07295f11..cb9178a3 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewVerdictBarNavigationTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewVerdictBarNavigationTest.java @@ -35,7 +35,7 @@ public void start(Stage stage) { @Override public void requestChanges(ReviewIntent intent, SessionReviewView.SettleUnit unit) { calls.add("changes"); } - @Override public void askAgentToFix(ReviewIntent intent) { calls.add("ask"); } + @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"); } From b47d5d168be8d497cb08970e06e4bf8b74567bb0 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sun, 23 Aug 2026 02:50:31 +0200 Subject: [PATCH 090/113] p means start at the beginning, even when the path moved while you were away The round-1 re-anchor introduced the defect it was written to prevent, one gesture over. refreshReviewState writes the remembered step list only inside its pathMode branch, so a path that changed while the reader was OUT of PATH mode left that memory stale -- and pressing p then set the cursor to 0 only for the very next refresh to drag it back onto wherever the remembered entry point had gone, leaving them on row 3 with row 1 labelled START HERE. Every deliberate reset now clears the memory through one helper. The rule between the two, written down rather than inferred, because they will keep fighting otherwise: re-anchor when the ground moves under a reader standing still; reset when the reader asks to start again. The cases are told apart by who moved, not by what changed. Two footer refusals could be up at once -- submit refuses, the reader then asks the agent on that same intent, and neither path calls update() -- which left them and Submit sharing one row three ways and the primary action reading "Sub...". They are mutually exclusive now, in both directions, which took two tests to pin because they are two lines. Three measured truncations, all of them findable only because of the elision check the last round added. The submit refusal was already too long for its own footer at the code column's floor. The blocking refusal never fit its action row at all -- 25px of slack for a 146px sentence -- so it shortens to its glyph before it takes the last of the intent title, and keeps the sentence on hover. And fitFooter never consulted the width it had, so a 1400px bar hid "press ? for shortcuts" with hundreds of pixels to spare; no test could see that, because both hints carry the same style class and the one assertion about "the hint" matched the other one's text. Measuring the footer taught me something worth keeping: summing prefWidth under-counted it by a hundred pixels, because the progress bar's 120px floor is a CSS min-width and its preferred width is the fill's seventeen. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/app/drydock/mcp/McpToolRouter.java | 7 + .../app/drydock/review/OutOfDiffFanIn.java | 23 +- .../drydock/ui/review/ReviewVerdictBar.java | 139 ++++++++++-- .../drydock/ui/review/SessionReviewView.java | 38 +++- .../ui/review/ReviewFanInPopoverTest.java | 72 +++++++ .../review/ReviewFindingsAndVerdictsTest.java | 49 +++++ .../ui/review/ReviewVerdictBarFitTest.java | 197 +++++++++++++++++- 7 files changed, 493 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/app/drydock/mcp/McpToolRouter.java b/app/src/main/java/app/drydock/mcp/McpToolRouter.java index fa31368d..df7cc7a7 100644 --- a/app/src/main/java/app/drydock/mcp/McpToolRouter.java +++ b/app/src/main/java/app/drydock/mcp/McpToolRouter.java @@ -87,6 +87,13 @@ public final class McpToolRouter { * 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<>(); diff --git a/app/src/main/java/app/drydock/review/OutOfDiffFanIn.java b/app/src/main/java/app/drydock/review/OutOfDiffFanIn.java index df4d7753..f68583f6 100644 --- a/app/src/main/java/app/drydock/review/OutOfDiffFanIn.java +++ b/app/src/main/java/app/drydock/review/OutOfDiffFanIn.java @@ -138,14 +138,21 @@ public static Result scan(Path worktree, ChangeGraph graph, Set changedF 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: {@link - // #mentions} below is, and it subsumes this (a mutation dropping - // -w alone changes no result, which was checked rather than - // assumed). It earns its place by keeping git from streaming - // back -- and this class from allocating an Occurrence for -- - // every line that merely contains a changed name as a substring, - // which for a short declaration like `id` is most of a - // repository. Do not read it as the reason the count is right. + // -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); 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 11f7f630..03451f5f 100644 --- a/app/src/main/java/app/drydock/ui/review/ReviewVerdictBar.java +++ b/app/src/main/java/app/drydock/ui/review/ReviewVerdictBar.java @@ -101,6 +101,13 @@ record StaleInfo(String oldBase, String newBase) { 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"; + private final Label refusalLabel = new Label(); /** * Why an "Ask the agent to fix it" click handed nothing over -- a THIRD @@ -139,6 +146,9 @@ record StaleInfo(String oldBase, String newBase) { 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; /** @@ -263,6 +273,14 @@ record StaleInfo(String oldBase, String newBase) { 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); @@ -277,7 +295,13 @@ record StaleInfo(String oldBase, String newBase) { 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 @@ -289,9 +313,8 @@ record StaleInfo(String oldBase, String newBase) { 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, askRefusalLabel, + footer.getChildren().setAll(progressLabel, progressBar, hintLabel, askRefusalLabel, submitRefusalLabel, footerSpacer, submitButton); footer.setAlignment(Pos.CENTER_LEFT); footer.getStyleClass().add("review-verdict-footer"); @@ -411,10 +434,29 @@ private static String unitWord(SessionReviewView.SettleUnit unit) { * 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(); } @@ -448,6 +490,8 @@ private void showAskRefused(String reason, String detail) { askRefusalLabel.setManaged(true); askAgentButton.pseudoClassStateChanged( javafx.css.PseudoClass.getPseudoClass("refused"), true); + // See showSubmitRefused: one refusal in this footer at a time. + clearSubmitRefused(); fitFooter(); } @@ -461,7 +505,8 @@ private void clearAskRefused() { /** * The footer's own version of {@link #fitActionRow}'s trade: while a - * refusal is showing, the standing hint gives up its room to it. + * 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 @@ -469,11 +514,41 @@ private void clearAskRefused() { * 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(); - hintLabel.setVisible(!refusing); - hintLabel.setManaged(!refusing); + 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() { @@ -539,7 +614,7 @@ private void render() { requestChangesButton.setTooltip( new Tooltip("Request changes on this " + unit + " (r)")); } - refusalLabel.setText("⚠ a blocking finding is still open"); + refusalLabel.setText(BLOCKING_REFUSAL); refusalLabel.setVisible(blocked); refusalLabel.setManaged(blocked); approveButton.pseudoClassStateChanged( @@ -586,6 +661,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(); } @@ -593,6 +674,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; @@ -601,15 +713,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/SessionReviewView.java b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java index a2966acc..c0ebb50f 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -957,6 +957,7 @@ private void renderSelectedScope() { 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 @@ -1898,6 +1899,7 @@ private void togglePathMode() { pathMode = !pathMode; if (pathMode) { pathIndex = 0; + forgetPathCursorHistory(); selectedScope().ifPresent(scope -> loadedDiff().ifPresent(diff -> requestGraph(scope.id(), diff))); } @@ -1997,6 +1999,33 @@ private void nextUnsettledPathStep() { } } + /** + * 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. * @@ -2441,8 +2470,13 @@ private void submitReview() { intentIndex = intents().indexOf(counted.get(i)); refreshReviewState(); revealCurrentIntent(); - verdictBar.showSubmitRefused( - "an intent still needs a verdict (approve or request changes); jumped to it"); + // Short, with the rest on hover: at the code column's floor + // the footer has about 290px for a refusal, and the sentence + // this used to be asked for 338 -- so it was elided to + // "an intent still needs a verdict …" mid-word, which is + // the truncation ReviewVerdictBarFitTest exists to stop. + verdictBar.showSubmitRefused("a verdict is missing; jumped to it", + "Approve it, or request changes on it, before submitting the review."); return; } // A stale verdict does not count toward "everything settled" diff --git a/app/src/test/java/app/drydock/ui/review/ReviewFanInPopoverTest.java b/app/src/test/java/app/drydock/ui/review/ReviewFanInPopoverTest.java index 8e0beff4..159e0055 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewFanInPopoverTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewFanInPopoverTest.java @@ -74,6 +74,15 @@ class ReviewFanInPopoverTest extends ApplicationTest { /** 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 @@ -123,6 +132,14 @@ public void start(Stage stage) { @AfterEach void tearDown() { + // theFanInReasonWrapsInsteadOfBeingCutToOneLine narrows the stage, + // and the stage outlives this class: left narrow, every later test + // in this JVM lays out against a window it never asked for. + interact(() -> { + view.getScene().getWindow().setWidth(1400); + view.getScene().getWindow().setHeight(900); + }); + WaitForAsyncUtils.waitForFxEvents(); interact(view::close); diffService.close(); host.store.close(); @@ -172,6 +189,51 @@ void aScanThatReordersThePathKeepsTheReaderOnTheHunkTheyWereReading() { + 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 @@ -526,6 +588,16 @@ private void showLonelyBoard(Path worktree) { * 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())); 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 bc935772..158ef85b 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewFindingsAndVerdictsTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewFindingsAndVerdictsTest.java @@ -81,6 +81,14 @@ public void start(Stage stage) { @AfterEach void tearDown() { + // The stage outlives this class, so a test that narrowed it hands + // every later one a window its own scene was never laid out for -- + // which reads as "the click missed", not as "the width leaked". + interact(() -> { + view.getScene().getWindow().setWidth(1400); + view.getScene().getWindow().setHeight(900); + }); + WaitForAsyncUtils.waitForFxEvents(); diffService.close(); host.store.close(); } @@ -611,6 +619,39 @@ void theAskRefusalIsClearedByTheNextBarUpdate() { assertEquals("", askRefusal(), "moving to another intent must clear it"); } + /** + * Round 3, item 3. The verdict-bar fit test measures a literal it holds + * itself, so it cannot see this string at all -- and this string is the + * one a reader gets. Driven through a real refused submit, at the code + * column's floor, so the two cannot drift: lengthen the production + * message and this fails, whatever the fit test's own copy says. + */ + @Test + void theRealSubmitRefusalFitsAtTheCodeColumnFloor() { + seed(); + // Narrowed here, restored in tearDown: TestFX's primary stage + // outlives the test AND the class, and a stage left at 560px makes + // every later test in this JVM click at coordinates its own scene + // never laid out. (It cost this file one failure before the restore + // went in.) + interact(() -> view.getScene().getWindow().setWidth(RailLayout.CODE_MIN_WIDTH)); + WaitForAsyncUtils.waitForFxEvents(); + + type(KeyCode.ENTER); + 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")); + 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"); + } + private void clickAskAgent() { interact(() -> lookup(".review-verdict-action").queryAll().stream() .map(Button.class::cast) @@ -660,6 +701,14 @@ private static UnifiedDiff fileWithALongUnchangedRun() { /** Shows the board on one scope and seeds the store with {@code findings}. */ private void seed(ReviewAnnotation... findings) { + // See tearDown: the stage is shared across classes, 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); 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 fafe0fbf..6911f71a 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewVerdictBarFitTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewVerdictBarFitTest.java @@ -8,6 +8,7 @@ import javafx.scene.Node; import javafx.scene.control.Label; import javafx.stage.Stage; +import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.testfx.framework.junit5.ApplicationTest; @@ -58,11 +59,18 @@ public void start(Stage stage) { // width still comes up as wide as whatever ran before it left it -- // under which every assertion here passes without measuring anything. this.stage = stage; + sharedStage = stage; atTheFloor(); } private Stage stage; + /** + * The SHARED primary stage, so the class can hand it back at a normal + * size -- see {@link #unpoisonTheSharedStage}. + */ + private static Stage sharedStage; + /** Whether the stub host's hand-off succeeds; false drives the refusal. */ private boolean askSucceeds = true; @@ -72,6 +80,28 @@ void restoreTheFloor() { atTheFloor(); } + /** + * Every test here deliberately leaves the stage at the code column's + * floor, and TestFX's primary stage outlives the CLASS -- so the next + * class in this JVM built its 1400x900 scene inside a 560x200 window, + * its diff column rendered no rows, and its clicks reported "no + * clickable gutter". Nothing in that failure names a width, which is why + * it reads as flakiness rather than as a leak. + * + *

Handing the stage back is this class's job, not the next class's: + * it is the one that took it.

+ */ + @AfterAll + static void unpoisonTheSharedStage() { + if (sharedStage == null) { + return; + } + WaitForAsyncUtils.waitForAsyncFx(5000, () -> { + sharedStage.setWidth(1400); + sharedStage.setHeight(900); + }); + } + private void atTheFloor() { interact(() -> { stage.setWidth(RailLayout.CODE_MIN_WIDTH); @@ -117,12 +147,12 @@ 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(hintShowing(), "a wide bar shows the hint again"); + assertTrue(navHintShowing(), "a wide bar shows the nav hint again"); } /** @@ -190,12 +220,7 @@ void theAskRefusalFitsAtTheCodeColumnFloor() { askSucceeds = false; show(intent(2, "drydock/review · 4 files"), Optional.empty()); - interact(() -> 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")) - .fire()); + interact(() -> askButton().fire()); WaitForAsyncUtils.waitForFxEvents(); interact(() -> bar.getScene().getRoot().layout()); WaitForAsyncUtils.waitForFxEvents(); @@ -205,6 +230,145 @@ void theAskRefusalFitsAtTheCodeColumnFloor() { 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(); + // AND the title still exists. "Nothing is truncated" is otherwise + // satisfiable by letting the refusal run to its full 146px and + // taking every one of them from the title: the title is allowed to + // YIELD (intentLabel.setMinWidth(0)) and at this floor it yields + // almost everything, but the row is not allowed to spend it down to + // nothing for a sentence whose glyph says as much. Measured: 14px + // with the short form, 0 with the long one. + double[] title = new double[1]; + interact(() -> title[0] = lookup(".review-verdict-intent").query().getBoundsInLocal().getWidth()); + assertTrue(title[0] > 0, "the intent title was squeezed out of existence; the refusal " + + "must shorten to its glyph before taking the last of it"); + } + + /** + * 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("an intent needs a verdict; jumped to it")); + 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(); + } + + /** + * 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("a verdict is missing; jumped to it")); + 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(); + + interact(() -> bar.showSubmitRefused("an intent needs a verdict; jumped to it")); + 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("an intent needs a verdict; jumped to it")); + 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"; @@ -227,7 +391,14 @@ private String approveButtonText() { // ---- 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() @@ -235,6 +406,14 @@ 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 decision) { interact(() -> { From 6a220c75eb42a2cb250b250b85cf8021323a46f8 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sun, 23 Aug 2026 04:05:56 +0200 Subject: [PATCH 091/113] A test class owns its own stage size, all thirty-six of them TestFX hands every class in a JVM the same primary stage, and a stage remembers an explicit size across classes. The tree got away with never setting one -- show() then sized itself to whatever Scene it had just been given -- except that ReviewVerdictBarFitTest has always set one, because the width is what it tests. So a size has been leaking across classes all along, and round 3 did not arm that leak: it moved it onto a value a neighbour could see. ReviewDiffColumnWidthTest asserts a 400-character line wraps, which holds at an inherited 560 and inverts at an inherited 1400, and round 3 handed the stage back at 1400. Nothing in that failure names a width. It surfaces as clicks landing on nothing, or an assertion quietly reading the other way, in whichever class happens to run next -- which is why it has twice been mistaken for flakiness. TestStages.show takes the size from the scene the class already declares, so there is no second copy of the number to drift, and its javadoc carries the rule rather than leaving it to be re-derived. The three hand-backs are gone: choosing a benign value for a leak makes every class depend on a number picked somewhere else. I first applied this to the eleven classes whose assertions read geometry and argued the rest could wait. The full suite refuted that before it could ship -- two narrow-scene classes failed on layout their tests never measure, ten tests between them -- because "asserts on geometry" is a cheap proxy for "depends on layout", and every clickOn depends on layout. So it is all thirty-six. Submit refusals are values now, not four literals, and the floor test loops them: three of the four were elided there and only the fourth was measured. A test that covers one instance of a defect class is how the others ship. INTENT_LABEL_MIN says in its own javadoc that it is a reservation and not a floor -- 96 budgeted, 14 measured, and that is the title yielding as designed. Co-Authored-By: Claude Opus 5 (1M context) --- .../drydock/ui/review/ReviewVerdictBar.java | 9 +++ .../drydock/ui/review/SessionReviewView.java | 51 +++++++++++---- .../app/drydock/ui/HandoffBannerTest.java | 3 +- .../java/app/drydock/ui/ModalLayerTest.java | 3 +- .../app/drydock/ui/NewWorktreeModalTest.java | 3 +- .../ui/OpenSessionTabReviewSubTabTest.java | 3 +- ...positorySidebarPullRequestDedupFxTest.java | 3 +- .../app/drydock/ui/ReviewEntryPointsTest.java | 3 +- .../drydock/ui/SessionHeaderLayoutTest.java | 3 +- .../drydock/ui/SettingsModalSkimRowTest.java | 3 +- .../test/java/app/drydock/ui/TestStages.java | 46 +++++++++++++ .../ui/explorer/SearchRailViewTest.java | 4 +- .../ui/explorer/SessionExplorerViewTest.java | 4 +- .../app/drydock/ui/explorer/SkimViewTest.java | 4 +- ...ngFindingAcrossOverlappingIntentsTest.java | 4 +- .../ui/review/ReviewCommentComposerTest.java | 4 +- .../ReviewDiffColumnIntentFilterTest.java | 4 +- .../review/ReviewDiffColumnPublishTest.java | 4 +- .../ui/review/ReviewDiffColumnTest.java | 4 +- .../ReviewDiffColumnUntrackedToggleTest.java | 4 +- .../ui/review/ReviewDiffColumnWidthTest.java | 4 +- .../review/ReviewDiffGutterSelectionTest.java | 4 +- .../ui/review/ReviewFanInPopoverTest.java | 12 +--- .../review/ReviewFindingsAndVerdictsTest.java | 15 ++--- .../ui/review/ReviewHunkProgressTest.java | 4 +- .../ui/review/ReviewIntentFallbackTest.java | 4 +- .../ReviewIntentRailCardHeightTest.java | 4 +- .../ReviewIntentRailEmptyStateTest.java | 4 +- .../ReviewIntentScopeIsolationTest.java | 4 +- .../review/ReviewLandsOnFirstIntentTest.java | 4 +- .../ui/review/ReviewLinkFooterWiringTest.java | 4 +- .../drydock/ui/review/ReviewLinkRowTest.java | 4 +- .../ui/review/ReviewPathOrderTest.java | 4 +- .../ui/review/ReviewPathRowContrastTest.java | 4 +- .../ui/review/ReviewSubmitSheetTest.java | 4 +- .../ui/review/ReviewVerdictBarFitTest.java | 64 ++++++++++--------- .../ReviewVerdictBarNavigationTest.java | 4 +- .../drydock/ui/review/ReviewViewFixture.java | 4 +- .../ui/review/SectionRailSwapTest.java | 4 +- .../ui/review/SessionReviewViewCloseTest.java | 4 +- .../ui/review/SessionReviewViewTest.java | 4 +- 41 files changed, 198 insertions(+), 131 deletions(-) create mode 100644 app/src/test/java/app/drydock/ui/TestStages.java 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 03451f5f..2f616ff3 100644 --- a/app/src/main/java/app/drydock/ui/review/ReviewVerdictBar.java +++ b/app/src/main/java/app/drydock/ui/review/ReviewVerdictBar.java @@ -640,6 +640,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; 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 c0ebb50f..986778d2 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -2448,6 +2448,42 @@ public void nextIntent() { * diff must not leave IT stuck refusing for the rest of the session -- it * falls through and submits with nothing to post.

*/ + /** + * 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.

+ */ + record SubmitRefusal(String reason, String detail) { + } + + static final SubmitRefusal DIFF_FAILED = new SubmitRefusal( + "the diff failed to load; nothing to submit", + "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( + "approvals are against an older base", + "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); + private void submitReview() { Optional scope = selectedScope(); if (scope.isEmpty()) { @@ -2456,9 +2492,8 @@ 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; } } @@ -2470,13 +2505,7 @@ private void submitReview() { intentIndex = intents().indexOf(counted.get(i)); refreshReviewState(); revealCurrentIntent(); - // Short, with the rest on hover: at the code column's floor - // the footer has about 290px for a refusal, and the sentence - // this used to be asked for 338 -- so it was elided to - // "an intent still needs a verdict …" mid-word, which is - // the truncation ReviewVerdictBarFitTest exists to stop. - verdictBar.showSubmitRefused("a verdict is missing; jumped to it", - "Approve it, or request changes on it, before submitting the review."); + verdictBar.showSubmitRefused(NEEDS_VERDICT.reason(), NEEDS_VERDICT.detail()); return; } // A stale verdict does not count toward "everything settled" @@ -2487,7 +2516,7 @@ private void submitReview() { intentIndex = intents().indexOf(counted.get(i)); refreshReviewState(); revealCurrentIntent(); - verdictBar.showSubmitRefused("approvals were given against an older base"); + verdictBar.showSubmitRefused(STALE_BASE.reason(), STALE_BASE.detail()); return; } decisions.add(decision.get()); 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 d7018013..057e6930 100644 --- a/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java +++ b/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java @@ -59,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 diff --git a/app/src/test/java/app/drydock/ui/RepositorySidebarPullRequestDedupFxTest.java b/app/src/test/java/app/drydock/ui/RepositorySidebarPullRequestDedupFxTest.java index 03a2c90c..e6bf6efa 100644 --- a/app/src/test/java/app/drydock/ui/RepositorySidebarPullRequestDedupFxTest.java +++ b/app/src/test/java/app/drydock/ui/RepositorySidebarPullRequestDedupFxTest.java @@ -99,8 +99,7 @@ public void start(Stage stage) throws Exception { sidebar = new RepositorySidebar(repositoryManager, gitStatusService, worktreeService, repositoryPullRequests, sessionManager, agentRegistry, navigator, viewModel); - stage.setScene(new Scene(new StackPane(sidebar), 360, 640)); - stage.show(); + TestStages.show(stage, new Scene(new StackPane(sidebar), 360, 640)); repository = repositoryManager.addRepository(repoRoot) .get(20, TimeUnit.SECONDS); diff --git a/app/src/test/java/app/drydock/ui/ReviewEntryPointsTest.java b/app/src/test/java/app/drydock/ui/ReviewEntryPointsTest.java index 559d5b07..ac98efeb 100644 --- a/app/src/test/java/app/drydock/ui/ReviewEntryPointsTest.java +++ b/app/src/test/java/app/drydock/ui/ReviewEntryPointsTest.java @@ -104,8 +104,7 @@ public void start(Stage stage) throws Exception { // without a count the row has no badge to click. sidebar.setOpenFindingsAt(path -> 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/TestStages.java b/app/src/test/java/app/drydock/ui/TestStages.java new file mode 100644 index 00000000..97718e91 --- /dev/null +++ b/app/src/test/java/app/drydock/ui/TestStages.java @@ -0,0 +1,46 @@ +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); + stage.setWidth(scene.getWidth()); + stage.setHeight(scene.getHeight()); + stage.show(); + } +} 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 index d25f51f7..ded9c52e 100644 --- a/app/src/test/java/app/drydock/ui/review/BlockingFindingAcrossOverlappingIntentsTest.java +++ b/app/src/test/java/app/drydock/ui/review/BlockingFindingAcrossOverlappingIntentsTest.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.AnnotationStatus; @@ -73,8 +74,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/ReviewCommentComposerTest.java b/app/src/test/java/app/drydock/ui/review/ReviewCommentComposerTest.java index 6b91d8ab..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 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/ReviewFanInPopoverTest.java b/app/src/test/java/app/drydock/ui/review/ReviewFanInPopoverTest.java index 159e0055..1099e6f3 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewFanInPopoverTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewFanInPopoverTest.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.process.ProcessRunner; @@ -126,20 +127,11 @@ 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 void tearDown() { - // theFanInReasonWrapsInsteadOfBeingCutToOneLine narrows the stage, - // and the stage outlives this class: left narrow, every later test - // in this JVM lays out against a window it never asked for. - interact(() -> { - view.getScene().getWindow().setWidth(1400); - view.getScene().getWindow().setHeight(900); - }); - WaitForAsyncUtils.waitForFxEvents(); interact(view::close); diffService.close(); host.store.close(); 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 158ef85b..ab3ba835 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewFindingsAndVerdictsTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewFindingsAndVerdictsTest.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.AnnotationStatus; @@ -75,20 +76,11 @@ 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 void tearDown() { - // The stage outlives this class, so a test that narrowed it hands - // every later one a window its own scene was never laid out for -- - // which reads as "the click missed", not as "the width leaked". - interact(() -> { - view.getScene().getWindow().setWidth(1400); - view.getScene().getWindow().setHeight(900); - }); - WaitForAsyncUtils.waitForFxEvents(); diffService.close(); host.store.close(); } @@ -650,6 +642,9 @@ void theRealSubmitRefusalFitsAtTheCodeColumnFloor() { 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"); + // And it is the PRODUCTION string, not one this file keeps in step by + // hand -- which is what lets ReviewVerdictBarFitTest loop all four. + assertEquals("⚠ " + SessionReviewView.NEEDS_VERDICT.reason(), ((Label) label).getText()); } private void clickAskAgent() { diff --git a/app/src/test/java/app/drydock/ui/review/ReviewHunkProgressTest.java b/app/src/test/java/app/drydock/ui/review/ReviewHunkProgressTest.java index 1b830e1d..e1d81c2b 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewHunkProgressTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewHunkProgressTest.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.BaseMove; @@ -80,8 +81,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/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 a5b3087f..1ce61cd3 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewIntentRailCardHeightTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewIntentRailCardHeightTest.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; @@ -58,8 +59,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 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 index d6b0480f..13af0d6e 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewLinkFooterWiringTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewLinkFooterWiringTest.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; @@ -85,8 +86,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/ReviewLinkRowTest.java b/app/src/test/java/app/drydock/ui/review/ReviewLinkRowTest.java index f365e94f..3e0ee2e5 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewLinkRowTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewLinkRowTest.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.ReadingPath; @@ -63,8 +64,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/ReviewPathOrderTest.java b/app/src/test/java/app/drydock/ui/review/ReviewPathOrderTest.java index e42e9acc..308ee4fd 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewPathOrderTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewPathOrderTest.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; @@ -65,8 +66,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/ReviewPathRowContrastTest.java b/app/src/test/java/app/drydock/ui/review/ReviewPathRowContrastTest.java index 2a013f17..eba0e788 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewPathRowContrastTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewPathRowContrastTest.java @@ -1,5 +1,6 @@ package app.drydock.ui.review; +import app.drydock.ui.TestStages; import app.drydock.review.ReadingPath; import app.drydock.review.ReviewIntent; @@ -54,8 +55,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 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 6911f71a..ad086c54 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewVerdictBarFitTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewVerdictBarFitTest.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; @@ -8,7 +9,6 @@ import javafx.scene.Node; import javafx.scene.control.Label; import javafx.stage.Stage; -import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.testfx.framework.junit5.ApplicationTest; @@ -53,24 +53,16 @@ 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); // 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. this.stage = stage; - sharedStage = stage; atTheFloor(); } private Stage stage; - /** - * The SHARED primary stage, so the class can hand it back at a normal - * size -- see {@link #unpoisonTheSharedStage}. - */ - private static Stage sharedStage; - /** Whether the stub host's hand-off succeeds; false drives the refusal. */ private boolean askSucceeds = true; @@ -81,27 +73,16 @@ void restoreTheFloor() { } /** - * Every test here deliberately leaves the stage at the code column's - * floor, and TestFX's primary stage outlives the CLASS -- so the next - * class in this JVM built its 1400x900 scene inside a 560x200 window, - * its diff column rendered no rows, and its clicks reported "no - * clickable gutter". Nothing in that failure names a width, which is why - * it reads as flakiness rather than as a leak. - * - *

Handing the stage back is this class's job, not the next class's: - * it is the one that took it.

+ * 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. */ - @AfterAll - static void unpoisonTheSharedStage() { - if (sharedStage == null) { - return; - } - WaitForAsyncUtils.waitForAsyncFx(5000, () -> { - sharedStage.setWidth(1400); - sharedStage.setHeight(900); - }); - } - private void atTheFloor() { interact(() -> { stage.setWidth(RailLayout.CODE_MIN_WIDTH); @@ -292,6 +273,29 @@ void raisingOneFooterRefusalRetiresTheOther() { 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 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 cb9178a3..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; @@ -47,8 +48,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 diff --git a/app/src/test/java/app/drydock/ui/review/ReviewViewFixture.java b/app/src/test/java/app/drydock/ui/review/ReviewViewFixture.java index 15e2cf68..f448cdf0 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewViewFixture.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewViewFixture.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.HunkDigest; @@ -68,8 +69,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/SectionRailSwapTest.java b/app/src/test/java/app/drydock/ui/review/SectionRailSwapTest.java index dc937b41..40edc436 100644 --- a/app/src/test/java/app/drydock/ui/review/SectionRailSwapTest.java +++ b/app/src/test/java/app/drydock/ui/review/SectionRailSwapTest.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; @@ -55,8 +56,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/SessionReviewViewCloseTest.java b/app/src/test/java/app/drydock/ui/review/SessionReviewViewCloseTest.java index 37613ab7..e5ade0a9 100644 --- a/app/src/test/java/app/drydock/ui/review/SessionReviewViewCloseTest.java +++ b/app/src/test/java/app/drydock/ui/review/SessionReviewViewCloseTest.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; @@ -66,8 +67,7 @@ public void start(Stage stage) throws IOException { 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); interact(() -> view.showScopes(new SessionReviewScopes.Scopes(scope, Optional.empty()), SessionReviewScopes.Choice.LOCAL)); interact(() -> view.diagShowDiff(scope, host.diff)); 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 042f21ca..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 From e745ba53bfc92d3d69e709441356613a2437ae36 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sun, 23 Aug 2026 05:09:35 +0200 Subject: [PATCH 092/113] Measure the bar where it actually is, not where the window is The four-string floor loop was measured in a fixture 35px wider than the bar production gives: ReviewVerdictBarFitTest built the bar at the WINDOW's floor, 560, and the view's chrome takes 35 of those. So "four of four measured" bought six characters of room that do not exist, and two strings were living inside it. The fixture takes the real width now, and a test in the real view fails if production ever gives the bar less than the fixture assumes. Each refusal is also raised through the path that raises it, so nothing rests on substituting one message into another's layout -- which is how the shortening got measured against the wrong Submit label. Both harnesses turned out to be load-bearing: the stale-base string was caught by its own path, and the failed-diff string only by the loop, because that path's fixture happens to show a short "Submit (2 left)" and a twelve-hunk review would not. The ask refusal had the same 35px error one row up, and is a constant now, as the submit refusals already were. A test in ReviewHunkProgressTest was pinned to a phrase rather than the constant and went red when the string shortened -- the drift these constants exist to stop, one file over. TestStages copied the scene's dimensions unconditionally, so a scene built without any pinned the stage at 0x0: a class laying out at nothing and failing while naming no width, which is the exact signature the helper was written to remove. It sizes to content in that case, as the show() it replaced did. The blocking refusal's shortening took three attempts to pin, and the first two are worth recording: at the bar's real width the intent title is squeezed to nothing whether or not the sentence shortens, so the geometric assertion stopped discriminating, and an unshrinkable label does not elide -- it lays the row out past its own right edge, where nothing was looking. There is a check for that now, and the shortening is pinned by what a reader sees: the glyph at the floor, the sentence when there is room. Co-Authored-By: Claude Opus 5 (1M context) --- .../drydock/ui/review/ReviewVerdictBar.java | 18 ++- .../drydock/ui/review/SessionReviewView.java | 68 ++++----- .../test/java/app/drydock/ui/TestStages.java | 15 +- .../java/app/drydock/ui/TestStagesTest.java | 66 +++++++++ .../review/ReviewFindingsAndVerdictsTest.java | 124 ++++++++++++++--- .../ui/review/ReviewHunkProgressTest.java | 6 +- .../ui/review/ReviewVerdictBarFitTest.java | 129 ++++++++++++++---- 7 files changed, 344 insertions(+), 82 deletions(-) create mode 100644 app/src/test/java/app/drydock/ui/TestStagesTest.java 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 2f616ff3..3446c6ee 100644 --- a/app/src/main/java/app/drydock/ui/review/ReviewVerdictBar.java +++ b/app/src/main/java/app/drydock/ui/review/ReviewVerdictBar.java @@ -108,6 +108,20 @@ record StaleInfo(String oldBase, String newBase) { */ 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 @@ -240,9 +254,7 @@ record StaleInfo(String oldBase, String newBase) { // 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, or nowhere to send it", - "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."); + 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 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 986778d2..01902919 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -2418,36 +2418,6 @@ public void nextIntent() { } } - /** - * Submit (spec §4.6): with anything unsettled this jumps to the first - * such intent rather than posting a partial review; once everything is - * settled it posts ONE review. - * - *

Refuses while {@link ReviewDiffColumn#displayedDiff()} belongs to a - * different scope than the one selected -- the window between selecting - * a scope and its diff actually landing. {@code displayedScopeId} lags - * {@code setScope}: it is left pointing at whatever scope's diff last - * finished loading until the new one resolves, so a Submit pressed - * during that "Diffing…" window would otherwise build the {@code - * DiffIndex} from the OUTGOING scope's diff under the INCOMING scope's - * id. A comment whose real anchor is not in that stale index gets - * refused as "not in this diff" even though it is; worse, one that - * happens to share a key by coincidence could be admitted with an anchor - * GitHub rejects, and since the whole review posts as one atomic call, a - * single bad anchor 422s every other comment in it.

- * - *

Pressing it again once the diff lands succeeds normally only on the - * SUCCESS branch: {@link ReviewDiffColumn#reload} clears {@code - * displayedScopeId} on a FAILED diff too, and re-selecting the already- - * selected scope is a no-op ({@code setScope}), so nothing here ever - * retries it. A PR scope genuinely has nothing to post without a diff to - * anchor comments to, so it stays refused -- but visibly now, via {@link - * ReviewVerdictBar#showSubmitRefused}, rather than doing nothing with no - * explanation. A non-PR scope posts no comments either way ({@link - * Host#submit} takes it straight to the Finish hand-off), so a failed - * diff must not leave IT stuck refusing for the rest of the session -- it - * falls through and submits with nothing to post.

- */ /** * Why a Submit click did nothing, as a value rather than four literals * scattered through {@link #submitReview}. @@ -2458,13 +2428,15 @@ public void nextIntent() { * 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.

+ * 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; nothing to submit", + "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( @@ -2476,7 +2448,7 @@ record SubmitRefusal(String reason, String detail) { "Approve it, or request changes on it, before submitting the review."); static final SubmitRefusal STALE_BASE = new SubmitRefusal( - "approvals are against an older base", + "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."); @@ -2484,6 +2456,36 @@ record SubmitRefusal(String reason, String detail) { 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 + * settled it posts ONE review. + * + *

Refuses while {@link ReviewDiffColumn#displayedDiff()} belongs to a + * different scope than the one selected -- the window between selecting + * a scope and its diff actually landing. {@code displayedScopeId} lags + * {@code setScope}: it is left pointing at whatever scope's diff last + * finished loading until the new one resolves, so a Submit pressed + * during that "Diffing…" window would otherwise build the {@code + * DiffIndex} from the OUTGOING scope's diff under the INCOMING scope's + * id. A comment whose real anchor is not in that stale index gets + * refused as "not in this diff" even though it is; worse, one that + * happens to share a key by coincidence could be admitted with an anchor + * GitHub rejects, and since the whole review posts as one atomic call, a + * single bad anchor 422s every other comment in it.

+ * + *

Pressing it again once the diff lands succeeds normally only on the + * SUCCESS branch: {@link ReviewDiffColumn#reload} clears {@code + * displayedScopeId} on a FAILED diff too, and re-selecting the already- + * selected scope is a no-op ({@code setScope}), so nothing here ever + * retries it. A PR scope genuinely has nothing to post without a diff to + * anchor comments to, so it stays refused -- but visibly now, via {@link + * ReviewVerdictBar#showSubmitRefused}, rather than doing nothing with no + * explanation. A non-PR scope posts no comments either way ({@link + * Host#submit} takes it straight to the Finish hand-off), so a failed + * diff must not leave IT stuck refusing for the rest of the session -- it + * falls through and submits with nothing to post.

+ */ private void submitReview() { Optional scope = selectedScope(); if (scope.isEmpty()) { diff --git a/app/src/test/java/app/drydock/ui/TestStages.java b/app/src/test/java/app/drydock/ui/TestStages.java index 97718e91..6877255d 100644 --- a/app/src/test/java/app/drydock/ui/TestStages.java +++ b/app/src/test/java/app/drydock/ui/TestStages.java @@ -39,8 +39,19 @@ private TestStages() { */ public static void show(Stage stage, Scene scene) { stage.setScene(scene); - stage.setWidth(scene.getWidth()); - stage.setHeight(scene.getHeight()); + 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/review/ReviewFindingsAndVerdictsTest.java b/app/src/test/java/app/drydock/ui/review/ReviewFindingsAndVerdictsTest.java index ab3ba835..252587ff 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewFindingsAndVerdictsTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewFindingsAndVerdictsTest.java @@ -4,6 +4,7 @@ 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; @@ -37,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; @@ -578,7 +580,7 @@ void askingTheAgentWithNoBoundSessionSaysSoOnTheBar() { clickAskAgent(); assertTrue(host.handedOffPrompts.isEmpty(), "nothing can be sent with no session"); - assertEquals("⚠ nothing to send, or nowhere to send it", askRefusal(), + assertEquals("⚠ " + ReviewVerdictBar.NOTHING_TO_SEND, askRefusal(), "a click that handed nothing over must say so"); } @@ -612,24 +614,98 @@ void theAskRefusalIsClearedByTheNextBarUpdate() { } /** - * Round 3, item 3. The verdict-bar fit test measures a literal it holds - * itself, so it cannot see this string at all -- and this string is the - * one a reader gets. Driven through a real refused submit, at the code - * column's floor, so the two cannot drift: lengthen the production - * message and this fails, whatever the fit test's own copy says. + * 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 theRealSubmitRefusalFitsAtTheCodeColumnFloor() { + void theRealBarIsNoNarrowerThanTheFitFixtureAssumes() { seed(); - // Narrowed here, restored in tearDown: TestFX's primary stage - // outlives the test AND the class, and a stage left at 560px makes - // every later test in this JVM click at coordinates its own scene - // never laid out. (It cost this file one failure before the restore - // went in.) + 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(); + } - type(KeyCode.ENTER); + /** 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(); @@ -637,14 +713,24 @@ void theRealSubmitRefusalFitsAtTheCodeColumnFloor() { .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"); - // And it is the PRODUCTION string, not one this file keeps in step by - // hand -- which is what lets ReviewVerdictBarFitTest loop all four. - assertEquals("⚠ " + SessionReviewView.NEEDS_VERDICT.reason(), ((Label) label).getText()); + // 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() { @@ -696,9 +782,9 @@ private static UnifiedDiff fileWithALongUnchangedRun() { /** Shows the board on one scope and seeds the store with {@code findings}. */ private void seed(ReviewAnnotation... findings) { - // See tearDown: the stage is shared across classes, so start every - // board from a known width rather than from whatever the last one - // left. + // 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); diff --git a/app/src/test/java/app/drydock/ui/review/ReviewHunkProgressTest.java b/app/src/test/java/app/drydock/ui/review/ReviewHunkProgressTest.java index e1d81c2b..9c8c6f2c 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewHunkProgressTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewHunkProgressTest.java @@ -247,8 +247,12 @@ void submitRefusesWhileTheCurrentSectionIsStale() { 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("older base")), + .anyMatch(text -> text.contains(SessionReviewView.STALE_BASE.reason())), "the reader must be told why submit did nothing"); } 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 ad086c54..b6ff5b35 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewVerdictBarFitTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewVerdictBarFitTest.java @@ -7,7 +7,10 @@ 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; @@ -18,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; @@ -34,6 +38,17 @@ */ 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 @@ -49,14 +64,15 @@ public void start(Stage stage) { @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()); TestStages.show(stage, scene); - // 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. + // 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(); } @@ -85,7 +101,7 @@ void restoreTheFloor() { */ private void atTheFloor() { interact(() -> { - stage.setWidth(RailLayout.CODE_MIN_WIDTH); + stage.setWidth(BAR_WIDTH_AT_FLOOR); stage.setHeight(200); }); WaitForAsyncUtils.waitForFxEvents(); @@ -234,17 +250,51 @@ void theBlockingRefusalFitsAtTheCodeColumnFloor() { .anyMatch(Node::isVisible), "the blocking refusal must be showing, or this measures nothing"); assertNothingTruncated(); - // AND the title still exists. "Nothing is truncated" is otherwise - // satisfiable by letting the refusal run to its full 146px and - // taking every one of them from the title: the title is allowed to - // YIELD (intentLabel.setMinWidth(0)) and at this floor it yields - // almost everything, but the row is not allowed to spend it down to - // nothing for a sentence whose glyph says as much. Measured: 14px - // with the short form, 0 with the long one. - double[] title = new double[1]; - interact(() -> title[0] = lookup(".review-verdict-intent").query().getBoundsInLocal().getWidth()); - assertTrue(title[0] > 0, "the intent title was squeezed out of existence; the refusal " - + "must shorten to its glyph before taking the last of it"); + // 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]; } /** @@ -257,7 +307,8 @@ void theBlockingRefusalFitsAtTheCodeColumnFloor() { @Test void raisingOneFooterRefusalRetiresTheOther() { show(intent(2, "drydock/review · 4 files"), Optional.empty()); - interact(() -> bar.showSubmitRefused("an intent needs a verdict; jumped to it")); + interact(() -> bar.showSubmitRefused(SessionReviewView.NEEDS_VERDICT.reason(), + SessionReviewView.NEEDS_VERDICT.detail())); WaitForAsyncUtils.waitForFxEvents(); assertTrue(refusalShowing("review-verdict-submit-refusal")); @@ -312,7 +363,8 @@ void raisingTheSubmitRefusalRetiresTheAskRefusalToo() { WaitForAsyncUtils.waitForFxEvents(); assertTrue(refusalShowing("review-verdict-ask-refusal")); - interact(() -> bar.showSubmitRefused("a verdict is missing; jumped to it")); + interact(() -> bar.showSubmitRefused(SessionReviewView.NEEDS_VERDICT.reason(), + SessionReviewView.NEEDS_VERDICT.detail())); WaitForAsyncUtils.waitForFxEvents(); interact(() -> bar.getScene().getRoot().layout()); WaitForAsyncUtils.waitForFxEvents(); @@ -337,7 +389,8 @@ void aWideBarKeepsTheShortcutHintWhileRefusing() { interact(() -> bar.getScene().getWindow().setWidth(1400)); WaitForAsyncUtils.waitForFxEvents(); - interact(() -> bar.showSubmitRefused("an intent needs a verdict; jumped to it")); + interact(() -> bar.showSubmitRefused(SessionReviewView.NEEDS_VERDICT.reason(), + SessionReviewView.NEEDS_VERDICT.detail())); WaitForAsyncUtils.waitForFxEvents(); assertTrue(refusalShowing("review-verdict-submit-refusal"), "the refusal must be up"); @@ -349,7 +402,8 @@ void aWideBarKeepsTheShortcutHintWhileRefusing() { @Test void atTheFloorTheShortcutHintStillYieldsToARefusal() { show(intent(2, "drydock/review · 4 files"), Optional.empty()); - interact(() -> bar.showSubmitRefused("an intent needs a verdict; jumped to it")); + interact(() -> bar.showSubmitRefused(SessionReviewView.NEEDS_VERDICT.reason(), + SessionReviewView.NEEDS_VERDICT.detail())); WaitForAsyncUtils.waitForFxEvents(); interact(() -> bar.getScene().getRoot().layout()); WaitForAsyncUtils.waitForFxEvents(); @@ -456,14 +510,14 @@ private void assertNothingTruncated() { // 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(RailLayout.CODE_MIN_WIDTH); + barPrefHeight[0] = bar.prefHeight(BAR_WIDTH_AT_FLOOR); }); - assertTrue(width[0] <= RailLayout.CODE_MIN_WIDTH + 1, + 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) RailLayout.CODE_MIN_WIDTH + "px floor; a wrapped label collapsed to " + + (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<>(); @@ -508,10 +562,37 @@ private void assertNothingTruncated() { + Math.round(label.getWidth()) + " of " + Math.round(wanted)); } })); - assertTrue(squeezed.isEmpty(), "at " + (int) RailLayout.CODE_MIN_WIDTH + // 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) { return new ReviewIntent("auto:" + number, number, title, ReviewIntent.Kind.CHANGE, ReviewIntent.Risk.LOW, "", List.of(), Optional.empty(), false); From 428da1e19053988751290e70752f6ebce263a5fc Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sun, 23 Aug 2026 10:14:51 +0200 Subject: [PATCH 093/113] 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. The rejection happens at DECODE, in intentsFromJson, not where the order is built: Graphs.topologicalOrder does refuse an edge pointing outside its nodes, but it refuses with an IllegalArgumentException on whatever thread 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 it can act on. The graph's nodes are POSITIONS in the supplied array, 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. Positions cannot, and they make the tie-break the agent's own array order -- total by construction, and the right answer anyway: where reads says nothing, the order it listed them in is the only other thing it told us. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/app/drydock/mcp/McpToolRouter.java | 6 +- .../java/app/drydock/mcp/ReviewToolCodec.java | 37 +++- .../app/drydock/review/FallbackIntents.java | 4 +- .../app/drydock/review/IntentGrouping.java | 89 ++++++++- .../java/app/drydock/review/ReviewIntent.java | 29 ++- .../drydock/ui/review/SessionReviewView.java | 4 +- .../drydock/mcp/ReviewIntentReadsTest.java | 179 ++++++++++++++++++ 7 files changed, 335 insertions(+), 13 deletions(-) create mode 100644 app/src/test/java/app/drydock/mcp/ReviewIntentReadsTest.java diff --git a/app/src/main/java/app/drydock/mcp/McpToolRouter.java b/app/src/main/java/app/drydock/mcp/McpToolRouter.java index df7cc7a7..53da5fa0 100644 --- a/app/src/main/java/app/drydock/mcp/McpToolRouter.java +++ b/app/src/main/java/app/drydock/mcp/McpToolRouter.java @@ -161,8 +161,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 " diff --git a/app/src/main/java/app/drydock/mcp/ReviewToolCodec.java b/app/src/main/java/app/drydock/mcp/ReviewToolCodec.java index 4091301c..edbbe930 100644 --- a/app/src/main/java/app/drydock/mcp/ReviewToolCodec.java +++ b/app/src/main/java/app/drydock/mcp/ReviewToolCodec.java @@ -17,8 +17,10 @@ import java.time.Instant; import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; import java.util.Optional; +import java.util.Set; /** * Encodes and decodes the Review MCP payloads (schema §§1-4), keeping the @@ -272,11 +274,44 @@ 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(), + stringList(obj, "reads"))); } + checkReadsResolve(intents); return List.copyOf(intents); } + /** + * 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)) { diff --git a/app/src/main/java/app/drydock/review/FallbackIntents.java b/app/src/main/java/app/drydock/review/FallbackIntents.java index 97c49455..04f9e05c 100644 --- a/app/src/main/java/app/drydock/review/FallbackIntents.java +++ b/app/src/main/java/app/drydock/review/FallbackIntents.java @@ -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() { diff --git a/app/src/main/java/app/drydock/review/IntentGrouping.java b/app/src/main/java/app/drydock/review/IntentGrouping.java index 1b1c1a71..27a0ecc5 100644 --- a/app/src/main/java/app/drydock/review/IntentGrouping.java +++ b/app/src/main/java/app/drydock/review/IntentGrouping.java @@ -7,12 +7,16 @@ 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; @@ -68,20 +72,86 @@ public long version(String scopeId) { * 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<>(); + for (List unit : Graphs.topologicalOrder(nodes, readsOf::get, + 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) { @@ -115,10 +185,12 @@ public List intentsFor(String scopeId, UnifiedDiff 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, - * 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).

+ * #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 @@ -166,7 +238,10 @@ public List intentsFor(String scopeId, UnifiedDiff diff, for (Sections.Section section : ordered) { computed.add(new ReviewIntent(computedId(section), number, section.title(), kindOf(section, fallbackByHunk), riskOf(section, fallbackByHunk), - rationale(section), section.hunkIds(), Optional.empty(), false)); + // 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); diff --git a/app/src/main/java/app/drydock/review/ReviewIntent.java b/app/src/main/java/app/drydock/review/ReviewIntent.java index 6fb47598..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()); } /** 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 01902919..f40c4fd4 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -2056,7 +2056,9 @@ private int reanchorPathCursor(List steps) { 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(), - List.of(step.hunkId()), Optional.empty(), false); + // 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. */ 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..f1cfd777 --- /dev/null +++ b/app/src/test/java/app/drydock/mcp/ReviewIntentReadsTest.java @@ -0,0 +1,179 @@ +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 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()); + } + + 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); + } +} From 869f51cb67802809b5c953e427a6d9686b995341 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sun, 23 Aug 2026 10:44:31 +0200 Subject: [PATCH 094/113] A reads that is not an array of ids is broken, not absent stringList answers List.of() for any non-array and drops any non-string element. reads went through it, so the rejection that was supposed to be the whole decode-time guarantee could only inspect what survived a lenient parse -- and what survived from "reads":"the-guard", one dependency written without the brackets, was nothing. review_intents then returned {"intents":2} with no error and the rail rendered the exact REVERSE of the order the agent asserted. The agent could not discover it either, because reads is echoed on no outbound wire. Absent and broken looked the same, which is the rule Graphs keeps for an edge pointing outside its nodes and the reason the decode-time check exists at all. readsFromJson decodes reads on its own terms and refuses both shapes: a present-but-non-array value, and any element that is not a string. stringList itself is untouched -- its lenience is shared with hunkIds, where a dropped entry costs at worst one hunk's membership in a group a human can see, and widening this task to change that is a different decision. An explicit null stays ABSENT rather than 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. Also: a determinism test over twenty intents whose ids run opposite to their array order, set twice on the same grouping; getOrDefault where a method reference could have handed Graphs a null; and a note that the decode-time number is provisional, since set re-assigns 1..N over the reads order and discards it. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/app/drydock/mcp/ReviewToolCodec.java | 48 +++++++++- .../app/drydock/review/IntentGrouping.java | 7 +- .../drydock/mcp/ReviewIntentReadsTest.java | 96 +++++++++++++++++++ 3 files changed, 149 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/app/drydock/mcp/ReviewToolCodec.java b/app/src/main/java/app/drydock/mcp/ReviewToolCodec.java index edbbe930..17ae92b0 100644 --- a/app/src/main/java/app/drydock/mcp/ReviewToolCodec.java +++ b/app/src/main/java/app/drydock/mcp/ReviewToolCodec.java @@ -258,6 +258,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)) { @@ -275,12 +279,54 @@ static List intentsFromJson(JsonValue value) throws McpToolExcepti stringList(obj, "hunkIds"), collapseFromJson(obj), obj.get("autoApprove") instanceof JsonBoolean auto && auto.value(), - stringList(obj, "reads"))); + 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. diff --git a/app/src/main/java/app/drydock/review/IntentGrouping.java b/app/src/main/java/app/drydock/review/IntentGrouping.java index 27a0ecc5..091a12fc 100644 --- a/app/src/main/java/app/drydock/review/IntentGrouping.java +++ b/app/src/main/java/app/drydock/review/IntentGrouping.java @@ -143,7 +143,12 @@ private static List orderByReads(List intents) { readsOf.put(position, targets); } List ordered = new ArrayList<>(); - for (List unit : Graphs.topologicalOrder(nodes, readsOf::get, + // 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)); diff --git a/app/src/test/java/app/drydock/mcp/ReviewIntentReadsTest.java b/app/src/test/java/app/drydock/mcp/ReviewIntentReadsTest.java index f1cfd777..dc88baa7 100644 --- a/app/src/test/java/app/drydock/mcp/ReviewIntentReadsTest.java +++ b/app/src/test/java/app/drydock/mcp/ReviewIntentReadsTest.java @@ -6,7 +6,10 @@ 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; @@ -159,6 +162,99 @@ void readsMayNameAnIntentThatComesLaterInTheBatch() throws Exception { 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(); From 281cdfc7cde0638752d8012662ffa60504877d56 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sun, 23 Aug 2026 11:19:15 +0200 Subject: [PATCH 095/113] 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 VerdictMerge's asymmetry -- any CHANGES wins, APPROVED needs every hunk -- pointed at a different question, and it is structural here: nothing on this path writes to verdicts at all, so "unaffected" is indistinguishable from never having been asked. SectionStates.stalenessOf consults the recheck SECOND: after the base is known to have moved, and before the filter gets to dismiss the move. So the agent can turn a FRESH or an UNKNOWN into MOVED and nothing else. It cannot invent a move that did not happen either -- a verdict recorded against the current base returns at step one. The wire says hunkId and the store is keyed by hunkDigest, and those are different things: hunkId is positional, HunkDigest is content-addressed and excludes line numbers so a hunk that merely moved keeps its approval. ReviewToolCodec translates, walking the diff the way IntentHunks already does, and refuses -- naming the id -- an id the current diff has no hunk for. A silently skipped entry would be an agent's recheck the human believes happened and did not. The base PAIR is derived rather than taken from the wire: fromBase is the base the hunk's own verdict was recorded against, toBase the scope's base now. The key the tool writes is therefore the key the board reads with. An agent-supplied pair could name commits no verdict was ever judged against, leaving the recheck answering a question nobody asks. Two refusals fall out of that: a hunk with no verdict has no fromBase and nothing decided to undermine, and a scope whose base does not resolve to a commit has no base move to assess. why is agent-authored text that a human will read, so it goes through PromptSafety.checkInboundText like intent.title, finding.body and evidence.code already do. Schema 5: the assessments array is new. loadFromDisk reads each named array on its own terms, so a v4 file needs no migration -- pinned by a test that asserts on submitted, which is read after the assessments and is the first thing a throwing decode would lose. Co-Authored-By: Claude Opus 5 (1M context) --- .../app/drydock/mcp/McpSessionContext.java | 26 ++ .../java/app/drydock/mcp/McpToolRouter.java | 65 ++++ .../java/app/drydock/mcp/ReviewToolCodec.java | 91 ++++++ .../mcp/WorkspaceMcpSessionContext.java | 29 ++ .../app/drydock/review/AnnotationStore.java | 133 +++++++- .../app/drydock/review/RecheckAssessment.java | 53 ++++ .../java/app/drydock/ui/MainWorkspace.java | 6 + .../app/drydock/ui/review/SectionStates.java | 17 + .../drydock/ui/review/SessionReviewView.java | 19 ++ .../drydock/mcp/FakeMcpSessionContext.java | 21 ++ .../drydock/mcp/McpToolRouterReadTest.java | 7 +- .../drydock/mcp/McpToolRouterRecheckTest.java | 299 ++++++++++++++++++ .../drydock/review/RecheckAsymmetryTest.java | 229 ++++++++++++++ .../ui/OpenSessionTabReviewSubTabTest.java | 6 + .../app/drydock/ui/review/FakeReviewHost.java | 7 + .../drydock/ui/review/SectionStatesTest.java | 113 +++++++ 16 files changed, 1108 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/app/drydock/review/RecheckAssessment.java create mode 100644 app/src/test/java/app/drydock/mcp/McpToolRouterRecheckTest.java create mode 100644 app/src/test/java/app/drydock/review/RecheckAsymmetryTest.java diff --git a/app/src/main/java/app/drydock/mcp/McpSessionContext.java b/app/src/main/java/app/drydock/mcp/McpSessionContext.java index 8047f672..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; @@ -113,6 +114,31 @@ Optional mutateAnnotation(ReviewAnnotation.Key key, /** 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 53da5fa0..13aec691 100644 --- a/app/src/main/java/app/drydock/mcp/McpToolRouter.java +++ b/app/src/main/java/app/drydock/mcp/McpToolRouter.java @@ -11,6 +11,7 @@ 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; @@ -193,6 +194,18 @@ 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 approved hunks valid. " + + "affected=true marks them stale; affected=false is ADVICE and " + + "never clears a human's approval. 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.")), + "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 " @@ -257,6 +270,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); @@ -469,6 +483,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 diff --git a/app/src/main/java/app/drydock/mcp/ReviewToolCodec.java b/app/src/main/java/app/drydock/mcp/ReviewToolCodec.java index 17ae92b0..489f7ff2 100644 --- a/app/src/main/java/app/drydock/mcp/ReviewToolCodec.java +++ b/app/src/main/java/app/drydock/mcp/ReviewToolCodec.java @@ -3,9 +3,12 @@ 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; @@ -19,6 +22,7 @@ import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; @@ -371,6 +375,93 @@ 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.

+ * + *

Three 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; and a {@code why} that fails {@link + * PromptSafety}. 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"); + } + // affected is the only field with an effect, and its absence is + // read as false -- the direction that changes nothing. A missing + // boolean must not be able to invent staleness nobody asserted. + boolean affected = obj.get("affected") instanceof JsonBoolean flag && flag.value(); + String why = PromptSafety.checkInboundText(optionalString(obj, "why").orElse(""), + "assessment.why"); + assessments.add(new RecheckAssessment(scopeId, digest, verdict.baseCommit(), toBase, + affected, why, at)); + } + return List.copyOf(assessments); + } + + /** + * 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 c28ca7ba..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 @@ -345,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 1c1912b2..5ceb3302 100644 --- a/app/src/main/java/app/drydock/review/AnnotationStore.java +++ b/app/src/main/java/app/drydock/review/AnnotationStore.java @@ -70,12 +70,16 @@ 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; 4 re-keys verdicts from {@code intentId} onto a hunk content - * digest, carrying {@code base}/{@code head}. 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. + * 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 = 4; + private static final int SCHEMA_VERSION = 5; private static final SecureRandom RANDOM = new SecureRandom(); /** @@ -100,6 +104,16 @@ static String legacyScopeId(ManagedSessionId sessionId, DiffScope scope) { /** 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<>(); @@ -121,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) { @@ -186,6 +201,26 @@ 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(); + } + + /** 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); } @@ -294,6 +329,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(); @@ -327,6 +363,26 @@ private synchronized boolean clearVerdictInternal(String scopeId, String hunkDig return false; } + /** + * 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.

+ */ + public void putAssessment(RecheckAssessment assessment) { + putAssessmentInternal(assessment); + fireChanged(null); + } + + private synchronized void putAssessmentInternal(RecheckAssessment assessment) { + assessments.put(assessment.key(), assessment); + persistAsync(); + } + /** Marks a scope's review as submitted. */ public void markSubmitted(String scopeId) { if (markSubmittedInternal(scopeId)) { @@ -415,7 +471,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) { @@ -433,7 +490,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); @@ -460,6 +517,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); @@ -470,11 +534,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)); @@ -499,6 +564,20 @@ private static JsonValue toJson(List findings, 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)); @@ -767,6 +846,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/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/ui/MainWorkspace.java b/app/src/main/java/app/drydock/ui/MainWorkspace.java index 1dcfed06..94dae517 100644 --- a/app/src/main/java/app/drydock/ui/MainWorkspace.java +++ b/app/src/main/java/app/drydock/ui/MainWorkspace.java @@ -2050,6 +2050,12 @@ 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 public void setResolved(ReviewScope scope, ReviewAnnotation finding, boolean resolved) { annotationStore.mutate(finding.key(), current -> current.withStatus( diff --git a/app/src/main/java/app/drydock/ui/review/SectionStates.java b/app/src/main/java/app/drydock/ui/review/SectionStates.java index aad966e1..79f2837d 100644 --- a/app/src/main/java/app/drydock/ui/review/SectionStates.java +++ b/app/src/main/java/app/drydock/ui/review/SectionStates.java @@ -355,12 +355,29 @@ SectionState stateOf(Board board, ReviewIntent intent) { * {@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; 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 f40c4fd4..81466d40 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -210,6 +210,25 @@ void setVerdict(ReviewScope scope, ReviewIntent intent, List hunkDigests */ 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); + /** Resolve / Reopen one finding. */ void setResolved(ReviewScope scope, ReviewAnnotation finding, boolean resolved); diff --git a/app/src/test/java/app/drydock/mcp/FakeMcpSessionContext.java b/app/src/test/java/app/drydock/mcp/FakeMcpSessionContext.java index 00268227..44bc8ca5 100644 --- a/app/src/test/java/app/drydock/mcp/FakeMcpSessionContext.java +++ b/app/src/test/java/app/drydock/mcp/FakeMcpSessionContext.java @@ -168,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/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..a2be7aa5 --- /dev/null +++ b/app/src/test/java/app/drydock/mcp/McpToolRouterRecheckTest.java @@ -0,0 +1,299 @@ +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")); + } + + // ---- 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/review/RecheckAsymmetryTest.java b/app/src/test/java/app/drydock/review/RecheckAsymmetryTest.java new file mode 100644 index 00000000..7d6b5035 --- /dev/null +++ b/app/src/test/java/app/drydock/review/RecheckAsymmetryTest.java @@ -0,0 +1,229 @@ +package app.drydock.review; + +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()); + } + + /** + * 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/ui/OpenSessionTabReviewSubTabTest.java b/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java index 057e6930..ed629c8f 100644 --- a/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java +++ b/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java @@ -526,6 +526,12 @@ 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 public void setResolved(ReviewScope scope, ReviewAnnotation finding, boolean resolved) { } 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 f13bb02d..a2ae226e 100644 --- a/app/src/test/java/app/drydock/ui/review/FakeReviewHost.java +++ b/app/src/test/java/app/drydock/ui/review/FakeReviewHost.java @@ -171,6 +171,13 @@ public BaseMove.Delta baseMove(ReviewScope scope, String recordedBase) { return 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 void setResolved(ReviewScope scope, ReviewAnnotation finding, boolean resolved) { store.mutate(finding.key(), current -> current.withStatus( diff --git a/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java b/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java index 54e3c8ae..eaaf4394 100644 --- a/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java +++ b/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java @@ -275,6 +275,108 @@ void anUnresolvableDeltaIsUnknownNotMoved() { 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() { @@ -578,6 +680,17 @@ 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); } From 16a173981a935211593b7eaa1cbdc93a0b80ea92 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Sun, 23 Aug 2026 12:07:39 +0200 Subject: [PATCH 096/113] A mark with no reason, and a broken affected, are both refused Three from review, all on review_recheck's decode. why is required whenever affected is true. A staleness signal asserted with no reason is the reflexive click the asymmetry exists to avoid -- it is worth less than no signal at all -- and it is what a renderer could only draw as a blank warning. Refused before a renderer exists, so the store never accumulates reasonless marks. An unaffected assessment may still omit it: "I looked and it does not matter" asks nothing of a human, so there is nothing for a reason to justify. affected and why are decoded on their own terms, the shape readsFromJson established one task ago. "affected":"true" from a stringifying client -- this codebase already accommodates one in optionalIntArg -- used to decode as false, silently: "the agent looked and found nothing", which is the one answer this tool must never manufacture. Absent and broken must not look the same is the rule this entire surface is drawn around, and it had slipped back in on the tool built to embody it. An explicit null stays ABSENT. The 4->5 bump is pinned by an assertion on the version the store actually writes. Reverting the constant killed nothing before; it kills exactly that test now. Two of the new type tests were vacuous on first write. Sent with affected:true, a lenient decode produced "" and the new needs-a-reason refusal fired instead, so the batch was rejected either way and the test could not tell a type check from a blank check -- it passed with the type check deleted. They send affected:false now, where nothing else can refuse, and assert the message names the type rather than merely that something threw. Descriptor: says why is required when affected is true, and says "already-settled hunks" and "a human's verdict" rather than "approved", since a CHANGES verdict can be marked too. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/app/drydock/mcp/McpToolRouter.java | 9 +- .../java/app/drydock/mcp/ReviewToolCodec.java | 75 +++++++++-- .../drydock/mcp/McpToolRouterRecheckTest.java | 121 ++++++++++++++++++ .../drydock/review/RecheckAsymmetryTest.java | 25 ++++ 4 files changed, 214 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/app/drydock/mcp/McpToolRouter.java b/app/src/main/java/app/drydock/mcp/McpToolRouter.java index 13aec691..6665dbe6 100644 --- a/app/src/main/java/app/drydock/mcp/McpToolRouter.java +++ b/app/src/main/java/app/drydock/mcp/McpToolRouter.java @@ -195,16 +195,19 @@ public List toolDescriptors() { JsonObject.empty().put("scopeId", schemaString("Review scope handle.")), "scopeId"), descriptor("review_recheck", - "Assesses whether a base move still leaves approved hunks valid. " + "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 approval. Drydock derives which base " + + "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.")), + + "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 " diff --git a/app/src/main/java/app/drydock/mcp/ReviewToolCodec.java b/app/src/main/java/app/drydock/mcp/ReviewToolCodec.java index 489f7ff2..88401ca1 100644 --- a/app/src/main/java/app/drydock/mcp/ReviewToolCodec.java +++ b/app/src/main/java/app/drydock/mcp/ReviewToolCodec.java @@ -399,13 +399,16 @@ private static Optional collapseFromJson(JsonObject obj) * recheck would then sit in the store answering a question nobody asks -- * absent and broken looking the same again.

* - *

Three 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; and a {@code why} that fails {@link - * PromptSafety}. 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.

+ *

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, @@ -430,18 +433,64 @@ static List assessmentsFromJson(String scopeId, JsonValue val + "no verdict; a recheck says whether a base move undermines a decision, " + "and there is no decision on that hunk to undermine"); } - // affected is the only field with an effect, and its absence is - // read as false -- the direction that changes nothing. A missing - // boolean must not be able to invent staleness nobody asserted. - boolean affected = obj.get("affected") instanceof JsonBoolean flag && flag.value(); - String why = PromptSafety.checkInboundText(optionalString(obj, "why").orElse(""), - "assessment.why"); + 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 diff --git a/app/src/test/java/app/drydock/mcp/McpToolRouterRecheckTest.java b/app/src/test/java/app/drydock/mcp/McpToolRouterRecheckTest.java index a2be7aa5..7875cafc 100644 --- a/app/src/test/java/app/drydock/mcp/McpToolRouterRecheckTest.java +++ b/app/src/test/java/app/drydock/mcp/McpToolRouterRecheckTest.java @@ -251,6 +251,127 @@ void anAffectedAssessmentIsReportedAsAMark() throws Exception { 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 ---------------------- /** diff --git a/app/src/test/java/app/drydock/review/RecheckAsymmetryTest.java b/app/src/test/java/app/drydock/review/RecheckAsymmetryTest.java index 7d6b5035..42256d3c 100644 --- a/app/src/test/java/app/drydock/review/RecheckAsymmetryTest.java +++ b/app/src/test/java/app/drydock/review/RecheckAsymmetryTest.java @@ -1,5 +1,7 @@ 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; @@ -134,6 +136,29 @@ void anUnaffectedAssessmentIsPersistedRatherThanDroppedAsInert() throws IOExcept 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 From fae871e681e6fad312a5422028816939e9cb111a Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Mon, 24 Aug 2026 17:47:15 +0200 Subject: [PATCH 097/113] A base move asks the agent which approvals it actually disturbed When the base moved under a review, drydock marked the affected approvals stale and stopped there. The reviewer came back to a row of "base moved" banners with nothing said about which of them mattered, and the only way to find out was to re-read every one or type the request by hand -- so the assessment arrived after a wait, exactly when they wanted to move on. A base move now dispatches its own recheck. It is a small bounded task by construction -- one base delta and the stale hunks, not the change -- which is why it earns a dispatch rather than a full re-review. The instruction says outright that "unaffected" does not clear an approval, because an agent should be told the rule rather than left to infer it from what review_recheck happens to refuse. The plan put this in SessionReviewView keyed on AnnotationStore's assessedAffected. Two constraints ruled that out. SessionReviewView has no AgentRegistry to resolve subagent support through, and its baseMove seam is called on the FX thread and must not block. More seriously, assessedAffected cannot deduplicate the dispatch at all: 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 see a dispatch still in flight. refreshReviewBoards re-renders every open board whenever a background git answer lands, and every one of those renders falls inside that window, so deduplicating on the store alone would spend a subagent per render. That is worse than the per-base-move flood the gate exists to prevent. So the memory is RecheckDispatch, a claim/release record mirroring the baseMoveInFlight set it sits beside, and the decision is SectionStates.requestRechecks, driven once per render. It gates on a section's staleness already being other than FRESH, which reuses BaseMove.couldMatter's file filter rather than repeating it -- the two cannot drift apart. A hand-off that returns false is released again: the send reached no terminal, and no human is watching an automatic recheck, so a swallowed failure is a scope that silently never gets one. Neither the dispatch nor its result can reach a verdict. requestRechecks only reads verdicts, and the recheck's answer lands through review_recheck as an assessment, which remains the one direction that can add staleness and never remove it. MainWorkspace.supportsSubagents is extracted from reviewInstruction so the review and the recheck resolve subagent support one way instead of two. No behaviour change; it is here rather than in its own commit because the recheck is its only new caller. Verified with ./gradlew :app:test --rerun-tasks: 2043 tests, 204 classes, 0 failures in 6m31s, against a baseline of 2023/202/0 confirmed the same way at 8487310. The +20 accounts exactly as 6 + 8 + 6. Mutation testing found four assertions that could not fail, each a test varying two things at once: forRecheck's bases were checked separately, so a genuine swap read identical; RecheckDispatch's key test moved both bases together, pinning neither component nor the separator; and requestRechecks without its staleAgainst filter dispatched fromBase == toBase, spending a subagent on an empty diff. All four are now pinned by tests that fail on the mutant and pass on the fix. Not covered: the wiring inside MainWorkspace.dispatchRecheck itself, which has no test -- MainWorkspace has one test in the whole class. It is kept thin for that reason, and everything decidable was pushed behind the Host seam where FakeReviewHost drives it. Co-Authored-By: Claude Opus 5 (1M context) --- .../app/drydock/review/RecheckDispatch.java | 58 ++++++++++ .../drydock/review/ReviewInstructions.java | 23 ++++ .../java/app/drydock/ui/MainWorkspace.java | 28 ++++- .../app/drydock/ui/review/SectionStates.java | 45 ++++++++ .../drydock/ui/review/SessionReviewView.java | 28 +++++ .../drydock/review/RecheckDispatchTest.java | 103 ++++++++++++++++++ .../review/ReviewInstructionsRecheckTest.java | 74 +++++++++++++ .../ui/OpenSessionTabReviewSubTabTest.java | 5 + .../app/drydock/ui/review/FakeReviewHost.java | 12 ++ .../drydock/ui/review/SectionStatesTest.java | 103 ++++++++++++++++++ 10 files changed, 477 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/app/drydock/review/RecheckDispatch.java create mode 100644 app/src/test/java/app/drydock/review/RecheckDispatchTest.java create mode 100644 app/src/test/java/app/drydock/review/ReviewInstructionsRecheckTest.java 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..e7c3eb7f --- /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 { + + /** + * NUL joins the three parts because it cannot occur in a commit and is + * not plausible in a scope handle: {@code ("s-a","b")} and + * {@code ("s","a-b")} must not collide on one key. + */ + private static final char SEPARATOR = '\0'; + + 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(key(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(key(scopeId, fromBase, toBase)); + } + + private static String key(String scopeId, String fromBase, String toBase) { + Objects.requireNonNull(scopeId, "scopeId"); + Objects.requireNonNull(fromBase, "fromBase"); + Objects.requireNonNull(toBase, "toBase"); + return scopeId + SEPARATOR + fromBase + SEPARATOR + 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..1440af97 100644 --- a/app/src/main/java/app/drydock/review/ReviewInstructions.java +++ b/app/src/main/java/app/drydock/review/ReviewInstructions.java @@ -29,4 +29,27 @@ 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.

+ */ + 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 + "."; + } } diff --git a/app/src/main/java/app/drydock/ui/MainWorkspace.java b/app/src/main/java/app/drydock/ui/MainWorkspace.java index 94dae517..02e12313 100644 --- a/app/src/main/java/app/drydock/ui/MainWorkspace.java +++ b/app/src/main/java/app/drydock/ui/MainWorkspace.java @@ -2300,6 +2300,22 @@ 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. + */ + @Override + public boolean dispatchRecheck(ReviewScope scope, String fromBase, String toBase) { + if (scope.sessionId().isEmpty()) { + return false; + } + return sendToBoundSession(scope, + ReviewInstructions.forRecheck(scope.id(), fromBase, toBase, + supportsSubagents(scope))); + } } /** @@ -2316,13 +2332,21 @@ 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); } /** diff --git a/app/src/main/java/app/drydock/ui/review/SectionStates.java b/app/src/main/java/app/drydock/ui/review/SectionStates.java index 79f2837d..c8513619 100644 --- a/app/src/main/java/app/drydock/ui/review/SectionStates.java +++ b/app/src/main/java/app/drydock/ui/review/SectionStates.java @@ -5,6 +5,7 @@ import app.drydock.review.ChangeGraph; import app.drydock.review.HunkDigest; import app.drydock.review.IntentHunks; +import app.drydock.review.RecheckDispatch; import app.drydock.review.ReviewIntent; import app.drydock.review.ReviewScope; import app.drydock.review.ReviewVerdict; @@ -349,6 +350,50 @@ SectionState stateOf(Board board, ReviewIntent intent) { staleness, List.copyOf(elsewhere), false); } + /** + * 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) { + 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 : board.sections()) { + if (stateOf(board, intent).staleness() == Staleness.FRESH) { + continue; + } + for (String digest : digestsOf(board, intent)) { + host.verdict(board.scope(), digest) + .filter(verdict -> verdict.staleAgainst(base)) + .ifPresent(verdict -> recordedBases.add(verdict.baseCommit())); + } + } + for (String from : recordedBases) { + 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 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 81466d40..8030642c 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -12,6 +12,7 @@ import app.drydock.review.OutOfDiffFanIn; import app.drydock.review.ReadingPath; import app.drydock.review.ReviewAnnotation; +import app.drydock.review.RecheckDispatch; import app.drydock.review.ReviewIntent; import app.drydock.review.ReviewScope; import app.drydock.review.ReviewVerdict; @@ -229,6 +230,20 @@ void setVerdict(ReviewScope scope, ReviewIntent intent, List hunkDigests */ 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.

+ */ + boolean dispatchRecheck(ReviewScope scope, String fromBase, String toBase); + /** Resolve / Reopen one finding. */ void setResolved(ReviewScope scope, ReviewAnnotation finding, boolean resolved); @@ -343,6 +358,14 @@ enum SettleUnit { 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; @@ -1143,6 +1166,11 @@ public void refreshReviewState() { 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(); 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..67eaf7a0 --- /dev/null +++ b/app/src/test/java/app/drydock/review/ReviewInstructionsRecheckTest.java @@ -0,0 +1,74 @@ +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")); + } + + /** + * 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 bothFormsReadTheDeltaFromTheOldBaseToTheNew() { + for (boolean subagents : new boolean[] {true, false}) { + assertTrue(ReviewInstructions.forRecheck("s", "a1b2c3", "d4e5f6", subagents) + .contains("between a1b2c3 and d4e5f6")); + } + } + + @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")); + } + } + + @Test + void bothFormsNameTheScopeHandle() { + for (boolean subagents : new boolean[] {true, false}) { + assertTrue(ReviewInstructions.forRecheck("rs_abc123", "a", "b", subagents) + .contains("rs_abc123")); + } + } + + /** + * Delivered through TerminalBridge.sendPrompt, which types the string into + * a prompt: a newline would submit half an instruction. + */ + @Test + void bothFormsAreASingleLine() { + for (boolean subagents : new boolean[] {true, false}) { + assertFalse(ReviewInstructions.forRecheck("s", "a", "b", subagents).contains("\n")); + } + } +} diff --git a/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java b/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java index ed629c8f..547c457f 100644 --- a/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java +++ b/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java @@ -569,5 +569,10 @@ public void submit(ReviewScope scope, SubmitPlan.DiffIndex index, 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; final List submittedScopes = new ArrayList<>(); final List explorerJumps = new ArrayList<>(); @@ -178,6 +184,12 @@ public boolean assessedAffected(ReviewScope scope, String hunkDigest, return store.assessedAffected(scope.id(), hunkDigest, fromBase, toBase); } + @Override + public boolean dispatchRecheck(ReviewScope scope, String fromBase, String toBase) { + recheckDispatches.add(fromBase + "->" + toBase); + return recheckHandOffSucceeds; + } + @Override public void setResolved(ReviewScope scope, ReviewAnnotation finding, boolean resolved) { store.mutate(finding.key(), current -> current.withStatus( diff --git a/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java b/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java index eaaf4394..5b24f84a 100644 --- a/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java +++ b/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java @@ -4,6 +4,7 @@ import app.drydock.review.BaseMove; import app.drydock.review.ChangeGraph; import app.drydock.review.HunkDigest; +import app.drydock.review.RecheckDispatch; import app.drydock.review.ReviewIntent; import app.drydock.review.ReviewScope; import app.drydock.review.ReviewScopeRegistry; @@ -647,6 +648,108 @@ void digestsForActionWithWholeFileIgnoresTheUnit() { board, section1, SessionReviewView.SettleUnit.HUNK, true, Optional.empty())); } + // ---- 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()); + } + + /** + * 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. */ + @Test + void aScopeWithNoRecordedApprovalAsksNothing() { + host.baseDelta = new BaseMove.Delta(false, new TreeSet<>(List.of(GUARDS_H))); + + sections.requestRechecks(overlapping(), new RecheckDispatch()); + + assertTrue(host.recheckDispatches.isEmpty()); + } + // ---- helpers ------------------------------------------------------------- /** Section ① covers both guards files; section ② covers guards.h again and profiler. */ From b663adb48d125c2a2847167faa9d7e6e99f267c6 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Mon, 24 Aug 2026 18:07:35 +0200 Subject: [PATCH 098/113] An ordering says whether drydock measured it or an agent claimed it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 "③ depends on ①" has to know which of those they are holding, and until now the rail rendered both identically. 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. The marker goes on the RAIL, which is where the warrant actually varies. Spec §7.1 names three order sources -- `reads`, the agent's array order, and ReadingPath -- of which the first two are the agent's claim and the third is drydock's measurement; hasReviewerGrouping is exactly that split, so the rail takes a Provenance and the view derives it there. It is deliberately NOT on a path row. Spec §6.4 has ReadingPath order the computed grouping only, and currentPath() bears that out: it calls ReadingPath.of over Sections.of(diff, graph), built fresh from the diff, never from the agent's intents -- `reads` reaches IntentGrouping.orderByReads and nothing else. A path row is measured by construction, so marking one would be the only place on this surface where the marker could lie. The plan's own test asserted the opposite -- a CLAIMED path step after agent-supplied intents declaring reads -- and cannot pass without changing where the path's sections come from, which the plan never proposes. Following the Task 1 precedent, the spec wins and the test is re-shaped to the rail. The plan's stylesheet path was also wrong: app/src/main/resources/app.css does not exist, and the rule belongs in app/drydock/ui/app.css, where both its selectors are really added (ReviewIntentRail:491, ReviewDiffColumn:1755). ReadingPath.Step and Link still gain provenance(), constant MEASURED today, because §6.3 has labels carry their provenance and §14's checklist requires a scope holding an agent grouping AND a computed link set to mark each correctly -- a claimed rail beside measured links. Both records take it through a secondary constructor defaulting to MEASURED, the same shape Task 21 used for `reads`, so the ten test construction sites stay as they are and only the two production sites name the warrant. Dashed rather than coloured: four risk encodings already compete for colour on this surface and a fifth would be unreadable. Only the claimed case is modified, since decorating every row would say nothing. Verified with ./gradlew :app:test --rerun-tasks: 2049 tests, 206 classes, 0 failures in 6m29s, from 2043/204 -- the +6 is 2 + 3 + 1. Mutation testing killed every-card-marked, no-card-marked, and view-always-claims. A fourth mutation, PATH mode not resetting the warrant, SURVIVED, and it was right to: buildPathRow never consulted provenance, so the reset was unreachable. It is deleted rather than kept, and the path test was re-checked against a buildPathRow that DOES apply the modifier, where it fails -- so it guards the real risk and not an accident of the current code. Checked visually, not only modelled, per the phase gate: rail snapshots in both warrants show dashed borders against solid ones, legible without opening a tooltip. Not covered: the diff column's link rows carry the CSS selector but can never match it while every link is measured, so that half of the rule is unexercised until a claimed link exists. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/app/drydock/review/Provenance.java | 38 ++++++++ .../java/app/drydock/review/ReadingPath.java | 26 ++++- .../drydock/ui/review/ReviewIntentRail.java | 26 ++++- .../drydock/ui/review/SessionReviewView.java | 12 ++- app/src/main/resources/app/drydock/ui/app.css | 8 ++ .../app/drydock/review/ProvenanceTest.java | 30 ++++++ .../app/drydock/review/ReadingPathTest.java | 23 +++++ .../ReviewIntentRailCardHeightTest.java | 4 +- .../ui/review/ReviewPathRowContrastTest.java | 3 +- .../ui/review/ReviewProvenanceTest.java | 95 +++++++++++++++++++ 10 files changed, 255 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/app/drydock/review/Provenance.java create mode 100644 app/src/test/java/app/drydock/review/ProvenanceTest.java create mode 100644 app/src/test/java/app/drydock/ui/review/ReviewProvenanceTest.java 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 index c70cc555..8ed121b0 100644 --- a/app/src/main/java/app/drydock/review/ReadingPath.java +++ b/app/src/main/java/app/drydock/review/ReadingPath.java @@ -97,11 +97,22 @@ private ReadingPath() { * 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) { + 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); } } @@ -113,13 +124,20 @@ public record Link(String kind, String targetHunkId, String label) { * other. */ public record Step(String hunkId, String file, int sectionNumber, String reason, - List links, boolean entryPoint) { + 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); + } } /** @@ -200,7 +218,7 @@ public static Path of(UnifiedDiff diff, ChangeGraph graph, 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())); + reason, links, steps.isEmpty(), Provenance.MEASURED)); } } return new Path(steps, ordered); @@ -404,7 +422,7 @@ private static void emit(List links, Set claimed, String label = (marker.isEmpty() ? "" : marker + " ") + FallbackIntents.fileName(to.file()) + relation + best(target.getValue(), graph); - links.add(new Link(kind, hunkId, label)); + links.add(new Link(kind, hunkId, label, Provenance.MEASURED)); } } 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 7d45baff..e06008a9 100644 --- a/app/src/main/java/app/drydock/ui/review/ReviewIntentRail.java +++ b/app/src/main/java/app/drydock/ui/review/ReviewIntentRail.java @@ -2,6 +2,7 @@ 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; @@ -101,6 +102,15 @@ 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: §7.1'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 @@ -242,8 +252,10 @@ 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 = provenance == null ? Provenance.MEASURED : provenance; this.intents = List.copyOf(newIntents); this.selectedId = selectedIntentId; this.emptyReason = reason == null ? Empty.NONE : reason; @@ -488,6 +500,10 @@ private int hunksInFile(String file) { */ 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); @@ -601,9 +617,15 @@ protected double computePrefHeight(double width) { 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). + 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())); 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 8030642c..0ff506e9 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -12,6 +12,7 @@ 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; @@ -1144,7 +1145,8 @@ public void refreshReviewState() { // 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(); @@ -1196,8 +1198,14 @@ public void refreshReviewState() { String selectedHunkId = steps.isEmpty() ? null : steps.get(pathIndex).hunkId(); intentRail.showPath(steps, selectedHunkId, emptyReason()); } else { + // Spec §7.1: 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()); + 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…" diff --git a/app/src/main/resources/app/drydock/ui/app.css b/app/src/main/resources/app/drydock/ui/app.css index 6dd7e50c..3af7f37d 100644 --- a/app/src/main/resources/app/drydock/ui/app.css +++ b/app/src/main/resources/app/drydock/ui/app.css @@ -3215,6 +3215,14 @@ -fx-alignment: center-left; -fx-cursor: hand; } +/* A claimed ordering is the agent's assertion, not drydock's measurement + * (spec §6.5). Dashed rather than coloured: four risk encodings already + * compete for colour on this surface and a fifth would be unreadable. Only + * the claimed case is modified -- marking every row would say nothing. */ +.review-intent-card.provenance-claimed, +.review-link-row.provenance-claimed { + -fx-border-style: segments(3, 3) line-cap round; +} .review-intent-card.collapsed { -fx-padding: 7 0 7 0; -fx-alignment: center; 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..8cb1e2ca --- /dev/null +++ b/app/src/test/java/app/drydock/review/ProvenanceTest.java @@ -0,0 +1,30 @@ +package app.drydock.review; + +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), 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()); + } + + /** + * 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 index 52b1fac8..12138e4d 100644 --- a/app/src/test/java/app/drydock/review/ReadingPathTest.java +++ b/app/src/test/java/app/drydock/review/ReadingPathTest.java @@ -194,6 +194,29 @@ void withNothingToTellThemApartStepsFollowPathOrder() { 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( 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 1ce61cd3..28b18eb5 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,7 @@ 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; @@ -159,7 +160,8 @@ private void assertSaneHeight(double height) { // ---- 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/ReviewPathRowContrastTest.java b/app/src/test/java/app/drydock/ui/review/ReviewPathRowContrastTest.java index eba0e788..6b23d487 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewPathRowContrastTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewPathRowContrastTest.java @@ -2,6 +2,7 @@ import app.drydock.ui.TestStages; import app.drydock.review.ReadingPath; +import app.drydock.review.Provenance; import app.drydock.review.ReviewIntent; import javafx.scene.Node; @@ -105,7 +106,7 @@ private Color titleFill(boolean selected) { "", 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)); + interact(() -> rail.setIntents(intents, "a", ReviewIntentRail.Empty.NONE, Provenance.MEASURED)); WaitForAsyncUtils.waitForFxEvents(); interact(() -> rail.getScene().getRoot().layout()); WaitForAsyncUtils.waitForFxEvents(); 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..69c3d3d2 --- /dev/null +++ b/app/src/test/java/app/drydock/ui/review/ReviewProvenanceTest.java @@ -0,0 +1,95 @@ +package app.drydock.ui.review; + +import app.drydock.review.Provenance; +import javafx.scene.Node; +import javafx.scene.input.KeyCode; +import org.junit.jupiter.api.Test; +import org.testfx.util.WaitForAsyncUtils; + +import java.util.List; + +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 §7.1 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"))); + } + + 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(); + } +} From f133007ed916d50268b1cec6508db5410d7e7956 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Tue, 25 Aug 2026 12:11:48 +0200 Subject: [PATCH 099/113] The automatic recheck waits until git has said the move could matter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the previous commit found the relevance gate did not work in the running app. It skipped only FRESH, and staleness has three values: the production host answers UNRESOLVABLE on the FIRST call for any base pair -- it spawns the git off the FX thread and replies on a later render -- which renders as UNKNOWN, not FRESH. So the very render that discovered a base move dispatched, before couldMatter had said anything at all, and the claim is permanent: when the real answer landed one render later saying the move was irrelevant, nothing could retract it. Every base move spent an agent run, which is the flood the gate was written to prevent. No test caught it because FakeReviewHost.baseMove returns an always-resolved field and cannot express the in-flight state the real host returns every first time. The suite already asserted UNKNOWN for that case forty lines away; it was simply never carried into the dispatch path. The gate now requires MOVED, and UNKNOWN means ask again next render, by which time git has spoken. Three more defects from the same review: "unresolved" is not a revision, and the guard existed only for the CURRENT base. A verdict carries the sentinel as its RECORDED base whenever the baseline was unresolved when the human settled the hunk -- while git was still answering, or permanently when resolveRef failed -- and that reached the agent as "read what changed between unresolved and ", a request nothing can satisfy, one-shot because the claim was already taken. dispatchRecheck now refuses a tab whose agent process has exited, the way requestHandoffRefresh and the Explorer bridge already do. sendToBoundSession answers "a tab object exists", not "the agent received it": TerminalBridge returns void and swallows a closing surface, so typing into a dead terminal returned true, the claim stood, and the recheck was lost with nothing logged. The two senders a human drives get away without the check because a human notices the reply never comes; nobody is watching this one. And the feature could be deleted in silence. Removing the requestRechecks call from the render pass left all 322 review-UI tests green, and gutting MainWorkspace.dispatchRecheck to `return true` left the suite green too -- ship both and the feature is inert with nothing failing. ReviewRecheckDispatch Test drives a real render, so the first of those now fails. Two rulings, both against the plan: Spec §9.7 says "inline harnesses simply do not get one" and the plan said the opposite. The spec wins, per the Task 1 precedent. The tempting middle option -- dispatch inline but only when the agent is idle -- is not available: Codex and Pi both return Optional.empty() from AgentProvider.activity(), and Claude, the only provider with subagents, is the only one that reports activity at all. The harnesses that would need an idle signal are exactly the ones with none. The in-memory claim dies with the view while the stale mark outlives it, so every restart re-asked a question already answered on disk. AnnotationStore gains assessedMove, which is deliberately NOT assessedAffected: that one folds "said unaffected" into "never asked" because only true may add staleness, and dispatch needs the other question. Smaller: only APPROVED and AUTO_APPROVED verdicts trigger it, since the instruction says "for each approved hunk"; counted(board) replaces board.sections() so a collapsed section spends nothing; forRecheck null-checks both bases, which are concatenated into a line typed at a prompt; and the NUL-joined key is a record Move, which deletes the argument about which byte is impossible in a scope handle rather than defending it. Verified with ./gradlew :app:test --rerun-tasks: 2060 tests, 207 classes, 0 failures in 8m53s, from 2049/206. Ten mutations, each with the mutated source printed and confirmed, all killed by the test written for them -- including the two the review found surviving (the gate itself, and collecting only the first distinct base). Not covered: MainWorkspace.dispatchRecheck still has no test, so the liveness guard and the instruction assembly are unexercised -- gutting it to `return true` still leaves the suite green. That class has one test in total. It is the next thing an end-to-end run against a real agent has to exercise. Also worth knowing for anyone writing a multi-scope test: ReviewScopeRegistry .mint does computeIfAbsent on the spec identity, so minting an "identical" scope returns the SAME handle. The first version of the two-scope test was comparing one scope against itself. Co-Authored-By: Claude Opus 5 (1M context) --- .../app/drydock/review/AnnotationStore.java | 16 ++ .../app/drydock/review/RecheckDispatch.java | 28 +-- .../drydock/review/ReviewInstructions.java | 4 + .../java/app/drydock/ui/MainWorkspace.java | 36 ++++ .../app/drydock/ui/review/SectionStates.java | 40 +++- .../drydock/ui/review/SessionReviewView.java | 27 +++ .../ui/OpenSessionTabReviewSubTabTest.java | 10 + .../app/drydock/ui/review/FakeReviewHost.java | 14 ++ .../ui/review/ReviewRecheckDispatchTest.java | 74 ++++++++ .../drydock/ui/review/SectionStatesTest.java | 171 +++++++++++++++++- 10 files changed, 399 insertions(+), 21 deletions(-) create mode 100644 app/src/test/java/app/drydock/ui/review/ReviewRecheckDispatchTest.java diff --git a/app/src/main/java/app/drydock/review/AnnotationStore.java b/app/src/main/java/app/drydock/review/AnnotationStore.java index 5ceb3302..0c9196b4 100644 --- a/app/src/main/java/app/drydock/review/AnnotationStore.java +++ b/app/src/main/java/app/drydock/review/AnnotationStore.java @@ -216,6 +216,22 @@ public synchronized boolean assessedAffected(String scopeId, String hunkDigest, 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(); diff --git a/app/src/main/java/app/drydock/review/RecheckDispatch.java b/app/src/main/java/app/drydock/review/RecheckDispatch.java index e7c3eb7f..472ed9d8 100644 --- a/app/src/main/java/app/drydock/review/RecheckDispatch.java +++ b/app/src/main/java/app/drydock/review/RecheckDispatch.java @@ -23,20 +23,27 @@ public final class RecheckDispatch { /** - * NUL joins the three parts because it cannot occur in a commit and is - * not plausible in a scope handle: {@code ("s-a","b")} and - * {@code ("s","a-b")} must not collide on one key. + * 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 static final char SEPARATOR = '\0'; + 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<>(); + 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(key(scopeId, fromBase, toBase)); + return dispatched.add(new Move(scopeId, fromBase, toBase)); } /** @@ -46,13 +53,6 @@ public boolean claim(String scopeId, String fromBase, String toBase) { * entirely, with no human present to notice. */ public void release(String scopeId, String fromBase, String toBase) { - dispatched.remove(key(scopeId, fromBase, toBase)); - } - - private static String key(String scopeId, String fromBase, String toBase) { - Objects.requireNonNull(scopeId, "scopeId"); - Objects.requireNonNull(fromBase, "fromBase"); - Objects.requireNonNull(toBase, "toBase"); - return scopeId + SEPARATOR + fromBase + SEPARATOR + 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 1440af97..7ea8cdd6 100644 --- a/app/src/main/java/app/drydock/review/ReviewInstructions.java +++ b/app/src/main/java/app/drydock/review/ReviewInstructions.java @@ -42,6 +42,10 @@ public static String forScope(String scopeId, boolean supportsSubagents) { public static String forRecheck(String scopeId, String fromBase, String toBase, boolean supportsSubagents) { 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 " diff --git a/app/src/main/java/app/drydock/ui/MainWorkspace.java b/app/src/main/java/app/drydock/ui/MainWorkspace.java index 02e12313..1b5d2c0a 100644 --- a/app/src/main/java/app/drydock/ui/MainWorkspace.java +++ b/app/src/main/java/app/drydock/ui/MainWorkspace.java @@ -2306,16 +2306,52 @@ public boolean runReview(ReviewScope scope) { * 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; + } return sendToBoundSession(scope, ReviewInstructions.forRecheck(scope.id(), fromBase, toBase, supportsSubagents(scope))); } + + /** + * 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); + } } /** diff --git a/app/src/main/java/app/drydock/ui/review/SectionStates.java b/app/src/main/java/app/drydock/ui/review/SectionStates.java index c8513619..7f13d5f2 100644 --- a/app/src/main/java/app/drydock/ui/review/SectionStates.java +++ b/app/src/main/java/app/drydock/ui/review/SectionStates.java @@ -38,9 +38,12 @@ * 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 blocks -- the two questions + * 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.

+ * 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 { @@ -369,6 +372,12 @@ SectionState stateOf(Board board, ReviewIntent intent) { * 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 @@ -376,17 +385,40 @@ void requestRechecks(Board board, RecheckDispatch dispatch) { return; } Set recordedBases = new LinkedHashSet<>(); - for (ReviewIntent intent : board.sections()) { - if (stateOf(board, intent).staleness() == Staleness.FRESH) { + for (ReviewIntent intent : counted(board)) { + // MOVED, not merely "not FRESH". UNKNOWN is what the host returns + // on the FIRST render that sees a base pair -- it spawns the git + // off-thread and answers on a later pass -- so dispatching on it + // would ask the agent before couldMatter had said anything, and + // the claim is permanent. UNKNOWN means ask again next render, + // by which time git has spoken. + if (stateOf(board, intent).staleness() != Staleness.MOVED) { continue; } for (String digest : digestsOf(board, intent)) { host.verdict(board.scope(), digest) .filter(verdict -> verdict.staleAgainst(base)) + // "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. + .filter(verdict -> !SessionReviewView.UNRESOLVED_BASE + .equals(verdict.baseCommit())) + // The instruction says "for each APPROVED hunk"; a + // requested-changes verdict is not one, and asking + // about it spends an agent looking for nothing. + .filter(verdict -> verdict.decision() == ReviewVerdict.Decision.APPROVED + || verdict.decision() == ReviewVerdict.Decision.AUTO_APPROVED) .ifPresent(verdict -> recordedBases.add(verdict.baseCommit())); } } for (String from : recordedBases) { + if (host.assessedMove(board.scope(), from, base)) { + // Already answered, and that answer is on disk. The in-memory + // claim dies with the view; this is what stops a restart + // re-asking a question the store can already answer. + continue; + } if (dispatch.claim(board.scope().id(), from, base) && !host.dispatchRecheck(board.scope(), from, base)) { dispatch.release(board.scope().id(), from, base); 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 0ff506e9..8866a28e 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -245,6 +245,33 @@ void setVerdict(ReviewScope scope, ReviewIntent intent, List hunkDigests */ boolean dispatchRecheck(ReviewScope scope, String fromBase, String toBase); + /** + * 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); + + /** + * 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); diff --git a/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java b/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java index 547c457f..3ca80391 100644 --- a/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java +++ b/app/src/test/java/app/drydock/ui/OpenSessionTabReviewSubTabTest.java @@ -574,5 +574,15 @@ public boolean runReview(ReviewScope scope) { public boolean dispatchRecheck(ReviewScope scope, String fromBase, String toBase) { return false; } + + @Override + public boolean supportsAutomaticRecheck(ReviewScope scope) { + return false; + } + + @Override + public boolean assessedMove(ReviewScope scope, String fromBase, String toBase) { + return false; + } } } 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 21201b01..217474bc 100644 --- a/app/src/test/java/app/drydock/ui/review/FakeReviewHost.java +++ b/app/src/test/java/app/drydock/ui/review/FakeReviewHost.java @@ -42,6 +42,9 @@ final class FakeReviewHost implements SessionReviewView.Host { /** 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; final List submittedScopes = new ArrayList<>(); final List explorerJumps = new ArrayList<>(); @@ -184,6 +187,17 @@ public boolean assessedAffected(ReviewScope scope, String hunkDigest, 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); 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/SectionStatesTest.java b/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java index 5b24f84a..1c22d29d 100644 --- a/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java +++ b/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java @@ -23,6 +23,7 @@ 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; @@ -720,6 +721,160 @@ void aMoveThatCouldNotMatterAsksNothing() { 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()); + } + + /** + * 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; @@ -740,14 +895,24 @@ void aFreshApprovalSharingAStaleSectionIsNotAskedAbout() { "a verdict already recorded against the current base has not moved"); } - /** No approval, nothing staled, nothing to ask. */ + /** + * 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(overlapping(), new RecheckDispatch()); - + 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 ------------------------------------------------------------- From faac73698440c2c3750e0d8115f8ff681c0c9b23 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Tue, 25 Aug 2026 12:48:39 +0200 Subject: [PATCH 100/113] Relevance is decided per approval, not per section The previous fix moved the gate to MOVED but left it on the wrong thing. A section's staleness is the strongest claim true of any of its hunks, and the loop that collects bases runs per verdict -- so one hunk whose move was confirmed dragged in every other stale approval in the same section, whatever that approval's own base delta said. Re-review demonstrated both halves: a neighbour whose git was still in flight was asked about before couldMatter had answered (the original defect, intact), and a neighbour whose move was resolved and touched only docs was asked about too, which is exactly what the filter exists to prevent. The comment claiming "UNKNOWN means ask again next render" was false as written. The relevance test now runs inside the digest loop, per verdict. The section check stays as a cheap pre-filter and says so: a section with no MOVED hunk can hold no MOVED verdict, but the converse does not follow, and that gap was the bug. FakeReviewHost.baseMove returned one delta for every recorded base, so no test could express two approvals resolving differently. That is the third defect on this branch to hide behind a fake collapsing a distinction the real host makes -- it memoizes one delta per (scope, oldBase, newBase). The fake now answers per recorded base, and the two tests this makes possible both fail against the section-only gate. Two deletions the review earned. AUTO_APPROVED is gone from the decision filter: nothing stores such a verdict, VerdictMerge derives it for display only, so the clause was unreachable. And forRecheck loses its supportsSubagents parameter -- once dispatch is gated on subagent support the inline form cannot be reached, and keeping it meant tests defending code that can never run. One case is knowingly forfeited and now says so in the code: a base move that is PERMANENTLY unresolvable, the old base force-pushed away, 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. Separating them needs a third state on Delta, which is a design change rather than a condition to bolt on here. Also documented rather than left to be rediscovered: the release-on-false retry is deliberately unbounded, because the check short-circuits on a dead tab before typing anything and a silently abandoned recheck is the failure this path exists to avoid; and assessedMove proves "some assessment exists for this pair", not "every approval at this pair was answered" -- review_recheck is agent-initiated and may answer partially. Verified with ./gradlew :app:test --rerun-tasks: 2061 tests, 207 classes, 0 failures in 8m44s, from 2060/207. Reverting the per-verdict filter to the section-only one kills both new tests. Still not covered, unchanged from the last commit: MainWorkspace .dispatchRecheck and supportsAutomaticRecheck have no tests -- removing the liveness guard, or making supportsAutomaticRecheck return true so inline harnesses get dispatched, both leave the suite green. Nothing in the test tree constructs MainWorkspace. An end-to-end run against a real agent is the only thing that exercises them. Co-Authored-By: Claude Opus 5 (1M context) --- .../drydock/review/ReviewInstructions.java | 13 ++--- .../java/app/drydock/ui/MainWorkspace.java | 3 +- .../app/drydock/ui/review/SectionStates.java | 53 ++++++++++++++----- .../review/ReviewInstructionsRecheckTest.java | 40 ++++---------- .../app/drydock/ui/review/FakeReviewHost.java | 11 +++- .../drydock/ui/review/SectionStatesTest.java | 38 +++++++++++++ 6 files changed, 108 insertions(+), 50 deletions(-) diff --git a/app/src/main/java/app/drydock/review/ReviewInstructions.java b/app/src/main/java/app/drydock/review/ReviewInstructions.java index 7ea8cdd6..596f20bd 100644 --- a/app/src/main/java/app/drydock/review/ReviewInstructions.java +++ b/app/src/main/java/app/drydock/review/ReviewInstructions.java @@ -38,9 +38,12 @@ public static String forScope(String scopeId, boolean supportsSubagents) { *

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, - boolean supportsSubagents) { + 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. @@ -51,9 +54,7 @@ public static String forRecheck(String scopeId, String fromBase, String toBase, + "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 + "."; + return "Dispatch a subagent to recheck stale approvals: " + work + + ". Report only its summary back here."; } } diff --git a/app/src/main/java/app/drydock/ui/MainWorkspace.java b/app/src/main/java/app/drydock/ui/MainWorkspace.java index 1b5d2c0a..2e66ee58 100644 --- a/app/src/main/java/app/drydock/ui/MainWorkspace.java +++ b/app/src/main/java/app/drydock/ui/MainWorkspace.java @@ -2331,8 +2331,7 @@ public boolean dispatchRecheck(ReviewScope scope, String fromBase, String toBase return false; } return sendToBoundSession(scope, - ReviewInstructions.forRecheck(scope.id(), fromBase, toBase, - supportsSubagents(scope))); + ReviewInstructions.forRecheck(scope.id(), fromBase, toBase)); } /** diff --git a/app/src/main/java/app/drydock/ui/review/SectionStates.java b/app/src/main/java/app/drydock/ui/review/SectionStates.java index 7f13d5f2..9c672485 100644 --- a/app/src/main/java/app/drydock/ui/review/SectionStates.java +++ b/app/src/main/java/app/drydock/ui/review/SectionStates.java @@ -386,39 +386,68 @@ void requestRechecks(Board board, RecheckDispatch dispatch) { } Set recordedBases = new LinkedHashSet<>(); for (ReviewIntent intent : counted(board)) { - // MOVED, not merely "not FRESH". UNKNOWN is what the host returns - // on the FIRST render that sees a base pair -- it spawns the git - // off-thread and answers on a later pass -- so dispatching on it - // would ask the agent before couldMatter had said anything, and - // the claim is permanent. UNKNOWN means ask again next render, - // by which time git has spoken. + // Cheap pre-filter ONLY. A section's staleness is the strongest + // thing true of any of its hunks, so a section with no MOVED hunk + // can hold no MOVED verdict either -- but the converse does not + // follow, which is why the real decision is per verdict below. if (stateOf(board, intent).staleness() != Staleness.MOVED) { continue; } + Collection files = filesAffectingScope(board, intent); for (String digest : digestsOf(board, intent)) { host.verdict(board.scope(), digest) - .filter(verdict -> verdict.staleAgainst(base)) // "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"; a // requested-changes verdict is not one, and asking // about it spends an agent looking for nothing. - .filter(verdict -> verdict.decision() == ReviewVerdict.Decision.APPROVED - || verdict.decision() == ReviewVerdict.Decision.AUTO_APPROVED) + // AUTO_APPROVED is not listed because nothing stores + // one -- VerdictMerge derives it for display only. + .filter(verdict -> verdict.decision() == ReviewVerdict.Decision.APPROVED) + // 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)) { - // Already answered, and that answer is on disk. The in-memory - // claim dies with the view; this is what stops a restart - // re-asking a question the store can already answer. + // 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); diff --git a/app/src/test/java/app/drydock/review/ReviewInstructionsRecheckTest.java b/app/src/test/java/app/drydock/review/ReviewInstructionsRecheckTest.java index 67eaf7a0..8007787e 100644 --- a/app/src/test/java/app/drydock/review/ReviewInstructionsRecheckTest.java +++ b/app/src/test/java/app/drydock/review/ReviewInstructionsRecheckTest.java @@ -13,8 +13,8 @@ class ReviewInstructionsRecheckTest { @Test - void theSubagentFormNamesBothBasesAndTheTool() { - String instruction = ReviewInstructions.forRecheck("scope-1", "a1b2c3", "d4e5f6", true); + void itNamesBothBasesAndTheTool() { + String instruction = ReviewInstructions.forRecheck("scope-1", "a1b2c3", "d4e5f6"); assertTrue(instruction.contains("a1b2c3")); assertTrue(instruction.contains("d4e5f6")); @@ -29,36 +29,20 @@ void theSubagentFormNamesBothBasesAndTheTool() { * both are present either way. */ @Test - void bothFormsReadTheDeltaFromTheOldBaseToTheNew() { - for (boolean subagents : new boolean[] {true, false}) { - assertTrue(ReviewInstructions.forRecheck("s", "a1b2c3", "d4e5f6", subagents) - .contains("between a1b2c3 and d4e5f6")); - } - } - - @Test - void theInlineFormDoesTheSameWorkWithoutASubagent() { - String instruction = ReviewInstructions.forRecheck("scope-1", "a1b2c3", "d4e5f6", false); - - assertTrue(instruction.contains("review_recheck")); - assertFalse(instruction.contains("subagent")); + 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 bothFormsSayThatUnaffectedIsAdviceOnly() { - for (boolean subagents : new boolean[] {true, false}) { - assertTrue(ReviewInstructions.forRecheck("s", "a", "b", subagents) - .contains("does not clear")); - } + void itSaysThatUnaffectedIsAdviceOnly() { + assertTrue(ReviewInstructions.forRecheck("s", "a", "b").contains("does not clear")); } @Test - void bothFormsNameTheScopeHandle() { - for (boolean subagents : new boolean[] {true, false}) { - assertTrue(ReviewInstructions.forRecheck("rs_abc123", "a", "b", subagents) - .contains("rs_abc123")); - } + void itNamesTheScopeHandle() { + assertTrue(ReviewInstructions.forRecheck("rs_abc123", "a", "b").contains("rs_abc123")); } /** @@ -66,9 +50,7 @@ void bothFormsNameTheScopeHandle() { * a prompt: a newline would submit half an instruction. */ @Test - void bothFormsAreASingleLine() { - for (boolean subagents : new boolean[] {true, false}) { - assertFalse(ReviewInstructions.forRecheck("s", "a", "b", subagents).contains("\n")); - } + void itIsASingleLine() { + assertFalse(ReviewInstructions.forRecheck("s", "a", "b").contains("\n")); } } 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 217474bc..a5954c12 100644 --- a/app/src/test/java/app/drydock/ui/review/FakeReviewHost.java +++ b/app/src/test/java/app/drydock/ui/review/FakeReviewHost.java @@ -45,6 +45,9 @@ final class FakeReviewHost implements SessionReviewView.Host { /** 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<>(); @@ -177,7 +180,13 @@ public String currentBase(ReviewScope scope) { @Override public BaseMove.Delta baseMove(ReviewScope scope, String recordedBase) { - return baseDelta; + // 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}. */ diff --git a/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java b/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java index 1c22d29d..5b9009b4 100644 --- a/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java +++ b/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java @@ -773,6 +773,44 @@ void anUnresolvedCurrentBaseAsksNothing() { 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 From 19c82c31cd2bbc45b1388aea68b82dda9dfd750e Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Tue, 25 Aug 2026 13:07:13 +0200 Subject: [PATCH 101/113] The recheck scan drops a guard that only added work Re-review approved the per-approval relevance fix and left two smaller things, both about code that asserted more than it enforced. The section-level pre-filter is gone. It was introduced as a cheap short-circuit and was neither: stateOf performs a strict superset of the work the guarded loop does -- the same stalenessOf for every digest, plus collectSharingSections walking every other section and its digests, plus a VerdictMerge and an allocation -- and only digestsByIntent is memoized, so guarding with it could only ever add work to every render. It was dead as well as expensive: deleting it changes no test, because a section is MOVED exactly when one of its hunks is, so "no hunk MOVED" already implies "no verdict MOVED". Calling it cheap was the mistake; the loop now says why there is no guard rather than leaving the next reader to add one back. The decision filter now reads "not CHANGES" instead of naming the approving decisions. The previous commit dropped AUTO_APPROVED on the grounds that nothing stores one, which is true today and enforced nowhere: putVerdict accepts any Decision, and the verdict load path accepts "auto-approved" straight off disk. An enumeration would therefore silently stop asking about an approval the day a new writer, or a hand-edited annotations file, produces one. Inverting it fails toward asking, which is the direction that costs least when wrong: one extra agent run against a human's approval going unexamined with nothing said. Mutating the filter away still fails aRequestedChangesVerdictEarnsNoRecheck, so it has not gone slack. Verified with ./gradlew :app:test --rerun-tasks: 2061 tests, 207 classes, 0 failures in 6m34s -- unchanged, as expected for two changes neither of which was meant to alter behaviour. Co-Authored-By: Claude Opus 5 (1M context) --- .../app/drydock/ui/review/SectionStates.java | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/app/drydock/ui/review/SectionStates.java b/app/src/main/java/app/drydock/ui/review/SectionStates.java index 9c672485..f9e82999 100644 --- a/app/src/main/java/app/drydock/ui/review/SectionStates.java +++ b/app/src/main/java/app/drydock/ui/review/SectionStates.java @@ -386,13 +386,13 @@ void requestRechecks(Board board, RecheckDispatch dispatch) { } Set recordedBases = new LinkedHashSet<>(); for (ReviewIntent intent : counted(board)) { - // Cheap pre-filter ONLY. A section's staleness is the strongest - // thing true of any of its hunks, so a section with no MOVED hunk - // can hold no MOVED verdict either -- but the converse does not - // follow, which is why the real decision is per verdict below. - if (stateOf(board, intent).staleness() != Staleness.MOVED) { - continue; - } + // 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) @@ -404,12 +404,18 @@ void requestRechecks(Board board, RecheckDispatch dispatch) { // 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"; a - // requested-changes verdict is not one, and asking - // about it spends an agent looking for nothing. - // AUTO_APPROVED is not listed because nothing stores - // one -- VerdictMerge derives it for display only. - .filter(verdict -> verdict.decision() == ReviewVerdict.Decision.APPROVED) + // 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 From 0e2107837ca3909f8a7b1b13bc8f3b6e07e6c328 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Tue, 25 Aug 2026 13:48:34 +0200 Subject: [PATCH 102/113] The diag script can finally deliver a key to the Review view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reviewkey has been documented in the explorerScript hook since Task 18 -- "deliver one KeyCode to the Review view, so the ⏎/esc page transitions can be driven" -- and never had a case in the switch. It fell through to the default branch, which prints "[diag] mark ". A driver script asking for reviewkey:A therefore printed a plausible beacon and did nothing at all. Found by using it: the first end-to-end run of the automatic recheck showed a fully loaded Review view sitting at "0/1 hunks reviewed" while the log claimed a key had been delivered. Any earlier visual verification that leaned on this verb proved less than it appeared to -- the same shape as the note about Robot input in diag runs, where the verbs report success and nothing reaches the app. A verb that only reports what it did is not a test. SessionReviewView.diagReviewKey fires a real KeyEvent through the same addEventFilter(KEY_PRESSED) path a physical press takes, rather than calling the settle action directly, so a driver exercises the routing and the focus rules too -- which is the part with no unit coverage. Verified by use, not by assertion: the next run logged "[diag] reviewkey A delivered" and the annotation store gained a real approved verdict stamped with the resolved base commit, where the previous run had produced no store at all. Full suite unaffected: 2061 tests, 207 classes, 0 failures in 6m26s (--rerun-tasks, all 207 result files written by that run). Co-Authored-By: Claude Opus 5 (1M context) --- .../main/java/app/drydock/DrydockApplication.java | 4 ++++ .../main/java/app/drydock/ui/MainWorkspace.java | 12 ++++++++++++ .../app/drydock/ui/review/SessionReviewView.java | 15 +++++++++++++++ 3 files changed, 31 insertions(+) diff --git a/app/src/main/java/app/drydock/DrydockApplication.java b/app/src/main/java/app/drydock/DrydockApplication.java index c2bfd4d7..eb69cf62 100644 --- a/app/src/main/java/app/drydock/DrydockApplication.java +++ b/app/src/main/java/app/drydock/DrydockApplication.java @@ -599,6 +599,10 @@ 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()); // forcebanner:/, or // forcebanner:none for a session that never wrote // a brief, or forcebanner://dead for one diff --git a/app/src/main/java/app/drydock/ui/MainWorkspace.java b/app/src/main/java/app/drydock/ui/MainWorkspace.java index 2e66ee58..2e9aa88c 100644 --- a/app/src/main/java/app/drydock/ui/MainWorkspace.java +++ b/app/src/main/java/app/drydock/ui/MainWorkspace.java @@ -4292,6 +4292,18 @@ 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: 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/review/SessionReviewView.java b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java index 8866a28e..5282e2da 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -30,6 +30,7 @@ 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; @@ -3206,6 +3207,20 @@ 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. + */ + 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); From 1e5cf4aeaf587a199a2790a65dbcb848151af3d5 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Tue, 25 Aug 2026 14:50:33 +0200 Subject: [PATCH 103/113] A claimed ordering is legible, and an agent's staleness says so too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of Task 24 approved the modelling and failed the half the task's own test javadoc calls for: "the distinction has to be visible, not merely modelled." The claimed border was not legible. -drydock-border is rgba(255,255,255,0.08), about 1.26:1 against the rail, so dashing it removed half the ink of a hairline nobody can see -- Task 18's 1.13:1 defect in a new place. I had looked at two snapshots and called it clearly dashed; I was reading enlarged PNGs rather than judging what a reviewer sees. The claimed border now also lifts onto -drydock-text-faint. Neutral on purpose: the reason for not using a hue is that four risk encodings already compete for colour here, and a grey is not a fifth. Nothing pinned the visible half either. Deleting the whole CSS rule, and dropping the warrant from the tooltip, both left every review-UI test green -- they all asserted on style-class STRINGS, and a class nothing renders says nothing. Two tests now read the resolved Border and the tooltip off the live scene, so both deletions fail. Spec §9.7 was unimplemented: "Assessments render as claimed, not measured." A hunk stale because an AGENT asserted the base move disturbed it was pixel-identical to one stale because drydock's own file-level filter found the move -- exactly the confusion §6.5 exists to prevent, and the reviewer's argument for accepting "affected" at all is that it is the agent's judgement closing §9.2's blind spot. SectionState now carries the warrant, set only when an assessment is what made a hunk MOVED, and the chip reads "⚠ agent: base moved — confirm". That string is LONGER than the measured one, so it gets a narrow-width card test: this rail has truncated before. Smaller, all from the same review: the .review-link-row.provenance-claimed selector could never match anything (nothing adds that class to a link row, and by §6.4 a link is measured by construction) and is deleted rather than parked; setIntents' null-coalesce was a dead branch and is a requireNonNull; the convenience constructors' MEASURED default is pinned, because every default in this design points at the MORE trusted value and flipping both survived the suite; and the tooltip naming the warrant on every card is reconciled with the "do not decorate every row" rule in the comment rather than left to look like a contradiction. Four citations pointed at §7.1 for a sentence that is in §8 ("Three sources, one rendering path -- and the first two are marked claimed while the third is marked measured"). The substance held; the justification pointed at the wrong section. Verified with ./gradlew :app:test --rerun-tasks: 2069 tests, 207 classes, 0 failures in 6m29s, from 2061/207. The run before it failed once on ReviewSettleActionsTest.withTheDiffColumnFocusedApproveSettlesOneHunk (expected 1, got 3) -- the focus race characterised at Task 22, verbatim. It passed twice in isolation and did not reproduce on the full re-run, so it is that flake and not a regression from touching the rail. Mutation-verified: deleting the CSS rule, dropping the tooltip label, flipping the constructor defaults, dropping the CLAIMED assignment, and removing the chip's "agent:" prefix are each killed by the test written for them -- the last two at both the model and the screen. Co-Authored-By: Claude Opus 5 (1M context) --- .../drydock/ui/review/ReviewIntentRail.java | 23 ++++- .../app/drydock/ui/review/SectionStates.java | 27 +++++- .../drydock/ui/review/SessionReviewView.java | 2 +- app/src/main/resources/app/drydock/ui/app.css | 23 ++++- .../app/drydock/review/ProvenanceTest.java | 18 ++++ .../ReviewIntentRailCardHeightTest.java | 21 ++++- .../ui/review/ReviewProvenanceTest.java | 93 ++++++++++++++++++- .../drydock/ui/review/SectionStatesTest.java | 52 +++++++++++ 8 files changed, 242 insertions(+), 17 deletions(-) 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 e06008a9..3ee7645d 100644 --- a/app/src/main/java/app/drydock/ui/review/ReviewIntentRail.java +++ b/app/src/main/java/app/drydock/ui/review/ReviewIntentRail.java @@ -30,6 +30,7 @@ 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; @@ -105,7 +106,7 @@ enum Mode { INTENTS, PATH } /** * 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: §7.1's three sources are three sources for + * 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. */ @@ -255,7 +256,7 @@ String message() { void setIntents(List newIntents, String selectedIntentId, Empty reason, Provenance provenance) { this.mode = Mode.INTENTS; - this.provenance = provenance == null ? Provenance.MEASURED : provenance; + this.provenance = Objects.requireNonNull(provenance, "provenance"); this.intents = List.copyOf(newIntents); this.selectedId = selectedIntentId; this.emptyReason = reason == null ? Empty.NONE : reason; @@ -618,7 +619,10 @@ 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). + // 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()); } @@ -747,8 +751,19 @@ protected double computePrefHeight(double width) { // 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) { - Label stale = new Label("⚠ base moved — confirm"); + // 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); } diff --git a/app/src/main/java/app/drydock/ui/review/SectionStates.java b/app/src/main/java/app/drydock/ui/review/SectionStates.java index f9e82999..c8273337 100644 --- a/app/src/main/java/app/drydock/ui/review/SectionStates.java +++ b/app/src/main/java/app/drydock/ui/review/SectionStates.java @@ -5,6 +5,7 @@ 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; @@ -98,13 +99,21 @@ enum Staleness { * @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) { + List settledElsewhere, boolean hunksMissing, + Provenance stalenessProvenance) { SectionState { settledElsewhere = List.copyOf(settledElsewhere); @@ -117,7 +126,8 @@ record SectionState(Optional decision, int settledHunks, * not the same claim. */ static SectionState unknown() { - return new SectionState(Optional.empty(), 0, 0, 0, Staleness.UNKNOWN, List.of(), false); + return new SectionState(Optional.empty(), 0, 0, 0, Staleness.UNKNOWN, List.of(), false, + Provenance.MEASURED); } /** @@ -128,7 +138,8 @@ static SectionState unknown() { * 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); + return new SectionState(Optional.empty(), 0, 0, 0, Staleness.FRESH, List.of(), true, + Provenance.MEASURED); } } @@ -318,6 +329,10 @@ SectionState stateOf(Board board, ReviewIntent 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) { @@ -332,6 +347,10 @@ SectionState stateOf(Board board, ReviewIntent intent) { || (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 @@ -350,7 +369,7 @@ SectionState stateOf(Board board, ReviewIntent intent) { } } return new SectionState(VerdictMerge.derive(perHunk), settled, recorded, digests.size(), - staleness, List.copyOf(elsewhere), false); + staleness, List.copyOf(elsewhere), false, stalenessProvenance); } /** 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 5282e2da..5a4f6ca6 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -1226,7 +1226,7 @@ public void refreshReviewState() { String selectedHunkId = steps.isEmpty() ? null : steps.get(pathIndex).hunkId(); intentRail.showPath(steps, selectedHunkId, emptyReason()); } else { - // Spec §7.1: reads and the agent's array order are both the + // 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), diff --git a/app/src/main/resources/app/drydock/ui/app.css b/app/src/main/resources/app/drydock/ui/app.css index 3af7f37d..a210b054 100644 --- a/app/src/main/resources/app/drydock/ui/app.css +++ b/app/src/main/resources/app/drydock/ui/app.css @@ -3216,11 +3216,24 @@ -fx-cursor: hand; } /* A claimed ordering is the agent's assertion, not drydock's measurement - * (spec §6.5). Dashed rather than coloured: four risk encodings already - * compete for colour on this surface and a fifth would be unreadable. Only - * the claimed case is modified -- marking every row would say nothing. */ -.review-intent-card.provenance-claimed, -.review-link-row.provenance-claimed { + * (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 { diff --git a/app/src/test/java/app/drydock/review/ProvenanceTest.java b/app/src/test/java/app/drydock/review/ProvenanceTest.java index 8cb1e2ca..04f7ca94 100644 --- a/app/src/test/java/app/drydock/review/ProvenanceTest.java +++ b/app/src/test/java/app/drydock/review/ProvenanceTest.java @@ -2,6 +2,8 @@ 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; @@ -17,6 +19,22 @@ void eachWarrantNamesItself() { 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 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 28b18eb5..f6e3628c 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewIntentRailCardHeightTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewIntentRailCardHeightTest.java @@ -112,12 +112,29 @@ void theSettledElsewhereMarkerNamingSeveralSectionsFitsAtNarrowWidth() { narrow(); rail.setSectionStateLookup(intent -> new SectionStates.SectionState( Optional.empty(), 1, 1, 3, SectionStates.Staleness.FRESH, - List.of("①", "②", "③", "④", "⑤"), false)); + 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(); @@ -132,7 +149,7 @@ void theStaleBannerFitsAtNarrowWidth() { narrow(); rail.setSectionStateLookup(intent -> new SectionStates.SectionState( Optional.of(ReviewVerdict.Decision.APPROVED), 2, 2, 2, - SectionStates.Staleness.MOVED, List.of(), false)); + SectionStates.Staleness.MOVED, List.of(), false, Provenance.MEASURED)); showIntents(List.of(intent(1, "guards.h", ReviewIntent.Kind.CHANGE, ""))); assertSaneHeight(cardHeights().get(0)); diff --git a/app/src/test/java/app/drydock/ui/review/ReviewProvenanceTest.java b/app/src/test/java/app/drydock/ui/review/ReviewProvenanceTest.java index 69c3d3d2..a7ff2eb1 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewProvenanceTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewProvenanceTest.java @@ -1,13 +1,27 @@ 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; /** @@ -17,7 +31,7 @@ * 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 §7.1 puts the distinction on the RAIL, which has three order + *

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 @@ -58,6 +72,83 @@ void aPathRowIsNeverMarkedClaimed() { .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(); diff --git a/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java b/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java index 5b9009b4..bdf81793 100644 --- a/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java +++ b/app/src/test/java/app/drydock/ui/review/SectionStatesTest.java @@ -4,6 +4,7 @@ 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; @@ -649,6 +650,57 @@ void digestsForActionWithWholeFileIgnoresTheUnit() { 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) ---------------- /** From 3d03854f46ff20a0b8f0495e907e8fe608624509 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Tue, 25 Aug 2026 16:44:45 +0200 Subject: [PATCH 104/113] An automatic recheck leaves a trace, dispatched or not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of Task 23 noted this path was silent in both directions: SectionStates has no logger, sendToBoundSession returns a boolean nobody records, and a recheck that silently never happened looked exactly like one nobody needed. It is the only dispatch on this surface with no human watching it land, which is precisely the one that cannot afford to be quiet. It now logs which, and names the base pair it was about. Written for the end-to-end run, and immediately earned by it. With a WORKTREE scope (probe vs main), a hunk approved against 055157a, and main advanced to a70cb80 with a commit touching the same src/guards.cpp the scope's diff carries: INFO: Dispatched a recheck for scope rs_MHBKSQE2YR0MQD2EJH (055157aa... -> a70cb800...) That is the first execution of MainWorkspace.dispatchRecheck outside a fake -- subagent support resolved from the bound Claude session, the liveness guard passed, the instruction assembled, and the hand-off returning true against a live terminal. All four were assertions until now; the class has one test in the whole repo and every mutant of this method survives. The rail agreed: "⚠ base moved — confirm" with "approved against base 055157a · base is now a70cb80", and progress fell to 0/1 because a stale hunk stops counting as settled. The chip read "base moved", not "agent: base moved", which is correct -- drydock's own filter found this move -- and incidentally exercised Task 24's new warrant in the same run. Not covered: the agent did not answer (assessments stayed 0). The instruction reaches the terminal; whether Claude acts on it needs MCP permissions granted ahead of time or a human present, the same wall the Task 24 run hit. The drydock half of §9.7 is verified; the agent round-trip is not. Verified with ./gradlew :app:test --rerun-tasks: 2069 tests, 207 classes, 0 failures in 6m28s, 0 result files older than the run start. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/main/java/app/drydock/ui/MainWorkspace.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/drydock/ui/MainWorkspace.java b/app/src/main/java/app/drydock/ui/MainWorkspace.java index 2e9aa88c..6c1a2ca2 100644 --- a/app/src/main/java/app/drydock/ui/MainWorkspace.java +++ b/app/src/main/java/app/drydock/ui/MainWorkspace.java @@ -2330,8 +2330,15 @@ public boolean dispatchRecheck(ReviewScope scope, String fromBase, String toBase if (open == null || open.isProcessExited()) { return false; } - return sendToBoundSession(scope, + 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; } /** From 8748e4544679cbd985a63451a4132d7d2acd62de Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Tue, 25 Aug 2026 17:47:47 +0200 Subject: [PATCH 105/113] A diag verb that does not exist says so, instead of impersonating mark Two of the three script dispatchers ended in `default -> println("[diag] mark " + arg)`, so any verb without a case became a marker: a driver script asking for one printed a plausible beacon and did nothing. That is how `reviewkey` and `comment` sat documented-but-unwired since Task 18, and how a screenshot run that approved nothing looked exactly like one that worked. `settingsScript` already ended in "unknown settings verb"; the other two now match it, and name the verb they did not recognise. `mark` had to become a real case first. It WAS the default, so replacing the default outright would have silently broken every synchronisation marker in every existing driver -- the defect's own mechanism was load-bearing for a legitimate verb. `comment` is wired at the same time, and it was not missing an implementation: ReviewDiffColumn.diagOpenComposer() was already written, routed through ReviewDiagFxThread, returning the anchor it resolved to, with a "no changed line to comment on" fallback -- and it had ZERO callers. Only the case label and two hops stood between it and working, which is a harder gap to notice than an absent method: anyone grepping found a real, careful, well-documented implementation and would reasonably conclude the verb worked. It matters because the composer is opened by a click on a 34px label inside a virtualized cell that the harness cannot aim at, so without this there is no way to drive or photograph a gutter comment at all. Verified by running it, not by reading it -- a fix to an error-reporting path that was only read would be the very mistake it exists to prevent. One script carrying a real mark in each dispatcher, a bogus verb, a one-character typo of a real verb, and a genuine comment: [diag] UNKNOWN tabScript verb 'definitelyNotAVerb' -- nothing was done. [diag] mark from-tabscript [diag] mark a-real-marker [diag] UNKNOWN explorerScript verb 'reviewkeyy' -- nothing was done. [diag] comment -> composer on src/guards.cpp n11 [diag] mark done The typo is the case that matters: under the old default `reviewkeyy` printed "[diag] mark A". The composer was confirmed on screen -- input field anchored "guards.cpp line 11" -- not from that log line. Full suite: 2069 tests, 207 classes, 0 failures in 6m20s, 0 result files older than the run start. Not covered: this makes FUTURE unwired verbs loud, it does not audit the existing ones. Two of the eight verbs I checked were broken; explorerScript has roughly two dozen cases and tabScript its own set, and no test anywhere asserts that a documented verb has a case. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/app/drydock/DrydockApplication.java | 26 +++++++++++++++++-- .../java/app/drydock/ui/MainWorkspace.java | 9 +++++++ .../drydock/ui/review/SessionReviewView.java | 16 ++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/app/drydock/DrydockApplication.java b/app/src/main/java/app/drydock/DrydockApplication.java index eb69cf62..4739d435 100644 --- a/app/src/main/java/app/drydock/DrydockApplication.java +++ b/app/src/main/java/app/drydock/DrydockApplication.java @@ -603,6 +603,10 @@ public CompletableFuture saveOpenChangedFilesInSkim(boolean value) { // 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 @@ -692,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."); } }); } @@ -1530,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); diff --git a/app/src/main/java/app/drydock/ui/MainWorkspace.java b/app/src/main/java/app/drydock/ui/MainWorkspace.java index 6c1a2ca2..58e42988 100644 --- a/app/src/main/java/app/drydock/ui/MainWorkspace.java +++ b/app/src/main/java/app/drydock/ui/MainWorkspace.java @@ -4299,6 +4299,15 @@ 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( 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 5a4f6ca6..4fc2916b 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -3216,6 +3216,22 @@ Optional diagSelectedChipText() { * "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)); From 9527abe373b3bce975d2af3056b34c81749c9d15 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Tue, 25 Aug 2026 18:06:03 +0200 Subject: [PATCH 106/113] A documented diag verb without a case fails the build, not a screenshot run The dispatchers now name an unrecognised verb at runtime, which only helps somebody who runs it. `reviewkey` was documented from Task 18 and unwired for two months precisely because nobody did. This asserts it at build time: every verb the DrydockApplication comments advertise has a case that implements it. One-directional on purpose. 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. The test found a bug in itself before it found anything else. It reported `tslider` as unwired; `tslider` is wired, through `case "uislider", "tslider" ->`, and a bare `case "x"` match cannot see a multi-label case. Trusting it would have meant "fixing" working code. It now collects every literal in a `case ... ->` label. It was then silently blind twice, which is worth recording because it is the same shape as the defect it exists to catch: a verb the parser misses is not reported, it is simply never checked. The prose anchor never matched -- the sentence ends `:saved". Verbs:`, not `steps. Verbs:` -- so the explorer block contributed nothing. With that fixed, line-wrapped entries were still skipped: `type` ends one line and `(insert ...)` begins the next, so a contiguous "type (" never appears. The block is unwrapped before matching now. Both fixes are mutation-verified, not assumed: unwiring `reviewkey` fails the test, and so does renaming BOTH `case "type"` labels -- one alone does not, since two dispatchers implement it, which is itself a reminder that a mutant has to be confirmed rather than assumed. It parses 25 verbs. A guard fails if it ever parses none, so a comment-format change breaks the build instead of quietly disabling the check. Not covered: this catches DOCUMENTED-but-unwired. It would not have caught `comment`, whose implementation ReviewDiffColumn.diagOpenComposer() was fully written with zero callers and which no source comment ever advertised -- an unreachable implementation is a different shape, and would need a check that every diag hook has a caller. Full suite: 2071 tests, 208 classes, 0 failures in 6m29s, 0 result files older than the run start. Co-Authored-By: Claude Opus 5 (1M context) --- .../app/drydock/DiagVerbsAreWiredTest.java | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 app/src/test/java/app/drydock/DiagVerbsAreWiredTest.java 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; + } +} From 9236b144dabd46cb31dda833268813bf0aad5eb5 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Wed, 26 Aug 2026 15:29:48 +0200 Subject: [PATCH 107/113] Log Scene focus state on the CI-only settle-unit failure withTheDiffColumnFocusedApproveSettlesOneHunk has failed the same way on both CI runs -- expected 1, got 3, i.e. the whole section settled instead of one hunk -- but passes every local run, isolated and full suite. That shape means settleUnit() read SECTION instead of HUNK, which only happens if Scene focus never actually landed inside the diff column after focusDiffColumn()'s click. diagFocusSnapshot() reports the live focus owner, whether it's inside diffColumn, and settleUnit()'s reading; the test now captures it right after the click and right after the key press and folds both into the assertion message, so the next CI failure's report shows what focus actually was instead of just the wrong count. --- .../drydock/ui/review/SessionReviewView.java | 20 +++++++++++++++++++ .../ui/review/ReviewSettleActionsTest.java | 5 ++++- 2 files changed, 24 insertions(+), 1 deletion(-) 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 4fc2916b..f4d19335 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -3281,6 +3281,26 @@ SectionStates.SectionState diagSectionState(int index) { }); } + /** + * 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=" + (owner == null ? "none" : owner.getClass().getSimpleName()) + + " inDiffColumn=" + isDescendantOf(owner, diffColumn); + }); + } + /** Diagnostic-only: the current rail's intent ids, in rendered order. */ List diagIntentIds() { return ReviewDiagFxThread.call(() -> intents().stream().map(ReviewIntent::id).toList()); diff --git a/app/src/test/java/app/drydock/ui/review/ReviewSettleActionsTest.java b/app/src/test/java/app/drydock/ui/review/ReviewSettleActionsTest.java index 5f59e4a5..b68ea521 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewSettleActionsTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewSettleActionsTest.java @@ -30,10 +30,13 @@ void withTheRailFocusedApproveSettlesTheWholeSection() { @Test void withTheDiffColumnFocusedApproveSettlesOneHunk() { focusDiffColumn(); + String afterFocus = view.diagFocusSnapshot(); press(KeyCode.A).release(KeyCode.A); WaitForAsyncUtils.waitForFxEvents(); + String afterPress = view.diagFocusSnapshot(); - assertEquals(1, view.diagSectionState(0).settledHunks()); + assertEquals(1, view.diagSectionState(0).settledHunks(), + () -> "after focusDiffColumn(): " + afterFocus + " | after a-press: " + afterPress); } @Test From a33ebac369bf136de052eabe012adadc7ffd8b9a Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Wed, 26 Aug 2026 16:09:40 +0200 Subject: [PATCH 108/113] Close the async graph-build race that flaked ReviewSettleActionsTest on CI diagFocusSnapshot() (previous commit) showed the CI failure's real shape: focus was never inside the diff column at all, even immediately after focusDiffColumn()'s click and before any key press. Root cause: showing a diff kicks off a ChangeGraph build on a background executor (SessionReviewView#requestGraph). Its completion -- success or failure -- runs refreshReviewState() from the FX thread, which rebuilds the rail's cards and touches the diff column. The shared ReviewViewFixture's @BeforeEach never waited for that build to settle before handing control to a test method, so every test built on it raced an async computation that has nothing to do with what it was testing. On the CI runner the build apparently finishes right as the test's click lands; locally it never has, in any run tried, isolated or full-suite. diagGraphBuildPending(scopeId) exposes the in-flight state; the fixture now waits for it to clear (and drains the resulting FX events) before returning from showBoard(), closing the race for every test built on this fixture -- ReviewSettleActionsTest, ReviewPathModeTest, ReviewProvenanceTest, ReviewRecheckDispatchTest -- not just the one that happened to expose it on CI. Full local suite passes. --- .../app/drydock/ui/review/SessionReviewView.java | 15 +++++++++++++++ .../app/drydock/ui/review/ReviewViewFixture.java | 16 +++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) 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 f4d19335..9624196f 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -3281,6 +3281,21 @@ SectionStates.SectionState diagSectionState(int index) { }); } + /** + * 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: what {@link #settleUnit()} would read right now, and * the Scene focus state it derives that from -- for a test to log when a diff --git a/app/src/test/java/app/drydock/ui/review/ReviewViewFixture.java b/app/src/test/java/app/drydock/ui/review/ReviewViewFixture.java index f448cdf0..58ed6e49 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewViewFixture.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewViewFixture.java @@ -26,6 +26,8 @@ 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 @@ -80,7 +82,7 @@ public void start(Stage stage) { * standard TestFX lifecycle -- {@link #start} runs once, not per test). */ @BeforeEach - void showBoard() { + 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())); @@ -100,6 +102,18 @@ void showBoard() { 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 From a5643b4382bf1511ca36518498b409901a360b53 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Wed, 26 Aug 2026 16:45:19 +0200 Subject: [PATCH 109/113] Wait for the actual postcondition after focusDiffColumn's click, not a fixed drain count The previous fix (waiting for the async ChangeGraph build to settle before a test starts) was real but not the cause: the next CI run failed identically -- focus never inside the diff column, even immediately after the click, before any key press -- proving the race isn't with that background build at all. The real mechanism: focusDiffColumn's clickOn is a real TestFX robot press. Monocle turns it into an FX MouseEvent on its own schedule, off the calling thread. A single waitForFxEvents() after the click only waits for whatever was ALREADY queued when it's called -- if the click's translated event hasn't been posted yet, it returns having waited for nothing. That explains why some tests using focusDiffColumn() passed on CI and others didn't: the passing ones happened to have a second, logically redundant waitForFxEvents() later that incidentally covered the gap. diagFocusInDiffColumn() exposes the real postcondition -- Scene focus actually inside diffColumn -- and focusDiffColumn() now polls it with a bounded wait instead of guessing how many drains are enough. Full local suite passes. --- .../drydock/ui/review/SessionReviewView.java | 17 +++++++++++++++++ .../ui/review/ReviewSettleActionsTest.java | 10 ++++++---- .../drydock/ui/review/ReviewViewFixture.java | 18 ++++++++++++++++-- 3 files changed, 39 insertions(+), 6 deletions(-) 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 9624196f..41d46810 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -3296,6 +3296,23 @@ 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 diff --git a/app/src/test/java/app/drydock/ui/review/ReviewSettleActionsTest.java b/app/src/test/java/app/drydock/ui/review/ReviewSettleActionsTest.java index b68ea521..9e265fcd 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewSettleActionsTest.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewSettleActionsTest.java @@ -6,6 +6,8 @@ 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; @@ -28,7 +30,7 @@ void withTheRailFocusedApproveSettlesTheWholeSection() { } @Test - void withTheDiffColumnFocusedApproveSettlesOneHunk() { + void withTheDiffColumnFocusedApproveSettlesOneHunk() throws TimeoutException { focusDiffColumn(); String afterFocus = view.diagFocusSnapshot(); press(KeyCode.A).release(KeyCode.A); @@ -40,7 +42,7 @@ void withTheDiffColumnFocusedApproveSettlesOneHunk() { } @Test - void shiftApproveSettlesEveryHunkOfTheCurrentFile() { + void shiftApproveSettlesEveryHunkOfTheCurrentFile() throws TimeoutException { focusDiffColumn(); press(KeyCode.SHIFT).press(KeyCode.A).release(KeyCode.A).release(KeyCode.SHIFT); WaitForAsyncUtils.waitForFxEvents(); @@ -111,7 +113,7 @@ void withAGutterSelectionOpenApproveSettlesTheSelectedHunkNotTheAnchor() { * was written for is worse than no test. */ @Test - void theBarNamesTheUnitAnActionWillHit() { + void theBarNamesTheUnitAnActionWillHit() throws TimeoutException { focusRail(); assertEquals("Approve (section)", approveButtonText()); @@ -134,7 +136,7 @@ void theBarNamesTheUnitAnActionWillHit() { * moves focus and could not have caught the bug. */ @Test - void aRealMousePressCapturesTheUnitBeforeTheFocusChangeItCauses() { + void aRealMousePressCapturesTheUnitBeforeTheFocusChangeItCauses() throws TimeoutException { focusDiffColumn(); assertEquals("Approve (next unread hunk)", approveButtonText()); diff --git a/app/src/test/java/app/drydock/ui/review/ReviewViewFixture.java b/app/src/test/java/app/drydock/ui/review/ReviewViewFixture.java index 58ed6e49..e39c427c 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewViewFixture.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewViewFixture.java @@ -137,10 +137,24 @@ final void focusRail() { WaitForAsyncUtils.waitForFxEvents(); } - /** A plain click into the diff column -- see {@link #focusRail}. */ - final void focusDiffColumn() { + /** + * A plain click into the diff column -- see {@link #focusRail}. + * + *

Polls {@link SessionReviewView#diagFocusInDiffColumn()} after the + * click rather than trusting one {@code waitForFxEvents()}: {@code + * clickOn} is a real TestFX robot press, and Monocle turns it into an FX + * {@code MouseEvent} on its own schedule, off this thread -- a single + * drain only waits for whatever was ALREADY queued when it is called, + * not for a click event that has not landed in the queue yet. That gap + * is what let this method silently return with focus still outside the + * diff column on CI, every run, while never once reproducing locally + * (see the CI-only failure {@code diagFocusSnapshot} was added to + * diagnose).

+ */ + final void focusDiffColumn() throws TimeoutException { clickOn(".review-diff-cell"); WaitForAsyncUtils.waitForFxEvents(); + WaitForAsyncUtils.waitFor(5, TimeUnit.SECONDS, view::diagFocusInDiffColumn); } /** How many hunks {@link #FILE_A} has -- what {@code ⇧A}/{@code ⇧R} settle. */ From 930bf584dc33c4c8a892ec035863f8c196890128 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Wed, 26 Aug 2026 17:22:00 +0200 Subject: [PATCH 110/113] TEMPORARY: log whether the diff-column click even reaches production code on CI The postcondition-wait fix proved the click's focus effect never happens on CI within 5s -- not a timing issue, since a real wait now times out instead of racing. That rules out "just needs more time" but not WHERE in the pipeline it breaks: TestFX's Robot-driven click could be failing to reach the FX layer at all, or reaching it and getting diverted before settling on the diff column. Two temporary diagnostics to tell those apart: - ReviewDiffColumn's MOUSE_PRESSED filter on `list` now logs its target and scene coordinates whenever it fires -- confirms whether the click physically reaches production code on CI at all. - ReviewViewFixture.focusDiffColumn() logs every ".review-diff-cell" match's empty/visible/bounds state before calling clickOn, since clickOn(String) picks whichever match lookup() returns first and a virtualized ListView's cell pool holds empty placeholder cells alongside populated ones. Confirmed locally that an empty-cell target is not itself the problem: the filter lives on `list`, not the individual cell, so it fires (and the test passes) even when clickOn happens to land on an empty placeholder row. Both remain temporary -- remove once CI shows what the CI-only case actually does differently. --- .../app/drydock/ui/review/ReviewDiffColumn.java | 11 ++++++++++- .../app/drydock/ui/review/ReviewViewFixture.java | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) 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 82503b73..2f35ed5e 100644 --- a/app/src/main/java/app/drydock/ui/review/ReviewDiffColumn.java +++ b/app/src/main/java/app/drydock/ui/review/ReviewDiffColumn.java @@ -372,7 +372,16 @@ private void clearSelectionAnchor() { // 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 -> list.requestFocus()); + list.addEventFilter(MouseEvent.MOUSE_PRESSED, e -> { + // TEMPORARY: investigating a CI-only failure where a TestFX + // clickOn(".review-diff-cell") never lands real focus here (see + // SessionReviewView#diagFocusInDiffColumn's javadoc). 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. diff --git a/app/src/test/java/app/drydock/ui/review/ReviewViewFixture.java b/app/src/test/java/app/drydock/ui/review/ReviewViewFixture.java index e39c427c..07f0f27a 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewViewFixture.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewViewFixture.java @@ -12,6 +12,7 @@ import javafx.scene.Node; import javafx.scene.Scene; import javafx.scene.control.Label; +import javafx.scene.control.ListCell; import javafx.stage.Stage; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -152,6 +153,19 @@ final void focusRail() { * diagnose).

*/ final void focusDiffColumn() throws TimeoutException { + // TEMPORARY: investigating the same CI-only failure the class javadoc + // above describes -- logs every ".review-diff-cell" match's empty/ + // visible/bounds state BEFORE the click, since clickOn(String) picks + // whichever match lookup() returns first, and a virtualized + // ListView's cell pool can hold empty or off-screen cells alongside + // populated ones. Remove once the investigation closes. + 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())); + })); clickOn(".review-diff-cell"); WaitForAsyncUtils.waitForFxEvents(); WaitForAsyncUtils.waitFor(5, TimeUnit.SECONDS, view::diagFocusInDiffColumn); From a26e219a3e48ad3e90a33cad7bdb9496c5150640 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Wed, 26 Aug 2026 18:27:49 +0200 Subject: [PATCH 111/113] Remove the temporary click diagnostics now that CI is green CI run 32986189657 (workflow_dispatch retry after an isolated GitHub Actions hiccup stuck the normal run queued with no runner for 20+ minutes) ran app:test clean: all 2123+ tests passed, including ReviewSettleActionsTest. The two real fixes -- waiting for the async ChangeGraph build to settle (diagGraphBuildPending) and polling the actual focus postcondition after focusDiffColumn's click (diagFocusInDiffColumn) -- resolved it. The single TimeoutException seen on the previous commit's run most likely landed during that same patch of CI degradation, pushing an already-marginal click delay past the 5s wait. Removing the two prints that were only ever meant to localize where in the pipeline the click was failing (production filter fire, and pre-click cell-pool state) -- their job is done. Keeping diagFocusSnapshot as a lasting diag accessor: cheap, and exactly the kind of thing worth having on hand if a different focus race ever surfaces. --- .../app/drydock/ui/review/ReviewDiffColumn.java | 11 +---------- .../app/drydock/ui/review/ReviewViewFixture.java | 14 -------------- 2 files changed, 1 insertion(+), 24 deletions(-) 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 2f35ed5e..82503b73 100644 --- a/app/src/main/java/app/drydock/ui/review/ReviewDiffColumn.java +++ b/app/src/main/java/app/drydock/ui/review/ReviewDiffColumn.java @@ -372,16 +372,7 @@ private void clearSelectionAnchor() { // 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 - // clickOn(".review-diff-cell") never lands real focus here (see - // SessionReviewView#diagFocusInDiffColumn's javadoc). 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.addEventFilter(MouseEvent.MOUSE_PRESSED, e -> list.requestFocus()); list.setCellFactory(view -> new DiffCell()); // Long lines wrap; the column never scrolls sideways. See // viewportWidth for what this replaces. diff --git a/app/src/test/java/app/drydock/ui/review/ReviewViewFixture.java b/app/src/test/java/app/drydock/ui/review/ReviewViewFixture.java index 07f0f27a..e39c427c 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewViewFixture.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewViewFixture.java @@ -12,7 +12,6 @@ import javafx.scene.Node; import javafx.scene.Scene; import javafx.scene.control.Label; -import javafx.scene.control.ListCell; import javafx.stage.Stage; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -153,19 +152,6 @@ final void focusRail() { * diagnose).

*/ final void focusDiffColumn() throws TimeoutException { - // TEMPORARY: investigating the same CI-only failure the class javadoc - // above describes -- logs every ".review-diff-cell" match's empty/ - // visible/bounds state BEFORE the click, since clickOn(String) picks - // whichever match lookup() returns first, and a virtualized - // ListView's cell pool can hold empty or off-screen cells alongside - // populated ones. Remove once the investigation closes. - 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())); - })); clickOn(".review-diff-cell"); WaitForAsyncUtils.waitForFxEvents(); WaitForAsyncUtils.waitFor(5, TimeUnit.SECONDS, view::diagFocusInDiffColumn); From d9277bdf228931d3a6dba4d04f0f29bac1f69f8f Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Wed, 26 Aug 2026 20:10:16 +0200 Subject: [PATCH 112/113] Try moveTo+press+release for the diff-column click; restore diagnostics The previous claim that this was fixed was wrong: it was one green run among many failures, and removing the temporary diagnostics right before it failed again lost the observability that mattered most. Restoring both prints, and adding one more: a TimeoutException from focusDiffColumn now carries diagFocusSnapshot() instead of a bare message, since a plain timeout says only "it never happened." Also trying a real, evidence-based change rather than another blind wait tweak: withAGutterSelectionOpenApproveSettlesTheSelectedHunkNotTheAnchor is the one test in this class using moveTo() + a separate press() (on the gutter, not clickOn) and has never once failed on CI across every run so far, while every test going through the compound clickOn(".review-diff-cell") has. focusDiffColumn now uses the same moveTo+press+release shape. Not confirmed as the fix -- needs CI, and this time won't be declared resolved off a single green run given the history here. --- .../drydock/ui/review/ReviewDiffColumn.java | 12 ++++- .../drydock/ui/review/ReviewViewFixture.java | 52 ++++++++++++++----- 2 files changed, 51 insertions(+), 13 deletions(-) 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 82503b73..bb1036f1 100644 --- a/app/src/main/java/app/drydock/ui/review/ReviewDiffColumn.java +++ b/app/src/main/java/app/drydock/ui/review/ReviewDiffColumn.java @@ -372,7 +372,17 @@ private void clearSelectionAnchor() { // 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 -> list.requestFocus()); + 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. diff --git a/app/src/test/java/app/drydock/ui/review/ReviewViewFixture.java b/app/src/test/java/app/drydock/ui/review/ReviewViewFixture.java index e39c427c..4dc3520e 100644 --- a/app/src/test/java/app/drydock/ui/review/ReviewViewFixture.java +++ b/app/src/test/java/app/drydock/ui/review/ReviewViewFixture.java @@ -12,6 +12,8 @@ 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; @@ -140,21 +142,47 @@ final void focusRail() { /** * A plain click into the diff column -- see {@link #focusRail}. * - *

Polls {@link SessionReviewView#diagFocusInDiffColumn()} after the - * click rather than trusting one {@code waitForFxEvents()}: {@code - * clickOn} is a real TestFX robot press, and Monocle turns it into an FX - * {@code MouseEvent} on its own schedule, off this thread -- a single - * drain only waits for whatever was ALREADY queued when it is called, - * not for a click event that has not landed in the queue yet. That gap - * is what let this method silently return with focus still outside the - * diff column on CI, every run, while never once reproducing locally - * (see the CI-only failure {@code diagFocusSnapshot} was added to - * diagnose).

+ *

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 { - clickOn(".review-diff-cell"); + // 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(); - WaitForAsyncUtils.waitFor(5, TimeUnit.SECONDS, view::diagFocusInDiffColumn); + 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. */ From e83497539aae5f49d1599ba28451c8106e3d9f9b Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Wed, 26 Aug 2026 20:49:38 +0200 Subject: [PATCH 113/113] Identify exactly which focus owner steals the diff-column click The previous run's diagnostics (moveTo+press+release, commit d9277bd) cut ReviewSettleActionsTest CI failures from 3 to 1, and proved something important: the MOUSE_PRESSED filter fired and list.requestFocus() ran for all four diff-column clicks in that run -- including the one that still timed out. All four produced a "[diag] review-diff-list MOUSE_PRESSED" line, with the failing one landing on a populated cell, same as the passing ones. So this is not a click-delivery failure; something downstream hands focus to a Button afterward, and "focusOwner=Button" alone never said which one. diagFocusSnapshot now describes the focus owner as SimpleClassName[#id][.styleClass...]("text") via diagDescribe, plus its full ancestor chain via diagAncestorChain -- enough to trace back to the exact code path that grabs focus, on the next CI failure. --- .../drydock/ui/review/SessionReviewView.java | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) 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 41d46810..2c0df167 100644 --- a/app/src/main/java/app/drydock/ui/review/SessionReviewView.java +++ b/app/src/main/java/app/drydock/ui/review/SessionReviewView.java @@ -3328,11 +3328,42 @@ String diagFocusSnapshot() { return ReviewDiagFxThread.call(() -> { Node owner = getScene() == null ? null : getScene().getFocusOwner(); return "settleUnit=" + settleUnit() - + " focusOwner=" + (owner == null ? "none" : owner.getClass().getSimpleName()) - + " inDiffColumn=" + isDescendantOf(owner, diffColumn); + + " 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());