Skip to content

Document the codebase, and fix six things reading it turned up - #15

Open
siegfriedpammer wants to merge 8 commits into
mainfrom
docs-and-correctness-fixes
Open

siegfriedpammer wants to merge 8 commits into
mainfrom
docs-and-correctness-fixes

Conversation

@siegfriedpammer

Copy link
Copy Markdown
Member

Six documents under docs/, one per layer, and the correctness fixes that
came out of writing them.

The documentation

docs/README.md indexes the rest. Written to be read cold, and to explain
why rather than what - the code's own comments are good, but they cannot
be read in an order.

Document Covers
architecture.md the four projects, the layering, the five decisions that shape everything, where state lives on disk, environment switches, build and test
review-session.md ReviewWorkspace / ReviewScopes / ReviewComments / startup
git-and-diff.md the git plumbing, the two diff representations, folding vs context gaps, the process runner and the log
pull-request-hosts.md IPullRequestHost and its two implementations, PrCache, CommentAnchor, ReviewStateStore, the merge queue
semantics.md ISemanticProvider, the Roslyn workspace, the LSP client, server discovery and bootstrap, decompilation, the test parsers
ui.md docking, documents, panes, the AvaloniaEdit extension points, syntax painting, the keyboard model, the screenshot protocol

Every build and test command quoted in them was run.

The fixes

One commit each, all reviewable on their own.

A path outside the tree answered as though it were inside it. Each
semantic provider carried its own copy of the repo-relative/absolute mapping
and the two had drifted: the language-server one has no separator check, so
/repo-other/x.py read as being inside /repo and came back as
-other/x.py. Every reference hit and navigation target in a sibling
directory whose name starts with the worktree's went there. It also compared
case-insensitively on every platform, where only Windows does. One tested
helper now; both providers forward to it.

A language-server request had no deadline. The callers that ask are the
ones with nothing to cancel them - go to definition, hover, references all
pass CancellationToken.None from the UI thread - so a server that stopped
answering took those commands down for as long as the review stayed open,
silently. And when the server ended, the read loop broke out and left every
outstanding request waiting forever, because nothing completed them. Thirty
seconds for a question about code, two minutes for the handshake (npx
downloads the server on first use), and no deadline for loadBase, whose work
is the waiting.

A refusal was not answered where a failure was. RefusedException and
ToolFailedException mean the same thing to a caller, and the comment said so
while the hierarchy did not. Opening a fork PR on Azure DevOps reported itself
as a crash dialog; the offline snapshot path skipped its fallback; the merge
queue abandoned a turn with its lock still held. Making it a
ToolFailedException fixes all three, and the ninety-odd existing catch
clauses become right by construction.

Three sharp edges where one caller was careful and its neighbour was not:
the unified comment-thread splice indexed past its text where the
side-by-side one guards, two of the three short-SHA truncations in the
worktree cache slice blindly, and generated sources diff against /dev/null
on Windows - so a generator whose output a change adds or removes showed
nothing there.

A symbol was resolved against an asserted workspace. A base-side view
takes the head's, and a head whose load failed part-way can leave a solution
without one - which is exactly when a reader starts pressing F12.

Plus one dead public method with no call site anywhere, one dead local, and
two doc comments that sat a member too high.

Not in here

The structural work the same read turned up - ReviewWorkspace at 2802 lines
and nine responsibilities, DiffDocumentView at 1270, GitService at 1040,
and the duplication between the unified and side-by-side views - is a separate
proposal, sequenced so each step ships on its own. Putting it in this PR would
have made it unreviewable.

Testing

dotnet build clean, 0 warnings. 348 tests, 346 passing, 2 skipped (the
basedpyright bootstrap, and one Windows-only path case) in ~18s. Six new test
cases cover the path helper and the splice guard.

🤖 Generated with Claude Code

Everything non-obvious about this tool lived in two places: the comments in
the code, which are good but cannot be read in an order, and CLAUDE.md, which
is an orientation rather than a reference. Someone picking up the semantic
layer or the host layer cold had to reconstruct the shape of it by reading
every file in it.

Six documents under docs/, one per layer, written to be read cold and to
explain why rather than what: the two diff representations and why they
disagree on purpose, why a symbol is a file plus a position, why the base-side
workspace is derived rather than loaded, why the merge queue pushes without
--force, why folds and context gaps are kept apart. The build and test
commands in them were run.

Claude-Session: https://claude.ai/code/session_01EgJ3xXgcismuaFmKhF6KD9
Assisted-by: Claude:claude-opus-5:Claude Code
GetIdentifierSpansAsync is public, is not on ISemanticProvider, and has no
call site anywhere in src or tests: the clickable reference segments it was
written for are built from the classified tokens instead, and it duplicated
the filtering half of that. A public method nobody calls reads as a thing to
keep working.

The unused local in TestRunComparison goes the same way - it names a question
("was this test present at base?") the comparison decided not to ask, and
leaving it suggests the answer is used somewhere.

Claude-Session: https://claude.ai/code/session_01EgJ3xXgcismuaFmKhF6KD9
Assisted-by: Claude:claude-opus-5:Claude Code
Both sat one member too high, stacked above another summary: the quick-info
line above GetDocumentTextAsync, and the one about walking up to a member
above MemberAtPosition, which decides something else entirely. Each described
the member below its neighbour, and each left that neighbour undocumented.

Nothing reads them but a person reading the file, which is exactly who they
mislead.

Claude-Session: https://claude.ai/code/session_01EgJ3xXgcismuaFmKhF6KD9
Assisted-by: Claude:claude-opus-5:Claude Code
Each semantic provider carried its own copy of the mapping between the
repo-relative paths git speaks and the absolute ones it reports, and the two
had drifted apart. The Roslyn one requires a separator after the root, with a
comment saying why; the language-server one, written later, does not - so
/repo-other/x.py read as being inside /repo and came back as "-other/x.py",
which names nothing. Every reference hit and navigation target in a sibling
directory whose name starts with the worktree's went there.

It also compared without regard to case on every platform, where only Windows
does that, so two files of one tree differing only in case answered for each
other.

One helper now, with the reasoning on it and tests of its own, and both
providers forward to it. A third provider gets the guards for free, which is
the half of this that was actually costing something.

Claude-Session: https://claude.ai/code/session_01EgJ3xXgcismuaFmKhF6KD9
Assisted-by: Claude:claude-opus-5:Claude Code
RefusedException and ToolFailedException mean the same thing to a caller: it
did not happen, and the message says why. The class comment said so, and the
type hierarchy did not - so a refusal walked past the catch clause written for
the failure beside it and reached the reader as a crash dialog.

Three places did that. Opening a pull request from a fork on Azure DevOps,
which is refused by design, reported itself as an unhandled exception. The
offline snapshot path skipped its fallback entirely, because the refusal never
reached the catch that reads the cache. And the merge queue abandoned a turn
with its lock still held, to be recovered only when the lease ran out five
minutes later.

Making it a ToolFailedException fixes all three at once, and the ninety-odd
existing catch clauses become right by construction rather than by each author
remembering. A caller that does want to tell the two apart still can, by
catching the refusal first - which is what the rebase does, and the compiler
now insists on that order, which is how the one place that had it backwards
was found.

Claude-Session: https://claude.ai/code/session_01EgJ3xXgcismuaFmKhF6KD9
Assisted-by: Claude:claude-opus-5:Claude Code
…aiting

A request waited for as long as the server took, and the callers that ask are
the ones with nothing to cancel them: go to definition, hover, references and
the rest all pass CancellationToken.None from the UI thread. A server that
stopped answering therefore took those commands down for as long as the review
stayed open, silently, with no way back but restarting the application.

Worse, when the server ended - crashed, killed, output closed - the read loop
broke out and left every outstanding request waiting forever, because nothing
completed them. The one case where the tool knows the answer is never coming
was the case where it waited hardest.

So: thirty seconds for a question about code, two minutes for the handshake
(a server installed through npx downloads itself on first use, and the
handshake is what waits for that), and no deadline at all for loadBase, whose
work IS the waiting - it answers once the server's own solution has loaded,
and giving up would abandon a base side that was going to arrive. Giving up
answers with nothing rather than throwing, because that is what a server error
already does here and what every caller is written for; the log carries the
difference between "found nothing" and "never answered".

The read loop now fails what it leaves behind, and gives each message's
JsonDocument back to the pool instead of dropping one per message.

Claude-Session: https://claude.ai/code/session_01EgJ3xXgcismuaFmKhF6KD9
Assisted-by: Claude:claude-opus-5:Claude Code
Each of these is a place where one caller was careful and its neighbour was
not, so the careful one hid the bug until something reached the other.

Splicing a comment thread into a unified document indexed the text by the tag
count, while the side-by-side splice beside it allows for a model carrying one
more tag than its text has lines. The guard belongs in both or neither.

The worktree cache abbreviates a revision to nine characters. One of the three
places that does it checks the length first; the other two slice blindly, and
a scope carries whatever revision resolved it - including an abbreviated one.

Generated sources diff a side the other does not have against /dev/null, which
on Windows is a path that is not there, so a generator whose output a change
adds or removes showed nothing at all.

Claude-Session: https://claude.ai/code/session_01EgJ3xXgcismuaFmKhF6KD9
Assisted-by: Claude:claude-opus-5:Claude Code
SymbolFinder is asked in terms of a Workspace, and the two call sites asserted
there was one rather than checking. A view derived for the base side takes the
head's workspace, and a head whose load failed part-way can leave a solution
behind without one - which is exactly the state where a reader starts pressing
F12 to find out what is wrong.

Answering nothing is what both callers already do for a file the workspace
does not hold, so there is nothing new to handle; the alternative was taking
the pane down.

Claude-Session: https://claude.ai/code/session_01EgJ3xXgcismuaFmKhF6KD9
Assisted-by: Claude:claude-opus-5:Claude Code
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant