diff --git a/docs/10kftarchitecture.md b/docs/10kftarchitecture.md new file mode 100644 index 0000000..eebc793 --- /dev/null +++ b/docs/10kftarchitecture.md @@ -0,0 +1,190 @@ +# Architecture + +Stampeded! is a code-review tool, and a review is a question about a *change*: two commits and +the difference between them. Everything in the architecture follows from where that change +lives. It lives in git, so git sits in the middle. Everything else is a source of *context* +around it: the pull-request host knows which commits and what people said about them, a +language service knows what the code means, and the rest is presentation. + +## The big picture + +```mermaid +flowchart TB + subgraph ui["Stampeded! (Avalonia desktop app)"] + panes["Panes and documents
diff, blame, structure, checks, tests, comments, log"] + rw["ReviewWorkspace
one open review: base SHA, head SHA, file diffs"] + panes --> rw + end + + subgraph host["Pull-request host  -  IPullRequestHost"] + direction LR + gh["GitHub
gh"] + az["Azure DevOps
az repos pr, az devops invoke"] + end + + subgraph git["git  -  the change itself"] + direction LR + odb["object database
blobs at any revision, diff, blame, log"] + wt["detached review worktree
a checkout of the head (and base) commit"] + end + + subgraph sem["Language semantics  -  ISemanticProvider"] + direction LR + roslyn["Roslyn in process
C#"] + rlsp["Stampeded.RoslynLsp
C#, out of process"] + py["pyright / basedpyright
Python"] + end + + rw -- "which PR: number, branches,
description, checks, comments, reviews" --> host + host -- "head refspec and base branch" --> git + rw -- "the diff, blame, history,
content of both sides" --> git + git -- "a checkout to read" --> sem + rw -- "go to definition, references,
hover, outline, folds" --> sem +``` + +Read it top to bottom. The app asks the host *which* commits a pull request is; git fetches +them and produces the diff; the language services read a checkout git made. The host never +sees the code, and git never sees the pull request: each layer answers only the question it is +the authority for. + +## Why git is in the middle + +The pull-request host is asked for the things only it knows: the open list, a description, +review comments, check runs, the merge state, and which commit is the head of a pull request. +It is never asked for file content. Once the two SHAs are known, the change is read from the +local object database: `git diff` for the file list, `git show` for either side of a file, +`git blame` and `git log` for history. That has consequences that shape the rest: + +- **Both sides of a diff are real code.** The base side is not a rendered patch; it is a blob + git handed over, so removed code gets the same navigation as added code. +- **A review survives losing the host.** What the host said is cached under + `~/.cache/stampeded/prs`. When `gh` or `az` fails and the commits are still in the object + database, the review opens from the cache, says it is offline, and refuses only what needs + the host: a verdict or a merge. +- **The user's checkout is never touched.** Reads come from the object database. Anything + that needs files on disk (a language server, a test run) gets a *detached* worktree under + `~/.cache/stampeded/worktrees`, so the branch under review is never checked out by the + tool. Operations that must move a branch (rebase, pull) first ask git which checkout holds + it and run there. + +## Two hosts, one vocabulary + +`IPullRequestHost` is decided once per repository, from the URL of `origin`: an Azure DevOps +URL picks `AzureDevOpsService`, anything else is GitHub because `gh` also serves GitHub +Enterprise hosts that nothing could enumerate. `STAMPEDED_PR_HOST` overrides the guess. + +Above the interface, the model speaks GitHub's words (`APPROVE`, `REQUEST_CHANGES`, +`MERGEABLE`, `LEFT` / `RIGHT`). Azure DevOps counts votes from 10 to -10 and has no review +object at all, and that translation happens inside its implementation and nowhere else. A pane +learns which host answered only from `HostName`, so a new host is a third implementation, not a +change to the review. + +## Languages come through one interface, from two kinds of process + +```mermaid +flowchart LR + subgraph review["One review"] + direction TB + cs["C# files"] + pyf["Python files"] + end + + subgraph csharp["C# provider pair"] + direction TB + csHead["head"] + csBase["base"] + end + + subgraph python["Python provider pair"] + direction TB + pyHead["head"] + pyBase["base"] + end + + cs --> csharp + pyf --> python + + csharp -. "in process: Roslyn
or out of process: Stampeded.RoslynLsp
(base derived with ?side=base)" .-> roslynImpl["Roslyn"] + pyHead -. "pyright on the head worktree" .-> p1["language server 1"] + pyBase -. "pyright on a base worktree" .-> p2["language server 2"] +``` + +Everything the review asks about source goes through `ISemanticProvider`: what a token means, +where it is declared, who uses it, which member owns a line, the outline, and the fold regions. +A symbol is a `SymbolRef`, a file and a position, because that is all a language server can be +handed back. + +A review holds one provider pair per language, head and base, and `SemanticsFor(oldSide, +relPath)` picks the pair by file extension. A Python server starts only for a review that +changes Python. Its base side is a *second* process on a checkout of the base revision: a +language server holds one text per file, so two revisions cannot share one. Only the tool's own +Roslyn server derives base from head, which is what `?side=base` on a document URI means. + +Two implementations exist: + +- **`RoslynWorkspaceService`** loads the solution in process. It is the default for C# and + the only provider that can also decompile a definition without source (ILSpy's decompiler, + offered as a separate capability rather than part of the interface, because a language + server cannot answer it). +- **`LspSemanticProvider`** speaks JSON-RPC to any language server over its stdin and stdout. + `Stampeded.RoslynLsp` wraps the same Roslyn logic as a server (`STAMPEDED_SEMANTICS=lsp`), + which is what proves the interface is honest. pyright reads Python through the same class. + +A language server needs an interpreter and a project configuration, neither of which is in the +detached worktree. `PythonEnvironment` finds the reader's own environment (`.venv`, an +activated one, or `python3` on PATH) and hands it to the server. When no server is installed, +the review installs basedpyright into `~/.cache/stampeded/python-lsp` and logs every step; +deleting the directory undoes it. + +## Everything external is a command line + +```mermaid +flowchart LR + et["ExternalTool.RunAsync
logs the command and, on failure, why"] + et --> g["git"] + et --> gh["gh"] + et --> az["az"] + et --> dn["dotnet
test, coverage"] + et --> code["code, xdg-open
open a file or link elsewhere"] + lsp["LspConnection
one long-lived process per side"] --> srv["language server
JSON-RPC on stdin/stdout"] + et --> log + lsp --> log["CliLog  -  the Log pane"] +``` + +There is no HTTP client and no token of the tool's own. Authentication, SSO and token refresh +are whatever `gh auth` and `az login` already did for the user, which is also why nothing had to +be registered as an OAuth application anywhere. Every command goes through one function that +writes the command line to the log, and on failure the first line of its output, so the Log +pane can always explain what was run. + +A language server is the one exception to "a command with an exit code": it starts once and +answers until the review closes. It still reaches the log: the command line, slow requests, +and every line of its stderr. + +## Other moving parts + +- **Diff and folds** (`Stampeded.Core/Diff`): the unified diff is built from git's output and + the structural folds come from the semantic provider, so a Python file folds by function the + same way a C# file folds by member. +- **Syntax colours**: TextMate grammars from VS Code, painted per side and transferred onto + the diff rows, because a grammar is a state machine over consecutive lines and a unified + diff is consecutive on neither side. +- **Tests and coverage** (`Stampeded.Core/Testing`): `dotnet test` runs in the review worktree, + results come from the TRX files it wrote, coverage from a cobertura file that + `dotnet-coverage` wraps around the run. Coverage lands as hit counts per line in the diff. +- **Review state** (`ReviewStateStore`): viewed files and draft comments, JSON per pull + request in the user data directory. Viewed flags reset on a new head; drafts are anchored to + content so they re-attach across a force-push. +- **Merge queue** (`Stampeded.Core/MergeQueue`): shared by every reader of the repository + with no server of its own. The queue is a ref on the remote, pointing at a chain of empty + commits whose messages carry the queue; a push that does not fast-forward is rejected by the + server, which is the whole concurrency story. Git in the middle once more. + +## Layers in the repository + +| Project | Holds | Depends on | +| --- | --- | --- | +| `Stampeded.Core` | git, hosts, diff, semantics, LSP client, review store | no UI | +| `Stampeded` | the Avalonia app: panes, documents, `ReviewWorkspace` | `Stampeded.Core` | +| `Stampeded.RoslynLsp` | Roslyn as a language server | `Stampeded.Core` | +| `Stampeded.Core.Tests` | NUnit, real git repositories in temp directories | `Stampeded.Core` |